Commit Graph

7 Commits

Author SHA1 Message Date
9e5fec08b8 feat(opencode): Zen 免费层可用 —— 门禁四要素 + id 公式逆出
问题:localzen 源(opencode Zen)整个免费池恒 403 FreeTierError
("free tier can only be used from within OpenCode"),space-bunny-free
等免费模型全部失效。

排查(装官方 CLI 抓包 + 逆其本地 opencode.db,见下):门禁是**四条请求形状
条件**,与 TLS 指纹 / UA / 凭据来源 / system 内容 / 消息条数全部无关:

  1. 端点必须是 /inference/v1/chat/completions(原来打的 /zen/v1 恒 403)
  2. 必须同时发 x-opencode-session 与 x-opencode-session-id(值相同)
  3. tools 里必须有一个名为 read 的工具,且其 parameters.properties **非空**
     (实测:抽掉官方 11 个工具中任意一个仍 200,**只有抽掉 read 变 403**;
      properties 里放任意属性名即可,描述/$schema/required 都不需要;
      改成 properties:{} 或只有 type:object 则 403)
  4. stream 必须为 true(同 body 改 false 恒 403)

★ 两个 id 的 12 位 hex 段由**确定公式**给出(逆自 opencode.db 的 session 表,
用 11/12 条真实 id 逐条比对完全一致):

  MASK = 2^48-1 ; K = 0x1afffffffffffe   (注意不是 27<<48,两者差 2)
  session 段 = (K - now_ms*4096) & MASK   ← 倒计时式,Δseg/Δms ≡ -2^12
  request 段 = (now_ms*16) & MASK

  时间容差实测 ≥1 小时(-3600s 仍 200),故 os.time() 的秒精度足够;
  now_ms*4096 ≈ 7.3e15 < 2^53,在 double 安全整数范围内。

实现:
- id 生成换成上述公式(原用 sha256 派生,完全不符)
- 补 x-opencode-session-id;两个头同值
- 会话号按**整分钟桶**取:既满足门禁的倒计时公式,又保留上游前缀缓存命中
  (实测 cached_tokens: 256 生效)
- tools 兜底:客户端没带 tools 时注入带非空 properties 的 read 桩;
  用户自带 read 则原样保留,不覆盖其定义
- 强制 stream=true
- ★ transform_response 支持 SSE 聚合:免费层只能流式,而客户端可能发非流式,
  此时内核按客户端意图走 Chat/transform_response(期望 JSON)⇒ 必须自行把
  SSE 聚合成 chat.completion,否则报 "invalid character 'd'"

配置(已在生产落地):
- localzen base_url: /zen/v1 → /inference/v1(billing 规则同步)
- 免费模型 1 个 → 8 个(含 deepseek-v4.1-flash-free)
- 适配器移入 internal/lua/adapters/,随 deploy.sh 自动投放

判据(4 条):
- TestOpencodeZenAdapterE2E                       头公式/两头部同值/tools 兜底/不覆盖
- TestOpencodeZenAggregatesSSEForNonStreamClient   SSE → chat.completion 聚合
- TestOpencodeZenLiveE2E                          ★真上游:纯对话/自定义system/多轮/算术
- TestOpencodeZenLiveStreamThroughAdapter         ★真上游:完整流解析链(25 chunk)
  (后两条需 LLMSPROXY_LIVE_ZEN=1 + 真实凭据,默认 skip)

生产 e2e 实测:非流式返回 GW-NONSTREAM-OK(usage 含 cached_tokens 256);
流式原生 SSE 透传;多轮+工具返回正确结果(12*12=144 → 144+1=145)。

回归:go test -tags luajit ./... 10 包全绿。

★ 方法论教训(本轮绕了 4 轮、自我推翻 3 次):
- 一次只改一个变量。我多次同时改 system+条数+tools,拟合出"消息条数上限 3"
  这种假规律,后续每次反转都源于此 —— 真相是那些测试**都缺 tools**。
- 重放成功 ≠ 解释成功。第一次逐字节重放就 200,我却当"重放了官方刚发的请求",
  继续追 TLS 指纹/端点/形状。正确做法:一旦重放成功立刻做**单变量消去**。
- "毒药短语"陷阱:我二分到 963/964 字符精度锁定 Never use tools 是触发点,
  但它单独出现全 200 —— 真相是缺 read 的 properties。凡"某内容触发"的规律
  必须交叉验证。
2026-10-15 20:30:33 +08:00
6113924655 chore(adapters): 移除非官方调用方式的适配器,保留官方适配器与模板
移除 4 个靠伪造客户端身份/绕过 WAF 才能工作的适配器,内核不再发行任何
非官方调用方式:

- opencodezen / opencode / opencodego:靠 x-opencode-* 客户端指纹冒充官方
  客户端;zen 免费池明确要求「free tier can only be used in OpenCode」
- agentrouter:伪造 Origin: qwen.ai + Referer + X-Requested-With 绕过阿里云 WAF

保留 12 个官方适配器(openai/deepseek/anthropic/gemini/github/groq/mistral/
ollama/kimicode/qwen38/sensenova/trae),其中 sensenova/qwen38 实测零伪装特征。

本地保留两处:adapters/_local_archive/(gitignored 非递归子目录,不加载)
与线上 /etc/llmsproxy/adapters/(adapter_dir 权威副本,cp -f 不删文件)。

测试同步:删除仅服务于被移除适配器的 11 个测试函数与 usage_cost_test.go;
改写 2 处借 opencode 之名的 provider 测试为用 openai 的 HTML 错误页压缩能力
验证同一契约(适配器钩子拥有错误格式、原文不外泄)。

验证:go test -tags luajit ./... 全绿;二进制内 x-opencode-client /
QwenCode/0.2.0 / qwen.ai 等指纹字符串计数归零。
2026-10-11 08:17:56 +08:00
97bb9c6ef4 feat(opencode): 透传 completion_tokens_details.reasoning_tokens 与上游 cost
回答「opencodego 的用量与费用透传呢」时逐字段核对上游产出,发现 usage 漏了
一项、费用整项丢失。

## 上游实际发什么(实测 opencode.ai/zen/go/v1)

  {
    "choices": [...],
    "usage": { "prompt_tokens": 37, "completion_tokens": 40, "total_tokens": 77,
               "prompt_cache_hit_tokens": 0, "prompt_cache_miss_tokens": 37,
               "prompt_tokens_details": {"cached_tokens": 0},
               "completion_tokens_details": {"reasoning_tokens": 40} },
    "cost": "0"
  }

cost 在**顶层**且是**字符串**。流式时还会单独发一帧:
{"choices":[],"cost":"0"}

## 此前丢了两样

1. completion_tokens_details.reasoning_tokens —— 输出里有多少是思考 token。
   没有它,客户端无法判断 completion_tokens 里多少是可见回答、多少是思考,
   而两者都按输出计费。
2. cost —— 唯一的费用信号,网关整个丢弃。Go 订阅是包月制恒为 "0",
   但 Zen 按量付费模型(以及未来的其它源)有信息量。

顺带修掉一处流式/非流式不一致:命中缓存时上游同时给
prompt_tokens_details.cached_tokens 和独立的 hit/miss,流式路径写成了 elseif,
只留 details,与非流式产出不同(只认独立字段的老客户端会看不到缓存)。

## 实现

- types.TokenUsage += CompletionTokensDetails;UnifiedResponse / UnifiedChunk += Cost
- opencodego/opencodezen 适配器映射两个字段;空 choices 帧改成 usage 与 cost
  都可带(早退只带 usage 会把同帧的 cost 丢干净 —— 新测试先抓到的就是这个)
- Gateway ChatCompletion / ChatChunk += cost,随终帧发(对齐上游的
  {"choices":[],"cost":"0"} 形态)
- Go 兜底 standardSSEChunk 同步支持(openai 系适配器不再漏 reasoning_tokens;
  纯 cost 帧不再被整体丢弃),新增 rawCostString 兼容字符串/数字两种形态

费用只做**搬运**:不解析、不换算、不汇总 —— 它是上游事实,且只有部分上游提供。

## 验证

经网关实测 gozen:deepseek-v4.1-flash,流式与非流式产出逐字段一致:
  prompt_tokens_details.cached_tokens=6784
  prompt_cache_hit_tokens=6784 / miss=148
  completion_tokens_details.reasoning_tokens=16
  cost="0"

测试:TestOpenCodeCostAndReasoningPassthrough(含「无数据不得凭空造字段」反例)、
TestOpenCodeStreamCacheFieldsMatchNonStream、TestTokenUsageMarshalsCompletionTokensDetails。

(cherry picked from commit c744ee151e)
2026-09-27 17:12:03 +08:00
d81074621a fix(opencode): 采纳客户端真实会话 id + 超窗消息不再被限流措辞封杀
两处都源于同一次排查:pi 到底有没有带会话标识、超窗为什么触发不了压缩。

## 1) 客户端会话 id:pi 一直在发,只是被配置关掉了

之前结论是「通用客户端不发会话 id」——只对了一半。pi 有会话 id,且能发:
pi-ai 的 createClient 在 compat.sendSessionAffinityHeaders 为真时,会把
平台会话 id(uuidv7,整个会话恒定)放到 x-session-affinity /
x-client-request-id / session_id 上。该开关默认 false,而 llmsproxy 的
provider 配置里没开,所以此前一直收不到。

现在网关按优先级采纳:x-session-affinity → x-session-id → session_id →
body 的 prompt_cache_key,并把值经 types.ChatRequest.ClientSession 传到
适配器 meta.client_session。适配器的会号种子优先级变为:
客户端会话 id > 首条 user 消息指纹 > 按源固定。

刻意不采纳 x-client-request-id:名字含 request,部分客户端每请求都换,
拿它当会话会让上游前缀缓存永不命中(pi 总会同时发 x-session-affinity,够用)。

实测:抓 127.0.0.1:8081 的真实 pi 请求,配置打开后收到
x-session-affinity = session_id = x-client-request-id = <子会话 uuid>。
上游缓存确为会话级隔离(同前缀、不同会号:A 冷→命中,B 首次仍为 0),
两个不同 header 值互不命中,反证网关确实采纳了客户端会话 id。

## 2) 超窗消息必须「干净」,否则被同链的限流措辞反向封杀

pi 的 isContextOverflow 先查 NON_OVERFLOW_PATTERNS(/rate limit/、
/too many requests/、Bedrock 前缀),命中就直接判为「非超窗」——**即使
消息里已经有 context_length_exceeded**,pi 也不会压缩重试。

而 AUTO 链的失败消息天生是多 tier 原因的拼接,超窗 tier(gozen 400
maximum context length)常与配额/限流 tier(429 token plan exhausted、
cooling、no free slot)同时出现。此前把 tier 明细原样拼在归一化标记后面,
等于让一条限流 tier 的措辞反过来封杀超窗识别。

现在超窗走独立的干净消息:
  context_length_exceeded: context window is full; reduce the length of
  the messages (gozen/deepseek-v4.1-flash)
只留超窗措辞 + 超窗源名,不带任何其它 tier 的文本。

测试:TestOverflowMessageSurvivesRateLimitedSiblingTier 用 pi 的完整判定
顺序(先 NON_OVERFLOW 后 OVERFLOW)断言同链限流 tier 不再封杀超窗识别;
TestClientSessionFromRequestHeaders / TestClientRequestIDIsNotUsedAsSession /
TestOpenCodePrefersClientSessionID 覆盖会话采纳与优先级。

(cherry picked from commit c9c09b2ba2)
2026-09-27 17:12:03 +08:00
4dd3431c26 feat(opencode): per-conversation session via first-user-message fingerprint
Follow-on to the session-stability fix. "Per source" already made the
prefix cache hit, but it puts every conversation into one upstream session.

Using the client's own session id is not possible: capturing real agent
traffic (tcpdump on 127.0.0.1:8081) shows generic OpenAI clients send NO
session identifier at all — no user / session_id / conversation_id /
metadata in the body, and no session header (only X-Stainless-* plus
User-Agent: pi). The x-opencode-session the Go endpoint asks for is an
OpenCode native-client concept that a generic client cannot forward.

Since history is replayed every turn, the FIRST user message is invariant
for the life of a conversation, so it is used as the conversation
fingerprint. The session becomes stable within a conversation and distinct
across conversations; requests with no user message fall back to per-source
stability.

Measured through the gateway (same 5.7k-token prompt): 2nd call
cached_tokens=5504, and an unrelated conversation gets its own session.

Test: TestOpenCodeSessionIsStableForCache covers same-conversation
stability, cross-conversation separation, per-request request ids and the
sessionless fallback.

(cherry picked from commit 39b48e556e)
2026-09-27 17:12:03 +08:00
3d1407e6fa fix(opencode): make x-opencode-session stable so the upstream prefix cache can hit
The opencode adapters derived x-opencode-session from meta.timestamp, i.e. a
brand new session on every request. The upstream prefix cache is
session-scoped, so no request could ever hit it, and the cache fields the
endpoint does report (prompt_tokens_details.cached_tokens,
prompt_cache_hit_tokens/prompt_cache_miss_tokens) always came back 0/absent.

Measured against the live endpoint, same 6032-token prompt:

  fixed session id   -> 2nd call: hit 5888, miss 144
  rotating session id -> every call: hit 0, miss 6032

Fix: derive the session from the source name (stable), matching how
x-opencode-project is already derived. x-opencode-request stays unique per
request — it is only a request identifier, not part of the cache key.
Applied to both opencodego and opencodezen.

Through the gateway the same prompt now reports, on the 2nd call:
  details={'cached_tokens': 5888} hit=5888 miss=144      (non-streaming)
  prompt_tokens_details={'cached_tokens': 5888}          (streaming)

Test: TestOpenCodeSessionIsStableForCache asserts the session is stable
across requests for one source while the request id differs.

(cherry picked from commit 791d198f47)
2026-09-27 17:12:03 +08:00
b544671f5e feat(adapters): split opencode into opencodezen and opencodego
Zen (https://opencode.ai/zen/v1) and Go (https://opencode.ai/zen/go/v1)
are different services with different requirements, and one shared adapter
could not satisfy both.

The decisive difference is reasoning_content:

  * OpenCode Go runs thinking models and REQUIRES the assistant turn's
    reasoning_content to be echoed back. The shared adapter stripped it
    (msg.reasoning_content = nil), so every replay of a thinking turn
    failed with:
      400 invalid_request_error: The `reasoning_content` in the thinking
      mode must be passed back to the API.
    Reproduced directly: the same request with reasoning_content -> 200,
    without -> 400. That is why the Go tier never worked in an agent loop.

  * The Zen free pool must not receive it, so it keeps stripping.

Both adapters keep the earlier fixes they share (never drop an assistant
turn carrying tool_calls; send stream_options only when streaming; role
whitelist; multimodal strip) and the opencode client fingerprint headers —
the Go endpoint additionally REQUIRES x-opencode-session, which the
adapter already sends.

config: localzen -> opencodezen, gozen -> opencodego.
Verified: all 25 gozen models answer correctly through the gateway with a
thinking + tool_call + tool_result history (was 0/25 before), streaming
included; the Zen free models still pass.

Test: TestOpenCodeGoVsZenReasoning pins the Go-keeps / Zen-strips split.
(cherry picked from commit 71e9040a45)
2026-09-27 17:12:03 +08:00