@ebony-vinyl/dsh-our-free-model 2.7.4 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/upstream.js CHANGED
@@ -15,34 +15,47 @@
15
15
  * Details and the measurements: docs/free-lane-wire.md.
16
16
  *
17
17
  * Re-verified 2026-10-16 against the **v2** client fingerprint (the shape the
18
- * official client has sent since 2026-10-14, and the one this module now
19
- * mirrors). Four facts changed together, and each of them is a 403
20
- * FreeTierError on its own:
18
+ * official client has sent since 2026-10-14). The v2 gate compares declared
19
+ * definitions and headers directly; see the re-verifications in
20
+ * `src/o2a/` — the port of the reference gateway's shaping — for the current
21
+ * fact list.
21
22
  *
22
- * | 项 | v1(旧) | v2(现在) |
23
- * |---|---|---|
24
- * | User-Agent | `opencode/1.18.31` | `opencode/latest/2.0.26/cli` |
25
- * | 请求头 | 带 `x-opencode-request` | **不带**(v1 专用头,带了即 403);另加 `x-session-affinity` / `x-session-id` |
26
- * | 工具闸门 | `bash`/`glob`/`grep`/`read` 四个名字,各带至少一个 property | **`read` 与 `shell` 两个名字,且 description 与参数 schema 必须与官方客户端逐字一致** |
27
- * | `tool_choice` | 无工具时补 `none` | **`none` 即 403**,绝不下发 |
28
-
29
- * The v2 gate compares the declared definitions themselves, so the old "stub
30
- * with a placeholder property" trick is refused: the definitions below are
31
- * verbatim from the v2.0.26 CLI and must not be reworded or reformatted.
32
- * Source of the shape (live-probed upstream by that project, and the reference
33
- * this port follows): 9router `open-sse/utils/opencodeFingerprint.js` +
34
- * `open-sse/executors/opencode.js` at v0.5.104.
23
+ * **2026-10-31: the shaping layer was replaced with a port of
24
+ * https://github.com/jasonxu114514/opencode2api v1.4.6** (commit 58f5667), the
25
+ * reference gateway this lane has tracked since 2026-10-17. The request-shaping
26
+ * logic no longer lives here: {@link module:src/o2a/version} names the ported
27
+ * release, and `src/o2a/SYNC.md` maps every Go file to its JS port together
28
+ * with the deviations this adapter keeps. What changed with the port:
35
29
  *
36
- * Re-verified 2026-10-17 after every request started answering 403: the gate
37
- * also validates the session id's *encoding*, not only its `ses_…` shape. The
38
- * twelve hex digits must be the descending `~(ts_ms << 12 | counter)` value with
39
- * counter 1–63 (low 12 bits `0xfc0–0xfff`); an ascending `ts << 12` value or a
40
- * sha256 digest is refused with 403 FreeTierError. See
41
- * {@link SESSION_DESCENDING_RE} and {@link canonicalizeSession}, and the
42
- * reference implementation at https://github.com/jasonxu114514/opencode2api
43
- * (`internal/identity/request.go`). A live control on the same egress IP showed
44
- * that the official 2.0.32 CLI's exact bytes pass while a fresh ascending id
45
- * 403s, so this is an identity check and not IP rate limiting.
30
+ * - **Every client tool is renamed to an opaque `clint_<digest>` wire name**
31
+ * and the full 13-tool official CLI set is appended from the captured
32
+ * `opencode_tools.json` — the free tier refuses bodies that still carry
33
+ * another client's tool vocabulary (2026-10-30's two-entry alias table was
34
+ * the same discovery in miniature, and is superseded).
35
+ * - **The official CLI system prompt is injected in full** (base prompt +
36
+ * `<env>` + skill listing, per wire), replacing the bare env block; the
37
+ * 2026-10-28 finding that a bare env block suffices stays true, but the full
38
+ * prompt is what the reference sends.
39
+ * - **Per-request 40-hex `x-opencode-project`** replaces the shared `global`
40
+ * bucket: free-tier quota is ledged per project id × egress, so a shared id
41
+ * is one shared bucket that arrives pre-burned.
42
+ * - **Endpoints follow the 2.1 client's provider-family grouping**
43
+ * (`/inference/openai/v1/...`, `/inference/anthropic/v1/messages?beta=true`);
44
+ * the flat `/inference/v1/...` routes still answer but are the older shape.
45
+ * - **Chat bodies gain the client's own ceiling** (`max_completion_tokens`
46
+ * 32000 when the caller named none) and `stream_options.include_usage`.
47
+ * - **Session minting follows the reference's slow clock** (`src/o2a/identity.js`):
48
+ * today's UTC midnight minus a hash-derived offset of at most 30 days, so a
49
+ * session's date never leaves the window the upstream accepts — the general
50
+ * fix for the 2026-10-28 finding that implausible timestamps answer 429.
51
+ * One conversation still maps to one persisted session, and sessions whose
52
+ * stored date has drifted out of the accepted window are re-minted.
53
+ *
54
+ * The adapter keeps the plugin-specific behaviors the reference has no need
55
+ * for (each is a listed deviation in SYNC.md): the placeholder note for the
56
+ * injected officials, `tool_choice: 'none'` stripping (the gate refuses it),
57
+ * the Responses `auto` default, block identity across prompt injection, and
58
+ * the idempotence pass for already-shaped bodies.
46
59
  *
47
60
  * @module src/upstream.js
48
61
  */
@@ -51,6 +64,29 @@ import crypto from 'node:crypto'
51
64
  import fs from 'node:fs'
52
65
  import path from 'node:path'
53
66
  import { DATA_DIR_NAME, resolveDshHome } from './store.js'
67
+ // 发布形态唯一导入点:src/o2a.js 由 scripts/build-legacy-payload.mjs 把
68
+ // src/o2a/ 八个模块(官方资产内联)打成单文件;开发/测试直接跑仓库里的产物。
69
+ import {
70
+ BASE62_ALPHABET,
71
+ CANONICAL_SESSION_RE,
72
+ CLIENT_UA,
73
+ DESCENDING_TIME_RE,
74
+ O2A_VERSION,
75
+ ToolRenameMap,
76
+ canonicalSessionID,
77
+ ensureChatUsage,
78
+ ensureV2CompletionLimit,
79
+ gatewayHeaders as o2aGatewayHeaders,
80
+ injectOfficialPrompt,
81
+ isOfficialSessionID,
82
+ officialTool,
83
+ phaseAgeMs,
84
+ randomID,
85
+ randomProjectID,
86
+ renameAndCompleteTools,
87
+ renamableToolName,
88
+ sessionPhaseMs,
89
+ } from './o2a.js'
54
90
 
55
91
  /**
56
92
  * Overridable so the selftest can point the adapter at a dead port and exercise
@@ -58,97 +94,33 @@ import { DATA_DIR_NAME, resolveDshHome } from './store.js'
58
94
  */
59
95
  export const UPSTREAM_BASE = process.env.OUR_FREE_MODEL_BASE ?? 'https://opencode.ai'
60
96
 
97
+ export { CLIENT_UA }
98
+
61
99
  /**
62
- * The client User-Agent the v2 gate fingerprints.
63
- *
64
- * ⚠️ 形状是 `opencode/<channel>/<version>/<client>`,且 major 必须 ≥ 2;v1 的
65
- * `opencode/1.18.x` 自 2026-10-14 起被上游按名字拒掉(403 FreeTierError)。
66
- * ⚠️ 这个值**必须原样单独发出**:v2 的校验是整串匹配,任何前缀/后缀
67
- * (包括本插件的归属 UA)都会让它不再是「一个 v2 客户端」。归属因此只保留在
68
- * 其余车道(channel pack / EAC / Kilo),本车道发官方那一串。
100
+ * Which opencode2api release the shaping layer currently mirrors. Bumped with
101
+ * `src/o2a/version.js` on every port refresh.
69
102
  */
70
- export const CLIENT_UA = 'opencode/latest/2.0.26/cli'
103
+ export const O2A_PORTED_VERSION = O2A_VERSION
104
+
71
105
  const MAX_SESSION_LENGTH = 256
72
106
  const MAX_TOOL_NAME_LEN = 128
73
- const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
107
+ const BASE62 = BASE62_ALPHABET
74
108
 
75
109
  /**
76
- * The two tools the v2 free-tier gate requires, with OpenCode's own definitions.
77
- *
78
- * ⚠️ **逐字取自 OpenCode v2.0.26 CLI**(与 9router 移植的那份一致)。上游比对
79
- * description 与 parameters:改写措辞、精简 schema、`properties: {}`、改名都会
80
- * 换来 403 FreeTierError(v1 时代「桩工具 + 占位 property」的做法已失效)。
81
- * 不要为了「更好看」动这些字符串。
110
+ * The names the v2 gate validates verbatim. The full official set now ships
111
+ * alongside them (see `src/o2a/official-tools.js`); these two are the ones the
112
+ * gate's tool check is known to read.
82
113
  */
83
- const FINGERPRINT_TOOL_DEFINITIONS = {
84
- read: {
85
- description: 'Read the contents of a file or directory. Supports text files, images, and PDFs. Images and PDFs are presented directly to the model. Each text line is prefixed by its 1-based line number as <line>: <content>. The prefix is for reference and is not part of the file content. Directory entries are returned one per line. Use offset and limit to read large files or directories in sections. Prefer one larger read over many small slices, and use grep to find specific content in large files.',
86
- parameters: {
87
- type: 'object',
88
- properties: {
89
- path: { type: 'string', description: 'File or directory to read' },
90
- offset: { type: 'integer', minimum: 0, description: 'The line or directory entry to start reading from (1-based)' },
91
- limit: { type: 'integer', minimum: 0, description: 'The maximum number of lines or directory entries to read (defaults to and capped at 2000)' },
92
- },
93
- required: ['path'],
94
- additionalProperties: false,
95
- },
96
- },
97
- shell: {
98
- description: 'Execute a shell command and return its output. Commands run on Linux using bash. Quote file paths containing spaces or special characters. Prefer dedicated tools over shell commands when possible. When output is large, the full result is saved to a file and a truncated preview is returned. Rely on automatic truncation unless filtering the output is more useful. Commands accept an optional timeout, background commands have no timeout by default. Background commands return immediately, and you will be notified when they complete.',
99
- parameters: {
100
- type: 'object',
101
- properties: {
102
- command: { type: 'string', description: 'Shell command string to execute' },
103
- workdir: { type: 'string', description: 'Working directory to execute the command in. Defaults to the current working directory. When possible, avoid changing directories in the command and set the working directory here instead.' },
104
- timeout: { type: 'integer', minimum: 0, description: 'Timeout in milliseconds. Set to 0 to disable the timeout. Defaults to 120000 for foreground commands. Background commands have no timeout by default.' },
105
- background: { type: 'boolean', description: 'Run the command in the background and return immediately (useful for dev servers and long-running builds). You do not need to use \'&\' at the end of the command when using this parameter. You will be notified when it completes. DO NOT poll for completion.' },
106
- },
107
- required: ['command'],
108
- additionalProperties: false,
109
- },
110
- },
111
- }
112
-
113
- /** The names the v2 gate requires to be declared (with the definitions above). */
114
114
  export const FINGERPRINT_TOOLS = ['read', 'shell']
115
115
 
116
- /**
117
- * The note appended when a fingerprint tool had to be injected.
118
- *
119
- * ⚠️ v2 的定义是**真工具**(描述与 schema 都来自官方客户端),模型会真的去调它
120
- * —— 实测把 `read` 用在「/etc 里有什么文件」这种问题上。v1 靠「must not be
121
- * used」的桩描述压住,v2 只能用提示词压。措辞是实测选出来的:写成「不可用」会让
122
- * 模型连调用方自己的 `Read`/`Bash` 也一起拒绝;「占位、请用别的工具」才既压住
123
- * 注入项、又不误伤调用方的工具。
124
- */
125
- function placeholderNote(names) {
126
- const list = names.map(name => `\`${name}\``).join(' or ')
127
- return names.length > 1
128
- ? `Do not call the ${list} tools; they are placeholders that do nothing. Use the other tools you have been given instead.`
129
- : `Do not call the ${list} tool; it is a placeholder that does nothing. Use the other tools you have been given instead.`
130
- }
131
-
132
116
  /** Models served by /responses instead of /chat/completions. */
133
117
  const RESPONSES_MODELS = new Set(['muse-spark-1.2-contributor-free', 'muse-spark-1.3-contributor-free'])
134
118
  /** Models served by the Anthropic-shaped /messages endpoint. */
135
119
  const MESSAGES_MODELS = new Set(['union-alpha'])
136
120
 
137
- export const SESSION_RE = /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/
121
+ export const SESSION_RE = CANONICAL_SESSION_RE
138
122
 
139
- /**
140
- * The time half of a canonical session id the upstream free tier actually accepts.
141
- *
142
- * ⚠️ 形状对还不够(2026-10-17 实测):官方客户端用 `Identifier.descending()`
143
- * 铸造时间戳 —— 48 位的 `~(ts_ms << 12 | counter)`,取反把低 12 位变成
144
- * `0xFFF - counter`。上游只放行 counter 1–63,即低 12 位落在 `0xfc0–0xfff`
145
- * (前 9 位任意 hex,第 10 位 `c`–`f`,后两位任意)。任何其它编码 —— 包括
146
- * `ts_ms << 12` 这种升序写法、以及 sha256 摘要 —— 即使 `ses_` 形状完全正确,
147
- * 也会被 403 FreeTierError 拒掉。参考实现:
148
- * https://github.com/jasonxu114514/opencode2api `internal/identity/request.go`
149
- * (`descendingTimePartPattern`)。
150
- */
151
- export const SESSION_DESCENDING_RE = /^[0-9a-f]{9}f[c-f][0-9a-f]$/
123
+ export const SESSION_DESCENDING_RE = DESCENDING_TIME_RE
152
124
 
153
125
  /** Whether `id` is a session the upstream free tier will accept as-is. */
154
126
  export function isCanonicalSession(id) {
@@ -156,37 +128,17 @@ export function isCanonicalSession(id) {
156
128
  }
157
129
 
158
130
  /**
159
- * Re-encode any session id into the descending layout the gate accepts, keeping
160
- * one conversation pinned to one upstream session.
161
- *
162
- * The digest is the same `ses\0<signal>` sha256 the reference gateway uses, so a
163
- * given downstream conversation always maps to the same upstream id. Counter 1
164
- * keeps the low 12 bits at `0xffe`, inside the accepted window.
131
+ * Re-encode any session signal into the canonical layout the gate accepts,
132
+ * keeping one signal on one session for the life of the process
133
+ * (`o2a/identity.canonicalSessionID`): official sessions pass through, and a
134
+ * synthesized one carries a slow-clock timestamp inside the window the
135
+ * upstream accepts, salted per process so a restart re-mints.
165
136
  */
166
137
  export function canonicalizeSession(signal) {
167
- const digest = crypto.createHash('sha256').update(`ses\0${signal}`).digest()
168
- const current = ((BigInt(digest[0]) << 28n | BigInt(digest[1]) << 20n | BigInt(digest[2]) << 12n |
169
- BigInt(digest[3]) << 4n | BigInt(digest[4]) >> 4n) << 12n) | 1n
170
- const inverted = ~current & 0xffffffffffffn
171
- const timeHex = inverted.toString(16).padStart(12, '0')
172
- return `ses_${timeHex}${base62Fixed(digest.subarray(6, 16), 14)}`
173
- }
174
-
175
- /** Render `bytes` as exactly `width` base62 characters (big-endian). */
176
- function base62Fixed(bytes, width) {
177
- let n = 0n
178
- for (const byte of bytes) n = (n << 8n) | BigInt(byte)
179
- const base = 62n
180
- const out = new Array(width)
181
- for (let i = width - 1; i >= 0; i -= 1) {
182
- out[i] = BASE62[Number(n % base)]
183
- n /= base
184
- }
185
- return out.join('')
138
+ return canonicalSessionID(signal)
186
139
  }
187
140
 
188
- const ANTHROPIC_API_VERSION = '2023-06-01'
189
-
141
+ /** Render `bytes` as base62, one character per byte (`base62From`). */
190
142
  function base62From(bytes) {
191
143
  let out = ''
192
144
  for (const byte of bytes) out += BASE62[byte % 62]
@@ -199,9 +151,9 @@ let seq = 0
199
151
  /**
200
152
  * Mint a gateway-shaped canonical session id (time-prefixed, monotonic counter).
201
153
  *
202
- * The encoding is the gate's: `~(ts_ms * 0x1000 + counter)`, whose low 12 bits
203
- * land at `0xfff - counter`. The counter resets every millisecond, so a burst of
204
- * mints inside one millisecond stays inside the accepted 1–63 window.
154
+ * Kept for bookkeeping and tests: the free lane mints through
155
+ * {@link canonicalizeSession} now. The encoding is the gate's:
156
+ * `~(ts_ms * 0x1000 + counter)`, whose low 12 bits land at `0xfff - counter`.
205
157
  */
206
158
  export function mintSessionId(timestamp = Date.now()) {
207
159
  if (timestamp !== lastStamp) { lastStamp = timestamp; seq = 0 }
@@ -225,6 +177,16 @@ export function mintRequestId(timestamp = Date.now()) {
225
177
  return `msg_${hex}${base62From(crypto.randomBytes(14))}`
226
178
  }
227
179
 
180
+ /**
181
+ * The phase age a kept session may carry. The wire's time part is a 36-bit
182
+ * millisecond phase (~795-day cycle, see o2a/identity.js), and the slow clock
183
+ * mints phases at most 30 days old; a 60-day ceiling accepts every id this
184
+ * lane mints (active conversations keep theirs indefinitely) while re-minting
185
+ * the pre-port ids, whose hash-derived phase landed uniformly across the whole
186
+ * cycle.
187
+ */
188
+ const SESSION_PLAUSIBILITY_MS = 60 * 24 * 60 * 60 * 1000
189
+
228
190
  const SESSION_MAP_FILE = 'session-map.json'
229
191
  let sessions
230
192
 
@@ -258,26 +220,35 @@ function rememberSession(seed, id) {
258
220
  } catch { /* non-fatal: the id still works, it just will not survive a restart */ }
259
221
  }
260
222
 
223
+ /**
224
+ * Whether a canonical session's embedded time phase is inside the accepted
225
+ * window. The wire carries a 36-bit millisecond phase (see
226
+ * `o2a/identity.js`), not an absolute date, so plausibility is "dated within
227
+ * the last month of the current phase cycle" — exactly what the slow clock
228
+ * mints and what a hash-derived 1970 landing (the pre-port ids) almost never
229
+ * satisfies.
230
+ */
231
+ function plausibleSession(id) {
232
+ const phase = sessionPhaseMs(id)
233
+ return phase !== null && phaseAgeMs(phase) < SESSION_PLAUSIBILITY_MS
234
+ }
235
+
261
236
  /**
262
237
  * Map one downstream conversation onto one stable upstream session.
263
238
  *
264
- * Mint once using a real creation timestamp and persist the result. Never
265
- * rotate IDs on retries or model errors. A caller's canonical OC ID is kept.
266
- *
267
- * Live A/B controls on 2026-10-28: hash-derived pseudo timestamps returned429
268
- * even for an unused ID; a real timestamp returned200 and repeated use of that
269
- * same ID also returned200. Structural regex validation alone is insufficient.
270
- * Legacy deterministic mappings are migrated once on use; other remembered
271
- * canonical IDs remain unchanged, including across restarts.
239
+ * Mint once and persist the result. Never rotate IDs on retries or model
240
+ * errors. A caller's canonical OC ID is kept. Since the o2a port, a minted id
241
+ * carries a slow-clock date inside the window the upstream accepts, and a
242
+ * remembered id whose date has left that window — an id carried over from the
243
+ * pre-port era, whose hash-derived timestamp landed outside it and answered
244
+ * 429 (live A/B, 2026-10-28) — is re-minted instead of replayed.
272
245
  */
273
246
  export function sessionForConversation(sessionId) {
274
247
  if (typeof sessionId === 'string' && isCanonicalSession(sessionId)) return sessionId.trim()
275
248
  const seed = typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : 'global'
276
249
  const remembered = loadSessions().get(seed)
277
- // A hash-derived pseudo timestamp has the right shape but is not a real
278
- // creation time. Migrate exactly that old mapping once; preserve real IDs.
279
- if (remembered !== undefined && isCanonicalSession(remembered) && remembered !== canonicalizeSession(seed)) return remembered
280
- const minted = mintSessionId()
250
+ if (remembered !== undefined && isCanonicalSession(remembered) && plausibleSession(remembered)) return remembered
251
+ const minted = canonicalizeSession(seed)
281
252
  rememberSession(seed, minted)
282
253
  return minted
283
254
  }
@@ -302,49 +273,45 @@ export function isMessagesModel(modelId) {
302
273
  }
303
274
 
304
275
  /**
305
- * Which upstream path serves this model. The free lane lives under
306
- * `/inference/v1` (measured 2026-10-14, and the shape the console's model table
307
- * documents); `/zen/v1` chat completions answer 403 FreeTierError for free ids.
276
+ * Which upstream path serves this model.
277
+ *
278
+ * The 2.1 client groups `/inference` routes by provider family, so the family
279
+ * segment sits between the origin and the protocol path (a 2.0.32 client
280
+ * posted the protocol path directly, and that flat route still answers — the
281
+ * reference gateway sends the family form, so this lane does too). Anthropic
282
+ * endpoints additionally carry the beta query the client appends.
308
283
  */
309
284
  export function endpointFor(modelId) {
310
- if (isResponsesModel(modelId)) return '/inference/v1/responses'
311
- if (isMessagesModel(modelId)) return '/inference/v1/messages'
312
- return '/inference/v1/chat/completions'
285
+ if (isResponsesModel(modelId)) return '/inference/openai/v1/responses'
286
+ if (isMessagesModel(modelId)) return '/inference/anthropic/v1/messages?beta=true'
287
+ return '/inference/openai/v1/chat/completions'
313
288
  }
314
289
 
315
290
  /** The wire shape one endpoint speaks; drives request encoding and response parsing. */
316
291
  export function wireFor(modelId) {
317
- const path = endpointFor(modelId)
318
- if (path === '/inference/v1/responses') return 'responses'
319
- if (path === '/inference/v1/messages') return 'messages'
292
+ if (isResponsesModel(modelId)) return 'responses'
293
+ if (isMessagesModel(modelId)) return 'messages'
320
294
  return 'chat'
321
295
  }
322
296
 
323
297
  /**
324
- * The headers the gateway fingerprints a genuine client by.
325
- * `Authorization: Bearer public` is the pooled免密 credential — there is no
326
- * per-user secret on this lane.
298
+ * The headers the gateway fingerprints a genuine client by — the v2 set
299
+ * (`o2a/headers.js`), with the pooled免密 credential `Bearer public`.
327
300
  *
328
- * ⚠️ v2 形状(2026-10-14 起),与官方客户端逐项对齐:
329
- * - `x-opencode-session` 与 `x-opencode-session-id` 必须同时存在(缺一即 403);
330
- * - `x-session-affinity` / `x-session-id` 是 v2 新增的同值镜像;
331
- * - **不带 `x-opencode-request`** —— 那是 v1 专用头,v2 收到就回 403;
332
- * - `x-opencode-client: cli` 与官方 CLI 一致。
301
+ * @param {object} options
302
+ * @param {string} options.session - canonical session (all four session headers)
303
+ * @param {boolean} [options.stream] - drives the default `accept`
304
+ * @param {string} [options.accept] - explicit `accept` override
305
+ * @param {string} [options.project] - caller project signal; **defaults to a
306
+ * fresh 40-hex id per request**, the quota-spread the reference mints when
307
+ * the client names no project (the old shared `global` bucket arrived
308
+ * pre-burned). A literal `global` or 40-hex commit passes through verbatim.
309
+ * @param {string} [options.childSession] - child session for
310
+ * `x-opencode-session-id`; defaults to `session`
311
+ * @param {boolean} [options.anthropic] - add the Messages-lane beta header
333
312
  */
334
- export function gatewayHeaders({ session, stream, accept }) {
335
- const headers = {
336
- 'content-type': 'application/json',
337
- 'authorization': 'Bearer public',
338
- 'user-agent': CLIENT_UA,
339
- 'x-opencode-client': 'cli',
340
- 'x-opencode-session': session,
341
- 'x-opencode-session-id': session,
342
- 'x-session-affinity': session,
343
- 'x-session-id': session,
344
- 'x-opencode-project': 'global',
345
- 'accept': accept ?? (stream ? 'text/event-stream' : '*/*'),
346
- }
347
- return headers
313
+ export function gatewayHeaders(options) {
314
+ return o2aGatewayHeaders(options)
348
315
  }
349
316
 
350
317
  function toolNameOf(tool) {
@@ -355,128 +322,59 @@ function toolNameOf(tool) {
355
322
  return ''
356
323
  }
357
324
 
358
- /**
359
- * The gate name when the tool's name is **exactly** one of the two; `''` otherwise.
360
- *
361
- * ⚠️ 大小写变体(`Read`/`Shell`)**不算**闸门工具,原样透传:上游接受它们与
362
- * 注入的 `read`/`shell` 并存(实测),而 v1 那种「按小写归并」会把调用方的
363
- * `Read` 改名成 `read`,正好撞上 v2 的「定义必须逐字一致」判定。
364
- */
365
- function fingerprintKey(name) {
366
- const trimmed = String(name ?? '').trim()
367
- return FINGERPRINT_TOOLS.includes(trimmed) ? trimmed : ''
368
- }
369
-
370
- /** The Chat-shape `{function:{…}}` wrapper, or null for the flat Responses one. */
371
- function functionOf(tool) {
372
- return tool.function && typeof tool.function === 'object' && !Array.isArray(tool.function) ? tool.function : null
373
- }
374
-
375
- /** Where a tool's description and parameter schema sit for one wire shape. */
376
- function definitionOf(tool, { claude, flat }) {
377
- const wrapper = claude || flat ? tool : functionOf(tool)
378
- if (wrapper === null || wrapper === undefined) return null
379
- return { description: wrapper.description, schema: claude ? wrapper.input_schema : wrapper.parameters }
325
+ /** The shape name one `style` argument speaks. */
326
+ function shapeOf(style) {
327
+ return style === true ? 'flat' : style === 'claude' ? 'claude' : 'chat'
380
328
  }
381
329
 
382
- /** Does this tool already carry OpenCode's own definition for `name`? */
383
- function carriesFingerprintDefinition(tool, name, shape) {
384
- const own = definitionOf(tool, shape)
330
+ /** Does this entry carry the verbatim official definition for `name`?
331
+ *
332
+ * Compared against the **rendered** official tool — the shell description is
333
+ * platform-aware (`v2ShellDescription`), so on a Windows host a verbatim
334
+ * official shell says powershell while the raw asset says bash. */
335
+ function carriesOfficialDefinition(entry, name, shape) {
336
+ const own = definitionOf(entry, shape)
385
337
  if (own === null) return false
386
- const want = FINGERPRINT_TOOL_DEFINITIONS[name]
387
- return own.description === want.description
388
- && JSON.stringify(own.schema) === JSON.stringify(want.parameters)
389
- }
390
-
391
- function withToolName(tool, name) {
392
- const fn = functionOf(tool)
393
- return fn ? { ...tool, function: { ...fn, name } } : { ...tool, name }
338
+ const rendered = officialTool(shape, name)
339
+ const wrapper = shape === 'chat' ? rendered.function : rendered
340
+ if (!wrapper) return false
341
+ return own.description === wrapper.description
342
+ && JSON.stringify(own.schema) === JSON.stringify(wrapper.parameters)
394
343
  }
395
344
 
396
- /** The gate's own declaration, in the shape the wire expects. */
397
- function fingerprintTool(name, { claude, flat }) {
398
- const { description, parameters } = FINGERPRINT_TOOL_DEFINITIONS[name]
399
- if (claude) return { name, description, input_schema: parameters }
400
- if (flat) return { type: 'function', name, description, parameters }
401
- return { type: 'function', function: { name, description, parameters } }
402
- }
403
-
404
- /** `sent name -> caller's name` for every tool the alias pass renamed. */
405
- function aliasesByOriginal(map) {
406
- return new Map([...map].map(([alias, original]) => [original, alias]))
407
- }
408
-
409
- /**
410
- * Point an explicit `tool_choice` at the alias of a tool that was renamed.
411
- *
412
- * Only an alias is retargeted: a choice naming a tool we left alone must stay
413
- * byte-identical, and `none` is never emitted (see {@link applyFingerprint}).
414
- */
415
- function retargetToolChoice(body, map) {
416
- if (!body || typeof body !== 'object' || map.size === 0) return
417
- const choice = body.tool_choice
418
- if (!choice || typeof choice !== 'object' || Array.isArray(choice)) return
419
- const aliases = aliasesByOriginal(map)
420
- if (typeof choice.name === 'string') {
421
- const alias = aliases.get(choice.name.trim())
422
- if (alias !== undefined) body.tool_choice = { ...choice, name: alias }
423
- return
424
- }
425
- const fn = choice.function
426
- if (fn && typeof fn === 'object' && !Array.isArray(fn) && typeof fn.name === 'string') {
427
- const alias = aliases.get(fn.name.trim())
428
- if (alias !== undefined) body.tool_choice = { ...choice, function: { ...fn, name: alias } }
429
- }
345
+ /** Where a tool's description and parameter schema sit for one wire shape. */
346
+ function definitionOf(entry, shape) {
347
+ if (!entry || typeof entry !== 'object') return null
348
+ const wrapper = shape === 'chat' ? entry.function : entry
349
+ if (wrapper === null || wrapper === undefined || typeof wrapper !== 'object') return null
350
+ const schema = shape === 'claude' ? wrapper.input_schema : wrapper.parameters
351
+ return { description: wrapper.description, schema }
430
352
  }
431
353
 
432
354
  /**
433
- * Rename prior-turn calls to a renamed tool in the history we are about to send.
355
+ * The note appended when official tools had to be injected.
434
356
  *
435
- * Without this the model reads its own earlier `read` calls as calls to
436
- * OpenCode's injected tool of that name, and the two definitions disagree.
357
+ * ⚠️ v2 的定义是**真工具**(描述与 schema 都来自官方客户端),模型会真的去调它
358
+ * —— 实测把 `read` 用在「/etc 里有什么文件」这种问题上。本插件的宿主并不实现
359
+ * 官方工具集,注入项必须用提示词压住。措辞是实测选出来的:写成「不可用」会让
360
+ * 模型连调用方自己的 `Read`/`Bash` 也一起拒绝;「占位、请用别的工具」才既压住
361
+ * 注入项、又不误伤调用方的工具。(参考实现不发这条 —— 它的下游是真 CLI ——
362
+ * 这是本适配层的既列偏差,见 src/o2a/SYNC.md。)
437
363
  */
438
- function aliasHistoryToolCalls(body, map) {
439
- if (!body || typeof body !== 'object' || map.size === 0) return
440
- const aliases = aliasesByOriginal(map)
441
- const rename = name => (typeof name === 'string' && aliases.has(name) ? aliases.get(name) : name)
442
-
443
- if (Array.isArray(body.input)) {
444
- body.input = body.input.map(item => item?.type === 'function_call' && aliases.has(item.name)
445
- ? { ...item, name: aliases.get(item.name) }
446
- : item)
447
- }
448
- if (Array.isArray(body.messages)) {
449
- body.messages = body.messages.map(message => {
450
- if (message === null || typeof message !== 'object') return message
451
- let next = message
452
- if (Array.isArray(message.content)) {
453
- const content = message.content.map(block => block?.type === 'tool_use' && aliases.has(block.name)
454
- ? { ...block, name: aliases.get(block.name) }
455
- : block)
456
- next = { ...next, content }
457
- }
458
- if (Array.isArray(message.tool_calls)) {
459
- next = {
460
- ...next,
461
- tool_calls: message.tool_calls.map(call => aliases.has(call?.function?.name)
462
- ? { ...call, function: { ...call.function, name: aliases.get(call.function.name) } }
463
- : call),
464
- }
465
- }
466
- return next
467
- })
468
- }
364
+ function placeholderNote(names) {
365
+ const list = names.map(name => `\`${name}\``).join(', ')
366
+ return `The tools ${list} are part of the host CLI's own toolset and are placeholders in this session; they do nothing here. Use the other tools you have been given instead.`
469
367
  }
470
368
 
471
369
  /** Add free-lane context without replacing the caller's system text or blocks. */
472
- function appendSystemContext(body, { claude, flat }, note) {
473
- if (flat) {
370
+ function appendSystemContext(body, shape, note) {
371
+ if (shape === 'flat') {
474
372
  body.instructions = typeof body.instructions === 'string' && body.instructions.trim() !== ''
475
373
  ? `${body.instructions}\n\n${note}`
476
374
  : note
477
375
  return
478
376
  }
479
- if (claude) {
377
+ if (shape === 'claude') {
480
378
  body.system = Array.isArray(body.system)
481
379
  ? [...body.system, { type: 'text', text: note }]
482
380
  : typeof body.system === 'string' && body.system !== '' ? `${body.system}\n\n${note}` : note
@@ -495,7 +393,7 @@ function appendSystemContext(body, { claude, flat }, note) {
495
393
  body.messages = [{ role: 'system', content: note }, ...messages]
496
394
  }
497
395
 
498
- /** Tell the model injected tools are placeholders; never replace caller blocks. */
396
+ /** Tell the model the injected officials are placeholders; never replace caller blocks. */
499
397
  function noteInjectedTools(body, shape, names) {
500
398
  appendSystemContext(body, shape, placeholderNote(names))
501
399
  }
@@ -505,13 +403,17 @@ function noteInjectedTools(body, shape, names) {
505
403
  * Reversible live controls (2026-10-28) returned 429 without it and PONG with it.
506
404
  * Use truthful process metadata, not a copied OC persona or a user workspace guess.
507
405
  * A caller's existing environment block takes precedence and is kept unchanged.
406
+ *
407
+ * Kept for its standalone contract (and the escaping guarantee); the shaping
408
+ * pass now injects the full official prompt — which carries the same env block
409
+ * — instead of this bare one.
508
410
  */
509
411
  export function ensureFreeLaneEnvironment(body, style, environment = {}) {
510
- const shape = { flat: style === true, claude: style === 'claude' }
412
+ const shape = shapeOf(style)
511
413
  const textOf = value => typeof value === 'string' ? value
512
414
  : Array.isArray(value) ? value.map(part => part?.text ?? '').join('\n') : ''
513
- const existing = shape.flat ? textOf(body.instructions)
514
- : shape.claude ? textOf(body.system)
415
+ const existing = shape === 'flat' ? textOf(body.instructions)
416
+ : shape === 'claude' ? textOf(body.system)
515
417
  : (Array.isArray(body.messages) ? body.messages : []).filter(message => message?.role === 'system' || message?.role === 'developer').map(message => textOf(message.content)).join('\n')
516
418
  if (/<env>[\s\S]*?<\/env>/i.test(existing)) return
517
419
  const line = value => String(value).replace(/[\r\n<>]/g, ' ')
@@ -520,109 +422,73 @@ export function ensureFreeLaneEnvironment(body, style, environment = {}) {
520
422
  appendSystemContext(body, shape, `Here is some useful information about the environment you are running in:\n<env>\n Working directory: ${line(cwd)}\n Platform: ${line(platform)}\n</env>`)
521
423
  }
522
424
 
523
- // Reproduced from a real DSH request (2026-10-30): subagent_fork alone, and
524
- // read_image together with working_directory, received 429. Renaming only the
525
- // two entries below restored the complete 80-tool request. Keep all capabilities
526
- // and schemas; map wire names back before the harness dispatches a tool call.
527
- const FREE_LANE_TOOL_ALIASES = Object.freeze({
528
- subagent_fork: 'conversation_delegate',
529
- working_directory: 'workspace_directory',
530
- })
531
-
532
425
  /**
533
- * Satisfy the v2 free-tier fingerprint gate on `body.tools`.
426
+ * Satisfy the v2 free-tier fingerprint on `body`, shaped as the reference
427
+ * gateway shapes it (opencode2api v1.4.6, see the module header):
534
428
  *
535
- * ## v2 规则(每条都对应一次 403,见模块头)
536
- *
537
- * 1. `read` 与 `shell` 必须**声明**,且定义与官方客户端逐字一致;
538
- * 2. 调用方的工具**原样保留**(含 `Read`/`Bash` 这类大小写变体);
539
- * 3. 调用方若**正好**用了 `read`/`shell` 这个名字但定义不同,它被改名成
540
- * `<name>_client[N]` 并走 {@link aliasHistoryToolCalls}/{@link retargetToolChoice}
541
- * 同步历史与 `tool_choice`,响应侧再由重命名表还原成调用方的名字;
542
- * 4. 同名重复声明只留第一个(上游按重复拒);
429
+ * 1. **官方系统提示注入**:base prompt + `<env>` + skills,逐线注入(Chat 折到
430
+ * 首条 system,Responses 折到 `instructions`,Messages 折到 `system`);
431
+ * 已带官方标记的请求体原样跳过。
432
+ * 2. **全部调用方工具改名** 为 `clint_<sha256[:12]>`(外来工具词汇一概不上
433
+ * 线——`ReportFindings`/`ScheduleWakeup` 同时出现即 429,2026-10-30 实测);
434
+ * Responses 内建与 Messages 服务端工具无客户名,原样保留。
435
+ * 3. **补齐官方 13 工具集**(捕获自官方 CLI 的逐字定义,chat 形状带
436
+ * `strict: false`,Windows 主机 shell 描述换 powershell);已带逐字官方
437
+ * 定义的声明视为官方声明,不重命名也不重复补。
438
+ * 4. **引用同步**:`tool_choice`、历史 tool_calls / tool_use / function_call、
439
+ * Responses 的 `input` 调用行全部跟随改名;字符串 choice 原样透传。
543
440
  * 5. **绝不下发 `tool_choice: 'none'`** —— v2 见到它就 403;调用方没给
544
441
  * `tool_choice` 时,只有 Responses 线补 `auto`(官方行为)。
545
- *
546
- * ⚠️ v1 的「把调用方的 shell 提升成 `bash` 槽位」已删除:v2 比对定义,提升会让
547
- * 槽位定义与官方不符(403),而调用方自己的 `pwsh`/`Bash` 本来就能被调用。
442
+ * 6. Chat 请求体补上官方客户端的两个常量字段:流式时
443
+ * `stream_options.include_usage: true`;调用方未给上限时
444
+ * `max_completion_tokens: 32000`。
548
445
  *
549
446
  * @param {object} body - request body, mutated in place
550
447
  * @param {boolean|'claude'} style - true 为 Responses,false 为 Chat,claude 为 Messages
551
- * @returns {Map<string, string>} sent name -> caller's name (for the response side)
448
+ * @returns {ToolRenameMap} sent name -> caller's name(响应侧还原用;带
449
+ * {@link ToolRenameMap.resolve} 的折叠还原)
552
450
  */
553
451
  export function applyFingerprint(body, style) {
554
- const flat = style === true
555
- const claude = style === 'claude'
556
- const shape = { claude, flat }
557
- const map = new Map()
558
- const tools = Array.isArray(body.tools) ? body.tools : []
559
- const taken = new Set(tools.map(toolNameOf).filter(name => name !== ''))
560
- // 两个集合分工不同:`seen` 只用于「同名重复留第一个」(含被改名的那个名字),
561
- // `present` 记录**真正带官方定义留下来**的门禁名 —— 注入判据必须看后者,
562
- // 否则「调用方的 read 被改成 read_client」会被误判成「read 已经声明」,
563
- // 官方定义反而不会补上(门禁仍然 403)。
564
- const seen = new Set()
565
- const present = new Set()
566
- const out = []
567
- const compatibilityAliases = []
452
+ const shape = shapeOf(style)
453
+ if (!body || typeof body !== 'object') return new ToolRenameMap()
454
+
455
+ // 1. Official system prompt (the reference injects before shaping tools).
456
+ injectOfficialPrompt(body, shape)
568
457
 
458
+ // 2.+3. Tool pass: rename client tools, append the official set. Tools that
459
+ // already carry the verbatim official definition are declared officials —
460
+ // kept as-is and not duplicated — so repeated shaping stays a no-op.
461
+ const tools = Array.isArray(body.tools) ? body.tools : []
462
+ const keep = new Set()
569
463
  for (const tool of tools) {
570
- const name = toolNameOf(tool)
571
- const compatibilityBase = Object.hasOwn(FREE_LANE_TOOL_ALIASES, name) ? FREE_LANE_TOOL_ALIASES[name] : undefined
572
- if (compatibilityBase !== undefined) {
573
- let alias = compatibilityBase
574
- for (let i = 2; taken.has(alias); i += 1) alias = `${compatibilityBase}${i}`
575
- taken.add(alias)
576
- map.set(alias, name)
577
- compatibilityAliases.push({ alias, original: name })
578
- out.push(withToolName(tool, alias))
579
- continue
464
+ const name = renamableToolName(shape, tool)
465
+ if (name !== null && carriesOfficialDefinition(tool, name, shape)) {
466
+ keep.add(name)
580
467
  }
581
- const key = fingerprintKey(name)
582
- if (key === '') { out.push(tool); continue }
583
- // 同名重复声明:上游按重复拒,留第一个。
584
- if (seen.has(key)) continue
585
- seen.add(key)
586
- if (carriesFingerprintDefinition(tool, key, shape)) {
587
- out.push(tool)
588
- present.add(key)
589
- continue
590
- }
591
- let alias = `${key}_client`
592
- for (let i = 2; taken.has(alias); i += 1) alias = `${key}_client${i}`
593
- taken.add(alias)
594
- map.set(alias, name)
595
- out.push(withToolName(tool, alias))
596
- }
597
-
598
- const injected = []
599
- for (const name of FINGERPRINT_TOOLS) {
600
- if (present.has(name)) continue
601
- out.push(fingerprintTool(name, shape))
602
- injected.push(name)
603
468
  }
469
+ const { renames, injected } = renameAndCompleteTools(body, shape, { keep })
604
470
 
605
- body.tools = out
606
- retargetToolChoice(body, map)
607
- aliasHistoryToolCalls(body, map)
608
- ensureFreeLaneEnvironment(body, style)
471
+ // 4b. The injected officials need the placeholder note.
609
472
  if (injected.length > 0) noteInjectedTools(body, shape, injected)
610
- if (compatibilityAliases.length > 0) {
611
- appendSystemContext(body, shape, `Tool name mapping for this request: ${compatibilityAliases.map(({ alias, original }) => `use \`${alias}\` for \`${original}\``).join('; ')}. These tools retain their original behavior and parameters.`)
612
- }
613
- // ⚠️ v2 见到 `tool_choice: 'none'` 就回 403(v1 允许,旧代码甚至主动补过它)。
614
- // 兜底清除:调用方(续写路径等)留下的 `none` 会让整轮失败。
473
+
474
+ // 5. Never ship tool_choice none; default the Responses wire to auto.
615
475
  if (body.tool_choice === 'none'
616
476
  || (body.tool_choice !== null && typeof body.tool_choice === 'object' && body.tool_choice.type === 'none')) {
617
477
  delete body.tool_choice
618
478
  }
619
- if (flat && !body.tool_choice) body.tool_choice = 'auto'
620
- return map
479
+ if (shape === 'flat' && !body.tool_choice) body.tool_choice = 'auto'
480
+
481
+ // 6. The chat body's own constants.
482
+ ensureChatUsage(body, shape)
483
+ ensureV2CompletionLimit(body, shape)
484
+ return renames
621
485
  }
622
486
 
623
487
  /** Restore the caller's tool spelling in a streaming delta or a final payload. */
624
488
  export function restoreToolName(name, map) {
625
- if (!map || map.size === 0) return name
489
+ if (!map) return name
490
+ if (typeof map.resolve === 'function') return map.resolve(name)
491
+ if (map.size === 0) return name
626
492
  return map.get(name) ?? name
627
493
  }
628
494
 
@@ -642,4 +508,15 @@ function truncateSession(value) {
642
508
  return trimmed.length > MAX_SESSION_LENGTH ? trimmed.slice(0, MAX_SESSION_LENGTH) : trimmed
643
509
  }
644
510
 
645
- export { truncateSession, ANTHROPIC_API_VERSION, MAX_TOOL_NAME_LEN }
511
+ /** The Anthropic API version the Messages lane sends. */
512
+ const ANTHROPIC_API_VERSION = '2023-06-01'
513
+
514
+ export { truncateSession, randomID, randomProjectId, MAX_TOOL_NAME_LEN, ANTHROPIC_API_VERSION }
515
+
516
+ /**
517
+ * A fresh per-request project id, exposed for callers that want to pin the
518
+ * same id across a retried turn. The lane default is one per request.
519
+ */
520
+ function randomProjectId() {
521
+ return randomProjectID()
522
+ }