local adapter = {} adapter.name = "opencodezen" adapter.version = "1.1.0" adapter.endpoint = "/chat/completions" -- ★ hidden:不进用户可见适配器列表(v1.1.0)。本适配器是 OpenCode 免费层的 -- **门禁专用形态**(强制 stream、注入 read 桩工具、会话号服从倒计时公式), -- 直接暴露会被误当成通用 OpenAI 适配器选去接别的上游,结果不可预测。 -- 被 localzen 源引用时内核仍会把它列出来(源编辑器回显),其余场合隐藏。 adapter.hidden = true -- OpenCode Zen(按量付费端点,https://opencode.ai/zen/v1) -- -- 与 OpenCode Go(opencodego.lua,https://opencode.ai/zen/go/v1)是两个不同的 -- 服务,行为要求并不相同,因此各有专用适配器: -- -- * 本适配器(Zen)服务免费池 / 按量付费池。免费模型必须带 opencode 客户端 -- 指纹(UA + x-opencode-*),否则上游拒绝:"free tier can only be used in -- OpenCode"。 -- ★ reasoning_content:**保留**(与 Go 侧一致)。此前这里写「剥掉」是基于 -- 「免费池以非 thinking 模型为主、回传会报错」的假设,**生产实测已证伪** —— -- 剥掉恰恰会引发 -- The `reasoning_content` in the thinking mode must be passed back to the API -- (详见 transform_request 内的长注释:失败只在「上一轮真的产出了 reasoning」 -- 时才出现,故表现为同一会话里成功/失败交替,极难归因)。 -- 参考的共享序列化器(openai-compat.ts)也是「有就回传」。 -- * Go 订阅读者请看 opencodego.lua:那边**必须**回传 reasoning_content, -- 否则 thinking 模式直接 400。 -- -- 缺 x-opencode-client/session/request/project 身份头会被判为匿名客户端, -- 部分模型在带 tools 时上游直接失败(流式返回单 chunk -- "finish_reason":"network_error" 空 content,非流式 503 -- "Endpoint is unavailable",表现为"空回复")。 -- ============================================================================ -- ★ opencode Zen 免费层门禁(2026-10-15 实测解出,逐条对齐官方 CLI) -- -- 上游对免费池有**三个**必须同时满足的条件,缺任一则 403 FreeTierError -- ("free tier can only be used from within OpenCode"),与 UA / TLS 指纹 / 凭据来源 -- 均无关: -- -- 1. 端点必须是 /inference/v1/chat/completions(不是 /zen/v1) -- —— 由源的 base_url 决定,不在本适配器内。 -- 2. 必须同时发 x-opencode-session **与** x-opencode-session-id(值相同)。 -- 两个 id 的 12 位 hex 段由**确定公式**给出(见下),不是随机数; -- 时间容差实测 ≥ 60 秒,故 os.time() 的秒精度足够。 -- 3. 请求的 tools 里必须有一个名为 read 的工具,且其 parameters.properties -- **非空**。这是唯一的 tools 要求:实测抽掉官方 11 个工具中的任意一个均为 -- 200,**只有抽掉 read 变 403**;且 properties 里放任意属性名即可, -- 描述文字 / $schema / required 都不需要(properties 为空对象则 403)。 -- ⇒ 无 tools 的纯对话请求会被拒,故 transform_request 需兜底注入桩工具。 -- 4. **stream 必须为 true**:实测同为合法请求,stream=false 恒 403、stream=true 恒 200 -- (包括 max_tokens 20/60/200/1000/32000 均 200)。 -- ⇒ 非流式请求会被拒,故 transform_request 强制 stream=true。 -- -- 明确**不影响**通过的(均实测):system 内容(含无 system)、消息条数 -- (2/3/4/6 条均 200)、凭据(Bearer public 与普通 API key 均可)、 -- x-opencode-project / x-opencode-client / UA 里的 opencode/ 前缀。 -- -- ⚠️ 参考实现 dsh-our-free-model 的门禁桩(bash + read,但无真实 properties) -- 恰好卡在第 3 条上 —— 即它「伪装需跟进上游更新」的真实原因。 adapter.headers = { ["User-Agent"] = "opencode/1.19.0 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14", } -- rand_id:按官方的 48-bit 公式生成 12 位 hex 段 + 14 位 base62 随机段。 -- -- 公式逆自官方 CLI 本地库(~/.local/share/opencode/opencode.db 的 session 表, -- 用 11/12 条真实 id 逐条比对完全一致): -- -- MASK = 2^48 - 1 -- K = 0x1afffffffffffe (注意不是 27<<48,两者差 2) -- session 段 = (K - now_ms * 4096) & MASK ← **倒计时式**:Δseg/Δms ≡ -4096 -- request 段 = (now_ms * 16) & MASK -- -- 为什么是「倒计时」而不是时间戳:0x1afffffffffffe 减去 ms*2^12,所以 seg 随 -- 时间**递减**。用 f"{ms:012x}" 之类的正向写法必然不对(我为此错过一轮)。 -- -- ⚠️ 精度:now_ms*4096 ≈ 7.3e15 < 2^53 ≈ 9.0e15,在 double 安全整数范围内, -- 故 LuaJIT 的默认数字类型(double)直接算即可,无需大整数库。 local B62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" local MASK48 = 281474976710655 -- 2^48 - 1 local ID_K = 0x1afffffffffffe local function rand_tail(n) local out = {} for i = 1, n do local v = 0 if math and math.random then v = math.random(1, 62) else -- 无 math 时用时间做退化熵源:只要唯一,不要求密码学强度。 v = (os.time() * 7 + i * 13) % 62 + 1 end out[i] = B62:sub(v, v) end return table.concat(out) end -- now_ms:毫秒。沙箱只有 os.time()(秒)与 os.clock()(CPU 时间,无用), -- 而上游时间容差实测 ≥ 60 秒,故秒精度足够;毫秒位补 0。 -- meta.timestamp 由内核给出(同为秒),优先用它以便同一请求内自洽。 local function now_ms(meta) local t = tonumber(meta and meta.timestamp) or (os.time and os.time()) or 0 return t * 1000 end local function rand_id(prefix, ms, kind, affinity) local seg if kind == "session" then seg = (ID_K - ms * 4096) % (MASK48 + 1) else seg = (ms * 16) % (MASK48 + 1) end -- %012x:12 位十六进制,不足补零 -- affinity(可空):会话亲和键。非空时其 8 位 hex 摘要混入 tail —— -- 同一分钟桶内不同会话得到不同会号;hex 段不受影响(门禁只校验它)。 local tail if type(affinity) == "string" and affinity ~= "" then tail = string.sub(sha256_hex(affinity), 1, 8) .. rand_tail(6) else tail = rand_tail(14) end return prefix .. string.format("%012x", seg) .. tail end -- session_bucket_ms 决定上游会话号的**稳定周期**(按整分钟对齐)。 -- -- 为什么需要稳定:上游前缀缓存是会话级的(实测:同一段 6032 token 提示词, -- 固定会号第 2 次命中 5888;每请求换会号则恒为 0 命中)。而免费层门禁要求 -- x-opencode-session 的 12 位 hex 段服从「倒计时」公式(见 rand_id 注释), -- 所以不能真的「随便挑一个固定值」,只能选一个**时间桶**。 -- -- 取整分钟(而非秒):同一分钟内的所有请求共享一个会话号 ⇒ 既保留缓存命中, -- 又不超出上游的时间容差。实测容差 ≥ 1 小时(-3600s 仍 200), -- 所以一分钟桶有很大余量,即便上游收紧到几分钟也仍然安全。 -- ★ 会话亲和(v1.1.0,移植自 _local_archive 版本):整分钟桶让**所有请求** -- 共享会号,多会话并发时互相污染缓存键。改进:桶内再按**会话身份**分桶—— -- 1) meta.client_session(pi 等真会话客户端自带,会话恒定) -- 2) 退路:历史里第一条 user 消息做会话指纹(通用 OpenAI 客户端不发任何 -- 会话标识,实测抓包确认;第一条 user 消息在整个会话里恒定) -- 3) 再退:无 user 消息时退回纯时间桶(与 v1.0 行为一致) -- 实现:把会话身份 hash 进 14 位 base62 随机段(hex 段仍服从门禁公式)。 local function conversation_fingerprint(body) if type(body) ~= "string" or body == "" then return "" end local ok, req = pcall(json.decode, body) if not ok or type(req) ~= "table" then return "" end for _, m in ipairs(req.messages or {}) do if type(m) == "table" and m.role == "user" then local c = m.content if type(c) == "string" then if c ~= "" then return c end elseif type(c) == "table" then for _, part in ipairs(c) do if type(part) == "table" and part.type == "text" and part.text then return part.text end end end return "" end end return "" end local function session_affinity_key(meta) local cs = meta and meta.client_session if type(cs) == "string" and cs ~= "" then return cs end return conversation_fingerprint(meta and meta.body) end local function session_bucket_ms(meta) local ms = now_ms(meta) return ms - (ms % 60000) end function adapter.build_headers(meta) local ms = now_ms(meta) local bucket = session_bucket_ms(meta) -- 免费层要求两个头都在且值相同(对齐官方 CLI;缺任一个则 403)。 -- 会话亲和键(可能为空)混入 tail:同一分钟桶内不同会话得到不同会号, -- 同一会话跨分钟仍共享指纹 ⇒ 前缀缓存按会话命中而非按分钟命中。 local sid = rand_id("ses_", bucket, "session", session_affinity_key(meta)) return { ["User-Agent"] = adapter.headers["User-Agent"], ["x-opencode-client"] = "cli", -- project 固定:同网关实例共享一个工作区身份(非门禁必需,但官方也发) ["x-opencode-project"] = "global", ["x-opencode-session"] = sid, -- ★ 两个都要:上游对免费池同时校验 x-opencode-session 与这个头。 ["x-opencode-session-id"] = sid, -- request id 每请求唯一(不参与缓存键),用当前时间而非桶,避免同桶内重复 ["x-opencode-request"] = rand_id("msg_", ms, "request"), } end -- ============================================================================ -- ★ 免费层 tools 兜底:上游要求 tools 里必须有一个名为 read、且 -- parameters.properties 非空的工具(实测唯一要求;抽掉它即 403)。 -- 纯对话请求(客户端没传 tools)会被拒,故这里注入一个桩工具。 -- -- 只补缺,不改用户已有的 tools:用户自带 read 时原样透传(官方 CLI 就是这么发的), -- 用户带的其它工具保留不动。 local FREE_GATE_TOOL = "read" local function ensure_free_gate_tool(req) local tools = req.tools if type(tools) ~= "table" then tools = {} req.tools = tools end for _, t in ipairs(tools) do if type(t) == "table" then local fn = t["function"] -- 注意:function 是 Lua 关键字,必须用 req["function"] 形式取下标。 if type(fn) == "table" and fn.name == FREE_GATE_TOOL then local p = fn.parameters -- 已有 read 但 properties 为空 ⇒ 补上;否则原样保留用户定义。 if type(p) == "table" and type(p.properties) == "table" and next(p.properties) ~= nil then return end fn.parameters = { type = "object", properties = { filePath = { type = "string", description = "Path to read" } } } return end end end tools[#tools + 1] = { type = "function", ["function"] = { name = FREE_GATE_TOOL, description = "Read a file from the local filesystem.", parameters = { type = "object", properties = { filePath = { type = "string", description = "Path to read" } }, required = { "filePath" } }, }, } end -- OpenAI /chat/completions format (pass-through, strip provider-specific fields) -- zen 上游 schema 只接受 text content part(无视觉/音频能力):多模态 part -- (image_url / input_audio / file 等)一律剥离。剥离后 content 变空的消息 -- 若不再携带 tool_calls / tool_call_id 才整条丢弃(避免上游 -- "unknown variant `image_url`, expected `text`");带工具调用的必须保留, -- 否则会把紧随其后的 tool 结果变成孤儿,模型会反复重发同一个调用。 -- zen 上游角色白名单只有 system / user / assistant / tool / latest_reminder: -- OpenAI 的 developer(及 function 等)不在其中,直接透传会触发上游 -- "unknown variant `developer`, expected one of ..." 错误;统一归一化为 system。 local ROLE_WHITELIST = { system = true, user = true, assistant = true, tool = true, latest_reminder = true, } function adapter.transform_request(raw_body) local ok, req = pcall(json.decode, raw_body) if not ok then return raw_body end -- ★ 免费层 gate:tools 里必须有 read(且 parameters.properties 非空)。 -- 放在最前面:它决定本请求会不会被 403,与后面的字段清理无关。 -- 对所有模型都注入:免费池与付费池共用同一层形状检查,多一个桩工具无害 -- (官方 CLI 本来也带全套工具),但缺了它会让免费模型整体失效。 ensure_free_gate_tool(req) -- ★ 免费层只接受流式:实测 stream=false 恒 403(同样 body 改 true 即 200)。 -- 转成流式由上游以 SSE 回应,内核本就会聚合/透传流式响应,故对客户端透明。 req.stream = true req.disable_thinking = nil req.extra_body = nil -- stream_options is only valid alongside stream:true; sending it on a -- non-streaming request is rejected by strict upstreams. if req.stream then if type(req.stream_options) ~= "table" then req.stream_options = {} end req.stream_options.include_usage = true end if req.messages then local kept = {} for _, msg in ipairs(req.messages) do if type(msg.role) == "string" and not ROLE_WHITELIST[msg.role] then msg.role = "system" end -- ★ 必须**保留** reasoning_content(不再剥离)。 -- -- 历史:这里原先写 `msg.reasoning_content = nil`,理由是「Zen 免费池 -- 不接受回传」。**生产实测证伪**(2026-10-16): -- provider localzen: api error 400: Upstream request failed: -- [invalid_request_error] The `reasoning_content` in the thinking -- mode must be passed back to the API. -- 失败形态很隐蔽:**同一模型同一会话里成功与失败交替出现** —— -- 因为只有当上一轮**真的产出了 reasoning**(thinking 模型、 -- reasoning_effort 开启、长上下文)时,回放该历史才会被拒; -- 剥掉之后,客户端看到的 502 与「上一轮想过」这件事毫无关联。 -- 审计文件里能看到成串 502 紧跟在一个 prompt_tokens=139330 的 -- 成功请求之后 —— 那正是「想了、但下一轮我们没把它还回去」。 -- -- 参考实现(`openai-compat.ts` 的 serializeMessages,opencode 适配器 -- 直接复用)在 assistant 回合携带 reasoning 时**就带上**该字段: -- `...reasoning.length > 0 ? { reasoning_content: reasoning } : {}` -- 即「有就回传」,没有额外条件 —— 与本适配器此前的假设方向相反。 -- -- ⚠️ 不改成「仅当 thinking 开启才回传」:我们**无法**从请求里可靠 -- 判定上游是否处于 thinking 模式(真正知道的是上游)。而「有就回传」 -- 是零风险方向 —— 上游不需要时忽略即可(实测传与不传同为 200), -- 需要时缺了就是硬 400。 if type(msg.reasoning_content) == "string" and msg.reasoning_content == "" then msg.reasoning_content = nil end local drop = false if type(msg.content) == "table" then local parts = {} for _, part in ipairs(msg.content) do if type(part) == "table" and part.type ~= nil and part.type ~= "text" then -- multimodal part not supported by zen else table.insert(parts, part) end end if #parts == 0 then -- Content collapsed to nothing after stripping unsupported -- parts. A message that still carries a tool call must -- NEVER be dropped: the very next message is its tool -- result, and dropping the call orphans that result. The -- model then sees a result for a call it never made and -- re-issues the same tool call on every turn (observed as -- an infinite "repeated tool call" loop). if (type(msg.tool_calls) == "table" and #msg.tool_calls > 0) or msg.tool_call_id ~= nil then msg.content = "" else drop = true end else msg.content = parts end end if not drop then table.insert(kept, msg) end end req.messages = kept end return json.encode(req) end -- ★ aggregate_sse:把 SSE 流(openai chat.completion.chunk 形状)聚合成一个非流式 -- 响应对象,供 transform_response 在「客户端要非流式、上游却回流式」时使用。 -- 返回 JSON 字符串;无法聚合(无任何 chunk)时返回 nil。 -- -- 只处理 content / reasoning_content / finish_reason / usage 四类增量: -- 它们覆盖了内核需要从响应里取的全部字段(正文、结束原因、token 计数)。 local function aggregate_sse(raw) local content, reasoning, finish = {}, {}, "" local usage, id, model, created = nil, nil, nil, nil local seen = false for line in (raw .. "\n"):gmatch("([^\n]*)\n") do local s = line:gsub("^%s+", ""):gsub("%s+$", "") if s:sub(1, 5) == "data:" then local payload = s:sub(6):gsub("^%s+", "") if payload ~= "" and payload ~= "[DONE]" then local ok, d = pcall(json.decode, payload) if ok and type(d) == "table" then seen = true id = id or d.id model = model or d.model created = created or d.created if type(d.usage) == "table" then usage = d.usage end local ch = d.choices and d.choices[1] if type(ch) == "table" then if ch.finish_reason and ch.finish_reason ~= "" then finish = ch.finish_reason end local dl = ch.delta if type(dl) == "table" then if type(dl.content) == "string" then content[#content + 1] = dl.content end if type(dl.reasoning_content) == "string" then reasoning[#reasoning + 1] = dl.reasoning_content end end end end end end end if not seen then return nil end local msg = { role = "assistant", content = table.concat(content) } local rc = table.concat(reasoning) if rc ~= "" then msg.reasoning_content = rc end local out = { id = id or "", object = "chat.completion", created = created or 0, model = model or "", choices = { { index = 0, message = msg, finish_reason = finish ~= "" and finish or "stop" } }, } if usage then out.usage = usage end local ok2, enc = pcall(json.encode, out) if not ok2 then return nil end return enc end function adapter.transform_response(raw_body) -- ★ 免费层只接受 stream=true(见文件头门禁第 4 条),所以本适配器把每个请求 -- 都改成了流式 —— 但内核是按**客户端意图**选路径的:客户端发非流式时, -- 内核走 Provider.Chat 并调本函数(期望 JSON),而上游回的是 SSE。 -- 若在这里直接 json.decode 就会得到 "invalid character 'd'" 错误。 -- -- 因此本函数必须先识别 SSE 并自行聚合,再走原来的 JSON 路径。 -- 这样保持内核对上游协议无感(内核不知道也不该知道上游选了流式)。 if type(raw_body) == "string" and (raw_body:sub(1, 6) == "data: " or raw_body:find("\ndata: ", 1, true)) then local aggregated = aggregate_sse(raw_body) if aggregated ~= nil then local ok2, resp2 = pcall(json.decode, aggregated) if ok2 and type(resp2) == "table" then raw_body = aggregated end end end local ok, resp = pcall(json.decode, raw_body) if not ok or resp == nil then return raw_body end local unified = { content = "", finish_reason = "", token_usage = { prompt = 0, completion = 0, total = 0 } } if type(resp.usage) == "table" then unified.token_usage.prompt = resp.usage.prompt_tokens or 0 unified.token_usage.completion = resp.usage.completion_tokens or 0 unified.token_usage.total = resp.usage.total_tokens or 0 local hit = 0 if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then hit = resp.usage.prompt_tokens_details.cached_tokens unified.token_usage.prompt_tokens_details = { cached_tokens = hit } end if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0 if hit == 0 then unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens } end end -- 上游报告输出里有多少是思考 token。不给客户端的话,无法判断 -- completion_tokens 里多少是「可见回答」、多少是「思考」(都按输出计费)。 local ctd = resp.usage.completion_tokens_details if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then unified.token_usage.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens } end end -- 本次调用的费用,上游放在**顶层**且是**字符串**(如 "0"、"0.0012")。 -- Go 订阅是包月制,恒为 "0";只有 Zen 按量付费模型才有信息量。 -- 原样透传:网关不解析、不换算、不汇总 —— 它只是上游事实的搬运者。 if resp.cost ~= nil then unified.cost = tostring(resp.cost) end if type(resp.choices) == "table" and #resp.choices > 0 then local ch = resp.choices[1] if type(ch.message) == "table" then unified.content = ch.message.content or "" local reasoning = ch.message.reasoning_content or ch.message.reasoning if reasoning then unified.reasoning_content = reasoning end if type(ch.message.tool_calls) == "table" then local tcs = {} for _, tc in ipairs(ch.message.tool_calls) do local args_ok, args = pcall(json.decode, tc["function"].arguments) if not args_ok then args = {} end table.insert(tcs, { id = tc.id, type = tc.type or "function", name = tc["function"].name, arguments = args }) end unified.tool_calls = tcs end end unified.finish_reason = ch.finish_reason or "" end return json.encode(unified) end function adapter.transform_stream_chunk(raw_chunk) local ok, chunk = pcall(json.decode, raw_chunk) if not ok then return "" end -- OpenAI-style streams may attach usage to a chunk with empty choices -- (the final usage chunk). Preserve it; the gateway emits it as the -- terminal usage chunk. Note: keys must match Go's TokenUsage json tags -- (prompt/completion/total); the gateway re-emits standard *_tokens. local uses = nil if type(chunk.usage) == "table" then uses = { prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0, completion = chunk.usage.completion_tokens or chunk.usage.completion or 0, total = chunk.usage.total_tokens or chunk.usage.total or 0, } -- 独立字段与 prompt_tokens_details **并存**透传,不要写成 elseif: -- 上游命中缓存时两者都发,非流式路径也是两个都带。写成 elseif 会让 -- 流式丢掉 prompt_cache_hit_tokens/miss,与同一源的**非流式**产出不一致 -- (dsh 优先读 details,但只认独立字段的老客户端会看不到缓存)。 if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens } end if (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0 if uses.prompt_tokens_details == nil then uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens } end end local ctd = chunk.usage.completion_tokens_details if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then uses.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens } end end if not chunk.choices or #chunk.choices == 0 then -- 空 choices 的帧既不是内容也不是结束信号。上游发两种: -- {"choices":[],"usage":{...}} —— 终帧用量 -- {"choices":[],"cost":"0"} —— 单独的费用帧 -- 两者可能落在同一帧上,必须都转出去;早退只带 usage 会把费用丢干净。 local out = { done = false } if uses ~= nil then out.usage = uses end if chunk.cost ~= nil then out.cost = tostring(chunk.cost) end if uses == nil and chunk.cost == nil then return "" end return json.encode(out) end local delta = chunk.choices[1].delta or {} local fr = chunk.choices[1].finish_reason local finish = (type(fr) == "string" and fr ~= "") and fr or nil local unified = { content = delta.content or "", done = (finish ~= nil) } if finish then unified.finish_reason = finish end if uses ~= nil then unified.usage = uses end -- zen 用 reasoning 字段承载推理文本(OpenAI 惯例是 reasoning_content) local reasoning = delta.reasoning_content or delta.reasoning if reasoning then unified.reasoning_content = reasoning end if delta.tool_calls then -- pass raw streaming fragments through; OpenAI clients accumulate index+id+name+arguments unified.tool_calls = delta.tool_calls end 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