@ebony-vinyl/dsh-our-free-model 2.7.4 → 2.8.2
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/README.md +23 -8
- package/client.js +894 -6
- package/cordis.patch.yml +1 -1
- package/index.js +248 -11
- package/package.json +6 -2
- package/src/adapter.js +54 -6
- package/src/aggregate.js +53 -13
- package/src/bun-fetch.js +33 -10
- package/src/catalog.js +238 -58
- package/src/core.js +838 -0
- package/src/egress.js +204 -3
- package/src/engine-parts/win32-x64.000.part +0 -0
- package/src/engine-parts/win32-x64.001.part +0 -0
- package/src/engine-parts/win32-x64.002.part +0 -0
- package/src/engine-parts/win32-x64.003.part +0 -0
- package/src/engine-parts/win32-x64.004.part +0 -0
- package/src/engine-parts/win32-x64.005.part +0 -0
- package/src/engine-parts/win32-x64.006.part +0 -0
- package/src/extensions.js +1888 -0
- package/src/feed.js +6 -0
- package/src/http.js +15 -2
- package/src/kilo.js +55 -5
- package/src/magpie-engine.js +61 -2
- package/src/magpie-models.js +31 -3
- package/src/model-test.js +3 -1
- package/src/o2a.js +956 -0
- package/src/open-external.js +43 -5
- package/src/plugins.js +1240 -0
- package/src/providers/registry.json +72 -30
- package/src/semver.js +40 -0
- package/src/store.js +17 -1
- package/src/stream.js +39 -4
- package/src/subscription-completion.js +12 -3
- package/src/updater.js +5 -33
- package/src/upstream.js +283 -363
- package/vendor/channel-pack/NOTICE.md +1 -1
- package/vendor/channel-pack/pack.js +7242 -3952
- package/vendor/magpie/NOTICE.md +159 -6
- package/vendor/magpie/engine.json +58 -27
- package/src/core/completion.js +0 -196
- package/src/core/runtime.js +0 -456
- package/src/core/stats.js +0 -98
- package/src/engine-parts/win32-x64.007.part +0 -0
- package/src/engine-parts/win32-x64.008.part +0 -0
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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 =
|
|
107
|
+
const BASE62 = BASE62_ALPHABET
|
|
74
108
|
|
|
75
109
|
/**
|
|
76
|
-
* The
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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 =
|
|
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
|
|
160
|
-
* one
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
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
|
-
|
|
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)}`
|
|
138
|
+
return canonicalSessionID(signal)
|
|
173
139
|
}
|
|
174
140
|
|
|
175
|
-
/** Render `bytes` as
|
|
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('')
|
|
186
|
-
}
|
|
187
|
-
|
|
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
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
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
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
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
|
-
|
|
278
|
-
|
|
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.
|
|
306
|
-
*
|
|
307
|
-
*
|
|
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
|
-
|
|
318
|
-
if (
|
|
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
|
-
* `
|
|
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
|
-
*
|
|
329
|
-
* -
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
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(
|
|
335
|
-
|
|
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
|
-
|
|
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 : ''
|
|
325
|
+
/** The shape name one `style` argument speaks. */
|
|
326
|
+
function shapeOf(style) {
|
|
327
|
+
return style === true ? 'flat' : style === 'claude' ? 'claude' : 'chat'
|
|
368
328
|
}
|
|
369
329
|
|
|
370
|
-
/**
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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 }
|
|
380
|
-
}
|
|
381
|
-
|
|
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
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
/**
|
|
397
|
-
function
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
if (
|
|
401
|
-
|
|
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
|
-
*
|
|
355
|
+
* The note appended when official tools had to be injected.
|
|
434
356
|
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
357
|
+
* ⚠️ v2 的定义是**真工具**(描述与 schema 都来自官方客户端),模型会真的去调它
|
|
358
|
+
* —— 实测把 `read` 用在「/etc 里有什么文件」这种问题上。本插件的宿主并不实现
|
|
359
|
+
* 官方工具集,注入项必须用提示词压住。措辞是实测选出来的:写成「不可用」会让
|
|
360
|
+
* 模型连调用方自己的 `Read`/`Bash` 也一起拒绝;「占位、请用别的工具」才既压住
|
|
361
|
+
* 注入项、又不误伤调用方的工具。(参考实现不发这条 —— 它的下游是真 CLI ——
|
|
362
|
+
* 这是本适配层的既列偏差,见 src/o2a/SYNC.md。)
|
|
437
363
|
*/
|
|
438
|
-
function
|
|
439
|
-
|
|
440
|
-
|
|
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,
|
|
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
|
|
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 =
|
|
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
|
|
514
|
-
: shape
|
|
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,116 @@ 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
|
-
*
|
|
426
|
+
* The request shapes the free tier reads as a conversation-title generator
|
|
427
|
+
* (`title.IsTitleRequest`, opencode2api v1.4.7): no tools declared, one to
|
|
428
|
+
* three turns, no assistant/tool/function turn, and a title marker in the
|
|
429
|
+
* system or leading text.
|
|
534
430
|
*
|
|
535
|
-
*
|
|
431
|
+
* The reference stopped shaping these bodies in v1.4.7 with a measured reason:
|
|
432
|
+
* the free tier answers an agent-shaped tool list on a toolless title call
|
|
433
|
+
* with 403/429. This lane injects the full official prompt and 13-tool set on
|
|
434
|
+
* every body, so the harness's title round-trips used to ship exactly that
|
|
435
|
+
* shape whenever the local title answer was switched off. A title request now
|
|
436
|
+
* ships as the caller wrote it — no prompt injection, no tool append, no
|
|
437
|
+
* renames.
|
|
438
|
+
*/
|
|
439
|
+
const TITLE_MARKERS = /generate a title|write a title|create a title|come up with a title|title (?:for|of) (?:this|the) (?:conversation|chat|session|exchange|request)|conversation title|summarize (?:this|the) conversation/i
|
|
440
|
+
|
|
441
|
+
export function isTitleRequest(body, style) {
|
|
442
|
+
if (!body || typeof body !== 'object') return false
|
|
443
|
+
if ((Array.isArray(body.tools) && body.tools.length > 0) || (Array.isArray(body.functions) && body.functions.length > 0)) return false
|
|
444
|
+
if (body.tool_choice !== undefined) return false
|
|
445
|
+
const shape = shapeOf(style)
|
|
446
|
+
const textOf = value => typeof value === 'string' ? value
|
|
447
|
+
: Array.isArray(value) ? value.map(part => part?.text ?? '').join('\n') : ''
|
|
448
|
+
let turns
|
|
449
|
+
let leading = ''
|
|
450
|
+
if (shape === 'flat') {
|
|
451
|
+
turns = (Array.isArray(body.input) ? body.input : []).filter(item => item !== null && typeof item === 'object' && typeof item.role === 'string')
|
|
452
|
+
leading = textOf(body.instructions)
|
|
453
|
+
} else {
|
|
454
|
+
turns = (Array.isArray(body.messages) ? body.messages : []).filter(message => message !== null && typeof message === 'object')
|
|
455
|
+
leading = shape === 'claude' ? textOf(body.system) : ''
|
|
456
|
+
}
|
|
457
|
+
if (turns.length === 0 || turns.length > 3) return false
|
|
458
|
+
for (const turn of turns) {
|
|
459
|
+
if (!['user', 'system', 'developer'].includes(String(turn.role ?? ''))) return false
|
|
460
|
+
}
|
|
461
|
+
return TITLE_MARKERS.test(`${leading}\n${turns.map(turn => textOf(turn.content)).join('\n')}`)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Satisfy the v2 free-tier fingerprint on `body`, shaped as the reference
|
|
466
|
+
* gateway shapes it (opencode2api v1.4.6, see the module header):
|
|
536
467
|
*
|
|
537
|
-
* 1.
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
468
|
+
* 1. **官方系统提示注入**:base prompt + `<env>` + skills,逐线注入(Chat 折到
|
|
469
|
+
* 首条 system,Responses 折到 `instructions`,Messages 折到 `system`);
|
|
470
|
+
* 已带官方标记的请求体原样跳过。
|
|
471
|
+
* 2. **全部调用方工具改名** 为 `clint_<sha256[:12]>`(外来工具词汇一概不上
|
|
472
|
+
* 线——`ReportFindings`/`ScheduleWakeup` 同时出现即 429,2026-10-30 实测);
|
|
473
|
+
* Responses 内建与 Messages 服务端工具无客户名,原样保留。
|
|
474
|
+
* 3. **补齐官方 13 工具集**(捕获自官方 CLI 的逐字定义,chat 形状带
|
|
475
|
+
* `strict: false`,Windows 主机 shell 描述换 powershell);已带逐字官方
|
|
476
|
+
* 定义的声明视为官方声明,不重命名也不重复补。
|
|
477
|
+
* 4. **引用同步**:`tool_choice`、历史 tool_calls / tool_use / function_call、
|
|
478
|
+
* Responses 的 `input` 调用行全部跟随改名;字符串 choice 原样透传。
|
|
543
479
|
* 5. **绝不下发 `tool_choice: 'none'`** —— v2 见到它就 403;调用方没给
|
|
544
480
|
* `tool_choice` 时,只有 Responses 线补 `auto`(官方行为)。
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
481
|
+
* 6. Chat 请求体补上官方客户端的两个常量字段:流式时
|
|
482
|
+
* `stream_options.include_usage: true`;调用方未给上限时
|
|
483
|
+
* `max_completion_tokens: 32000`。
|
|
548
484
|
*
|
|
549
485
|
* @param {object} body - request body, mutated in place
|
|
550
486
|
* @param {boolean|'claude'} style - true 为 Responses,false 为 Chat,claude 为 Messages
|
|
551
|
-
* @returns {
|
|
487
|
+
* @returns {ToolRenameMap} sent name -> caller's name(响应侧还原用;带
|
|
488
|
+
* {@link ToolRenameMap.resolve} 的折叠还原)
|
|
552
489
|
*/
|
|
553
490
|
export function applyFingerprint(body, style) {
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
//
|
|
562
|
-
|
|
563
|
-
// 官方定义反而不会补上(门禁仍然 403)。
|
|
564
|
-
const seen = new Set()
|
|
565
|
-
const present = new Set()
|
|
566
|
-
const out = []
|
|
567
|
-
const compatibilityAliases = []
|
|
491
|
+
const shape = shapeOf(style)
|
|
492
|
+
if (!body || typeof body !== 'object') return new ToolRenameMap()
|
|
493
|
+
|
|
494
|
+
// 0. Title requests ship unshaped: an agent-shaped tool list on a toolless
|
|
495
|
+
// title call is a 403/429 the reference measured (see isTitleRequest).
|
|
496
|
+
if (isTitleRequest(body, style)) return new ToolRenameMap()
|
|
497
|
+
|
|
498
|
+
// 1. Official system prompt (the reference injects before shaping tools).
|
|
499
|
+
injectOfficialPrompt(body, shape)
|
|
568
500
|
|
|
501
|
+
// 2.+3. Tool pass: rename client tools, append the official set. Tools that
|
|
502
|
+
// already carry the verbatim official definition are declared officials —
|
|
503
|
+
// kept as-is and not duplicated — so repeated shaping stays a no-op.
|
|
504
|
+
const tools = Array.isArray(body.tools) ? body.tools : []
|
|
505
|
+
const keep = new Set()
|
|
569
506
|
for (const tool of tools) {
|
|
570
|
-
const name =
|
|
571
|
-
|
|
572
|
-
|
|
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
|
|
580
|
-
}
|
|
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
|
|
507
|
+
const name = renamableToolName(shape, tool)
|
|
508
|
+
if (name !== null && carriesOfficialDefinition(tool, name, shape)) {
|
|
509
|
+
keep.add(name)
|
|
590
510
|
}
|
|
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
511
|
}
|
|
512
|
+
const { renames, injected } = renameAndCompleteTools(body, shape, { keep })
|
|
597
513
|
|
|
598
|
-
|
|
599
|
-
for (const name of FINGERPRINT_TOOLS) {
|
|
600
|
-
if (present.has(name)) continue
|
|
601
|
-
out.push(fingerprintTool(name, shape))
|
|
602
|
-
injected.push(name)
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
body.tools = out
|
|
606
|
-
retargetToolChoice(body, map)
|
|
607
|
-
aliasHistoryToolCalls(body, map)
|
|
608
|
-
ensureFreeLaneEnvironment(body, style)
|
|
514
|
+
// 4b. The injected officials need the placeholder note.
|
|
609
515
|
if (injected.length > 0) noteInjectedTools(body, shape, injected)
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
}
|
|
613
|
-
// ⚠️ v2 见到 `tool_choice: 'none'` 就回 403(v1 允许,旧代码甚至主动补过它)。
|
|
614
|
-
// 兜底清除:调用方(续写路径等)留下的 `none` 会让整轮失败。
|
|
516
|
+
|
|
517
|
+
// 5. Never ship tool_choice none; default the Responses wire to auto.
|
|
615
518
|
if (body.tool_choice === 'none'
|
|
616
519
|
|| (body.tool_choice !== null && typeof body.tool_choice === 'object' && body.tool_choice.type === 'none')) {
|
|
617
520
|
delete body.tool_choice
|
|
618
521
|
}
|
|
619
|
-
if (flat && !body.tool_choice) body.tool_choice = 'auto'
|
|
620
|
-
|
|
522
|
+
if (shape === 'flat' && !body.tool_choice) body.tool_choice = 'auto'
|
|
523
|
+
|
|
524
|
+
// 6. The chat body's own constants.
|
|
525
|
+
ensureChatUsage(body, shape)
|
|
526
|
+
ensureV2CompletionLimit(body, shape)
|
|
527
|
+
return renames
|
|
621
528
|
}
|
|
622
529
|
|
|
623
530
|
/** Restore the caller's tool spelling in a streaming delta or a final payload. */
|
|
624
531
|
export function restoreToolName(name, map) {
|
|
625
|
-
if (!map
|
|
532
|
+
if (!map) return name
|
|
533
|
+
if (typeof map.resolve === 'function') return map.resolve(name)
|
|
534
|
+
if (map.size === 0) return name
|
|
626
535
|
return map.get(name) ?? name
|
|
627
536
|
}
|
|
628
537
|
|
|
@@ -642,4 +551,15 @@ function truncateSession(value) {
|
|
|
642
551
|
return trimmed.length > MAX_SESSION_LENGTH ? trimmed.slice(0, MAX_SESSION_LENGTH) : trimmed
|
|
643
552
|
}
|
|
644
553
|
|
|
645
|
-
|
|
554
|
+
/** The Anthropic API version the Messages lane sends. */
|
|
555
|
+
const ANTHROPIC_API_VERSION = '2023-06-01'
|
|
556
|
+
|
|
557
|
+
export { truncateSession, randomID, randomProjectId, MAX_TOOL_NAME_LEN, ANTHROPIC_API_VERSION }
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* A fresh per-request project id, exposed for callers that want to pin the
|
|
561
|
+
* same id across a retried turn. The lane default is one per request.
|
|
562
|
+
*/
|
|
563
|
+
function randomProjectId() {
|
|
564
|
+
return randomProjectID()
|
|
565
|
+
}
|