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

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/feed.js CHANGED
@@ -109,6 +109,11 @@ function parseItem(row, now) {
109
109
  && /^https?:\/\//i.test(row.link.url)) {
110
110
  link = { url: row.link.url, label: typeof row.link.label === 'string' ? row.link.label.slice(0, 100) : '' }
111
111
  }
112
+ // `holdSeconds` asks the client to keep the modal's close control locked for
113
+ // a few seconds so the body is actually read (0/absent closes immediately).
114
+ // The ceiling keeps an owner from writing "hold for a week".
115
+ const holdSeconds = row.holdSeconds
116
+ const hold = Number.isInteger(holdSeconds) && holdSeconds >= 0 && holdSeconds <= 30 ? holdSeconds : 0
112
117
  return {
113
118
  id,
114
119
  title,
@@ -118,6 +123,7 @@ function parseItem(row, now) {
118
123
  ...expiresAt === undefined ? {} : { expiresAt },
119
124
  ...row.pinned === true ? { pinned: true } : {},
120
125
  ...link === undefined ? {} : { link },
126
+ ...(hold > 0 ? { holdSeconds: hold } : {}),
121
127
  }
122
128
  }
123
129
 
package/src/http.js CHANGED
@@ -420,8 +420,11 @@ function emptyBodyFailure(status, retryAfterMs, imageContent) {
420
420
 
421
421
  export async function postStreamed({ path, body, session, attributionUserAgent, signal, onData, timeoutMs = 300000 }) {
422
422
  // The Messages wire carries the 2.x client's beta header (o2a/headers.js);
423
- // the path is the only wire signal this layer sees.
424
- const headers = gatewayHeaders({ session: truncateSession(session), stream: true, anthropic: path.includes('/messages') })
423
+ // the path is the only wire signal this layer sees. The project id derives
424
+ // from the same canonical session, so one conversation's retries,
425
+ // continuations and refreshes share one upstream quota bucket (PR #757).
426
+ const canonical = truncateSession(session)
427
+ const headers = gatewayHeaders({ session: canonical, project: canonical, stream: true, anthropic: path.includes('/messages') })
425
428
  headers['user-agent'] = userAgentWith(attributionUserAgent)
426
429
  let response
427
430
  try {
@@ -431,6 +434,13 @@ export async function postStreamed({ path, body, session, attributionUserAgent,
431
434
  // `TimeoutError`/user Error rather than `AbortError` — testing the name alone
432
435
  // reported a cancelled turn as `TRANSPORT`, which is retryable.
433
436
  if (signal?.aborted === true || error?.name === 'AbortError') throw new UpstreamError('request aborted', CODE.aborted)
437
+ // A lane-configuration refusal (a missing Bun, an unusable proxy) is not an
438
+ // "upstream request failed": the sentence the lane wrote is already the
439
+ // actionable answer, so it ships unwrapped, with its flag intact for the
440
+ // surfaces that distinguish "fix your environment" from "upstream broke".
441
+ if (error?.laneConfig === true) {
442
+ throw new UpstreamError(String(error?.message ?? error), CODE.transport, { laneConfig: true })
443
+ }
434
444
  throw new UpstreamError(`our-free-model: upstream request failed: ${error?.message ?? error}${transportCause(error)}`, CODE.transport)
435
445
  }
436
446
 
@@ -625,7 +635,8 @@ export async function readSse(source, onData, signal, timeoutMs = 300000) {
625
635
 
626
636
  /** Fetch a small JSON document from the gateway with the fingerprint headers. */
627
637
  export async function getJson(path, { session, attributionUserAgent, signal, timeoutMs = 15000 } = {}) {
628
- const headers = gatewayHeaders({ session: truncateSession(session ?? ''), stream: false, accept: 'application/json' })
638
+ const canonical = truncateSession(session ?? '')
639
+ const headers = gatewayHeaders({ session: canonical, project: canonical, stream: false, accept: 'application/json' })
629
640
  headers['user-agent'] = userAgentWith(attributionUserAgent)
630
641
  const controller = new AbortController()
631
642
  const timer = setTimeout(() => controller.abort(), timeoutMs)
package/src/kilo.js CHANGED
@@ -25,8 +25,20 @@ import { egressFetch } from './egress.js'
25
25
  const LISTING_TIMEOUT_MS = 15000
26
26
  const TURN_TIMEOUT_MS = 300000
27
27
 
28
- /** Overridable for the offline suite and the live probes, like the free lane's base. */
29
- export const KILO_BASE = (process.env.OUR_FREE_MODEL_KILO_BASE ?? 'https://api.kilo.ai/api/gateway').replace(/\/+$/, '')
28
+ /**
29
+ * The gateway base, newest first (`ai-gateway.kilo.ai` is what the official
30
+ * docs publish; the older `api.kilo.ai/api/gateway` path answers the same
31
+ * catalog and stays as the override/fallback). Overridable for the offline
32
+ * suite and the live probes, like the free lane's base.
33
+ */
34
+ export const KILO_BASE = (process.env.OUR_FREE_MODEL_KILO_BASE ?? 'https://ai-gateway.kilo.ai/api/v1').replace(/\/+$/, '')
35
+
36
+ /** The attribution headers Kilo's own clients send; documented optional, and a
37
+ * gateway that ranks by referrer sees the plugin, not a spoofed editor. */
38
+ const KILO_ATTRIBUTION = {
39
+ 'http-referer': 'https://kilo.ai/',
40
+ 'x-title': 'Our Free Model',
41
+ }
30
42
 
31
43
  /**
32
44
  * The lane's transport is `egressFetch`, exactly like the free lane's: an
@@ -37,7 +49,13 @@ function kiloFetch(url, init) {
37
49
  return egressFetch(url, init)
38
50
  }
39
51
 
40
- /** One listing round: `GET {base}/models`. Returns the parsed JSON document. */
52
+ /** The cached listing document and its validator, for conditional rounds. */
53
+ let listingCache = null
54
+ let listingETag = null
55
+
56
+ /** One listing round: `GET {base}/models`, conditionally when the gateway
57
+ * named a validator last time. Returns the parsed JSON document; a `304`
58
+ * answers with the cached one. */
41
59
  export async function fetchKiloListing({ signal, timeoutMs = LISTING_TIMEOUT_MS } = {}) {
42
60
  const controller = new AbortController()
43
61
  const timer = setTimeout(() => controller.abort(), timeoutMs)
@@ -46,15 +64,27 @@ export async function fetchKiloListing({ signal, timeoutMs = LISTING_TIMEOUT_MS
46
64
  const onCallerAbort = () => { callerAborted = true; controller.abort() }
47
65
  signal?.addEventListener('abort', onCallerAbort, { once: true })
48
66
  try {
67
+ const headers = {
68
+ accept: 'application/json',
69
+ 'user-agent': 'dsh-our-free-model',
70
+ ...KILO_ATTRIBUTION,
71
+ ...(listingETag !== null ? { 'if-none-match': listingETag } : {}),
72
+ }
49
73
  const response = await kiloFetch(`${KILO_BASE}/models`, {
50
- headers: { accept: 'application/json', 'user-agent': 'dsh-our-free-model' },
74
+ headers,
51
75
  redirect: 'error',
52
76
  signal: controller.signal,
53
77
  })
78
+ if (response.status === 304 && listingCache !== null) return listingCache
54
79
  const text = await response.text()
55
80
  let payload
56
81
  try { payload = JSON.parse(text) } catch { payload = { error: { message: text.slice(0, 300) || `HTTP ${response.status}` } } }
57
82
  if (!response.ok) throw classifyFailure(response.status, payload)
83
+ const etag = response.headers.get('etag')
84
+ if (typeof etag === 'string' && etag !== '') {
85
+ listingETag = etag
86
+ listingCache = payload
87
+ }
58
88
  return payload
59
89
  } catch (error) {
60
90
  if (error instanceof UpstreamError) throw error
@@ -83,7 +113,7 @@ export async function postKiloStreamed({ body, signal, onData, timeoutMs = TURN_
83
113
  try {
84
114
  response = await kiloFetch(`${KILO_BASE}/chat/completions`, {
85
115
  method: 'POST',
86
- headers: { 'content-type': 'application/json', accept: 'text/event-stream', 'user-agent': 'dsh-our-free-model' },
116
+ headers: { 'content-type': 'application/json', accept: 'text/event-stream', 'user-agent': 'dsh-our-free-model', ...KILO_ATTRIBUTION },
87
117
  body: bodyText,
88
118
  redirect: 'error',
89
119
  signal,
@@ -151,9 +181,29 @@ function classifyKilo(status, text, retryAfterMs) {
151
181
  let payload
152
182
  try { payload = JSON.parse(text) } catch { payload = { error: { message: text.slice(0, 300) || `HTTP ${status}` } } }
153
183
  const type = payload?.error_type ?? payload?.error?.code ?? payload?.error?.type
184
+ const rawMeta = payload?.error?.metadata?.raw ?? payload?.metadata?.raw
185
+ let message = payload?.error?.message ?? (typeof payload?.message === 'string' ? payload.message : undefined)
186
+ if (typeof rawMeta === 'string' && rawMeta.trim() !== '') {
187
+ let rawDetail = ''
188
+ try {
189
+ const parsed = JSON.parse(rawMeta)
190
+ rawDetail = typeof parsed?.error === 'string' ? parsed.error
191
+ : typeof parsed?.message === 'string' ? parsed.message
192
+ : typeof parsed?.error?.message === 'string' ? parsed.error.message
193
+ : ''
194
+ } catch {
195
+ rawDetail = rawMeta.slice(0, 200).trim()
196
+ }
197
+ const providerName = payload?.error?.metadata?.provider_name ?? payload?.metadata?.provider_name
198
+ const providerSuffix = typeof providerName === 'string' && providerName !== '' ? ` (${providerName})` : ''
199
+ if (rawDetail !== '') {
200
+ message = message ? `${message}: ${rawDetail}${providerSuffix}` : `${rawDetail}${providerSuffix}`
201
+ }
202
+ }
154
203
  const shaped = {
155
204
  error: {
156
205
  ...payload?.error,
206
+ ...(message !== undefined ? { message } : {}),
157
207
  ...(type === undefined ? {} : { type: String(type) }),
158
208
  },
159
209
  }
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process'
2
2
  import { randomBytes, createHash } from 'node:crypto'
3
- import { existsSync, readFileSync, createReadStream } from 'node:fs'
4
- import { mkdir, readFile, writeFile, rename, link, unlink, realpath } from 'node:fs/promises'
3
+ import { existsSync, readFileSync, createReadStream, statSync } from 'node:fs'
4
+ import { mkdir, readFile, writeFile, rename, link, unlink, realpath, rm } from 'node:fs/promises'
5
5
  import http from 'node:http'
6
6
  import net from 'node:net'
7
7
  import path from 'node:path'
@@ -767,7 +767,47 @@ export function createMagpieEngine({ dataDir, logger, binary } = {}) {
767
767
  const env = { ...process.env }
768
768
  // An inherited Magpie override must never redirect this owned instance.
769
769
  for (const key of Object.keys(env)) if (key.toUpperCase().startsWith('MAGPIE_')) delete env[key]
770
+ // The engine wires every agent it finds under the user's home (ZCode's
771
+ // provider_config.json, Codex's config.toml, …) to itself when it starts.
772
+ // This engine's catalog is its own and may be empty, so that startup sync
773
+ // would rewrite those agents with no models. Give it a home of its own:
774
+ // it still serves its page, and no file of a real agent is touched. The
775
+ // Windows folders os.UserConfigDir reads are moved too, for the agents
776
+ // that keep their settings there (VS Code, Cline, …).
777
+ const agentHome = path.join(home, 'agent-home')
778
+ const agentRoaming = path.join(agentHome, 'AppData', 'Roaming')
779
+ const agentLocal = path.join(agentHome, 'AppData', 'Local')
780
+ for (const dir of [agentHome, agentRoaming, agentLocal]) {
781
+ await mkdir(dir, { recursive: true, mode: 0o700 })
782
+ }
783
+ // The engine goes straight to -p for a while after Claude Code's
784
+ // interactive mode failed to start (its claude-tui-broken mark, under
785
+ // this engine's LOCALAPPDATA). A mark earned by the previous engine
786
+ // says nothing about this one: after an engine upgrade that carries a
787
+ // start fix, a stale mark would keep the TUI off for up to a day.
788
+ // Forget it when the engine's revision changes.
789
+ const engineRevision = metadata.revision || ''
790
+ if (engineRevision !== '') {
791
+ const revisionFile = path.join(home, '.ofm-engine-revision')
792
+ let previousRevision = ''
793
+ try { previousRevision = (await readFile(revisionFile, 'utf8')).trim() } catch { /* first boot: no mark to forget either */ }
794
+ if (previousRevision !== engineRevision) {
795
+ await rm(path.join(agentLocal, 'magpie', 'claude-tui-broken'), { force: true }).catch(() => {})
796
+ await writeFile(revisionFile, `${engineRevision}\n`).catch(() => {})
797
+ }
798
+ }
799
+ // Windows folds every ancestor of a session folder into a 260-character
800
+ // budget; this redirection adds ~40, and claude appends a slug of the
801
+ // whole project path on top. A long or non-ASCII data home is the class
802
+ // of failure behind "rename the folder to something short and it works"
803
+ // — say so up front, with the escape hatch, instead of leaving a bare
804
+ // mkdir error nobody can act on.
805
+ if (agentHome.length > 200 || /[^\x20-\x7E]/.test(agentHome)) {
806
+ logger?.warn?.(`our-free-model: 引擎数据目录较长或含非 ASCII 字符(${agentHome.length} 字符)——Claude 等客户端可能无法新建会话文件夹;如遇到,请把 DSH_HOME 指向一个短的纯英文路径后重启。(engine data home is long or contains non-ASCII characters; if agents cannot create session folders, point DSH_HOME at a short ASCII path and restart)`)
807
+ }
770
808
  Object.assign(env, {
809
+ HOME: agentHome, USERPROFILE: agentHome,
810
+ APPDATA: agentRoaming, LOCALAPPDATA: agentLocal,
771
811
  XDG_CONFIG_HOME: dirs.config, XDG_CACHE_HOME: dirs.cache,
772
812
  XDG_DATA_HOME: dirs.data, XDG_STATE_HOME: dirs.state,
773
813
  MAGPIE_ADDR: `127.0.0.1:${gatewayPort}`, MAGPIE_WEB_KEY: webKey,
@@ -929,7 +969,13 @@ export function createMagpieEngine({ dataDir, logger, binary } = {}) {
929
969
  if (failure.statusCode === 400) throw engineError('Invalid gateway key request', 400)
930
970
  if (failure.statusCode === 413) throw engineError('Gateway key request exceeds limit', 413)
931
971
  if (options.signal?.aborted) throw new DOMException('Engine request aborted', 'AbortError')
932
- throw engineError('Engine request failed')
972
+ // The engine's own transport refusals carry the actionable fact (the
973
+ // refused path, the socket error); a bare "Engine request failed" turns a
974
+ // diagnosable boot into a support thread. Keep the detail, bounded, and
975
+ // never from response bodies — those can quote user content.
976
+ const detail = typeof failure?.message === 'string' && failure.message !== ''
977
+ ? `: ${failure.message.slice(0, 160)}` : ''
978
+ throw engineError(`Engine request failed${detail}`)
933
979
  }
934
980
  }
935
981
  const proxy = async (req, res, subpath = '/', options = {}) => {
@@ -1106,3 +1152,27 @@ export function createMagpieEngine({ dataDir, logger, binary } = {}) {
1106
1152
  }
1107
1153
  return { start, status, close, request, proxy, gateway }
1108
1154
  }
1155
+
1156
+ /**
1157
+ * A lightweight PATH probe for the agent CLIs the engine connects to (claude
1158
+ * first among them). The engine manages agent installs itself, but its
1159
+ * downloader cannot reach the release host from every network, so the practical
1160
+ * requirement is a pre-installed CLI — warn at boot instead of at the user's
1161
+ * first failed connect. Never resolves shell shims deeper than a PATH entry.
1162
+ */
1163
+ export function findAgentCli(command) {
1164
+ const name = String(command ?? '').trim()
1165
+ if (name === '' || name.includes('/') || name.includes('\\')) return undefined
1166
+ const spellings = process.platform === 'win32' ? ['.cmd', '.bat', '.exe', ''] : ['']
1167
+ const dirs = [
1168
+ ...(process.env.PATH ?? '').split(path.delimiter).filter(Boolean),
1169
+ ...(process.platform === 'win32' && process.env.APPDATA ? [path.join(process.env.APPDATA, 'npm')] : []),
1170
+ ]
1171
+ for (const dir of dirs) {
1172
+ for (const spelling of spellings) {
1173
+ const candidate = path.join(dir, `${name}${spelling}`)
1174
+ try { if (statSync(candidate).isFile()) return candidate } catch { /* try the next spelling */ }
1175
+ }
1176
+ }
1177
+ return undefined
1178
+ }
@@ -4,14 +4,42 @@ import { deniedModel } from './magpie-policy.js'
4
4
 
5
5
  const PREFIX = 'subscription:magpie/'
6
6
 
7
+ /**
8
+ * Engine inference rides the host's replaceable `globalThis.fetch` — the same
9
+ * transport the imported sidecar subscriptions already use — so local proxy
10
+ * plugins that swap `globalThis.fetch` to re-serialize outgoing bodies can see
11
+ * engine turns again, as they could before the 2026-10-24 private-transport
12
+ * quarantine. Startup, health, management and catalog reads keep the private
13
+ * loopback transport: a broken fetch replacement must never keep the workspace
14
+ * from becoming ready. An explicit `fetchImpl` (tests, embedded hosts) still
15
+ * governs both paths, and OFM_ENGINE_PRIVATE_FETCH=1 forces inference back
16
+ * onto the private transport.
17
+ */
18
+ function defaultInferenceTransport() {
19
+ return process.env.OFM_ENGINE_PRIVATE_FETCH === '1' ? loopbackFetch : globalThis.fetch
20
+ }
21
+
7
22
  /** Expose the engine's live catalog through the existing DSH stream adapter. */
8
- export function createMagpieModels({ engine, stats, logger, onChanged = () => {}, fetchImpl = loopbackFetch }) {
23
+ export function createMagpieModels({ engine, stats, logger, onChanged = () => {}, fetchImpl = loopbackFetch, inferenceFetch, extensionRuntime } = {}) {
9
24
  let models = []
10
25
  let pending
11
26
  let closed = false
12
27
  let owner
28
+ // An explicit fetchImpl governs inference and catalog alike (the historical
29
+ // contract); otherwise inference is the replaceable transport and only the
30
+ // catalog read stays private.
31
+ const catalogFetch = fetchImpl
32
+ const inferenceTransport = inferenceFetch ?? (fetchImpl !== loopbackFetch ? fetchImpl : defaultInferenceTransport())
13
33
  const runtime = createSubscriptionRuntime({
14
- stats, logger, fetchImpl,
34
+ stats, logger,
35
+ fetchImpl: inferenceTransport,
36
+ // Enhancement extensions get the same fail-open body seam the harness lane
37
+ // gives them: one plain-object pass, microseconds, no gateway hop.
38
+ onPayload: async (payload, meta) => {
39
+ const source = typeof extensionRuntime === 'function' ? extensionRuntime() : extensionRuntime
40
+ if (source?.applyRequestHooks === undefined) return payload
41
+ return source.applyRequestHooks(payload, { ...meta, lane: 'magpie' })
42
+ },
15
43
  subscriptions: {
16
44
  list: () => [{
17
45
  id: 'magpie', provider: 'magpie', enabled: engine.status().state === 'ready',
@@ -65,7 +93,7 @@ export function createMagpieModels({ engine, stats, logger, onChanged = () => {}
65
93
  if (pending) return pending
66
94
  pending = (async () => {
67
95
  const gateway = engine.gateway()
68
- const response = await fetchImpl(`${gateway.url.replace(/\/$/, '')}/v1/models`, {
96
+ const response = await catalogFetch(`${gateway.url.replace(/\/$/, '')}/v1/models`, {
69
97
  headers: { authorization: `Bearer ${gateway.key}` },
70
98
  signal: AbortSignal.timeout(15000), redirect: 'error',
71
99
  })
package/src/model-test.js CHANGED
@@ -84,7 +84,9 @@ export function modelAvailability(id, snapshot, listed = false) {
84
84
  // Older saved snapshots have only `at`, which retains their previous order.
85
85
  const measured = manual && (!probe || manual.at >= (probe.startedAt ?? probe.at)) ? manual : probe
86
86
  return {
87
- availabilitySource: measured === manual && manual ? 'manual' : listed ? 'listing' : 'probe',
87
+ availabilitySource: measured === manual && manual ? 'manual'
88
+ : measured?.source === 'passive' ? 'passive'
89
+ : listed ? 'listing' : 'probe',
88
90
  availability: measured?.state ?? 'unknown',
89
91
  detail: measured?.detail ?? '', probedAt: measured?.at ?? 0,
90
92
  ttftMs: measured?.ttftMs, latencyMs: measured?.latencyMs,
package/src/o2a.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // src/o2a/version.js
2
- var O2A_VERSION = "1.4.6";
3
- var O2A_COMMIT = "58f56676ba1438adb30604b889bf36db2a1542a1";
2
+ var O2A_VERSION = "1.4.7";
3
+ var O2A_COMMIT = "b296a61d0e3ef93b4df58e298be2c4e34e397322";
4
4
  var O2A_REFERENCE = "https://github.com/jasonxu114514/opencode2api";
5
- var V2_CLIENT_VERSION = "2.1.1";
5
+ var V2_CLIENT_VERSION = "2.2.3";
6
6
  var CLIENT_UA = `opencode/latest/${V2_CLIENT_VERSION}/cli`;
7
7
  var V2_COMPLETION_LIMIT = 32e3;
8
8
  var ANONYMOUS_CREDENTIAL = "public";
@@ -98,6 +98,34 @@ function clientToolWireName(name, used) {
98
98
  }
99
99
  }
100
100
  }
101
+ var OFFICIAL_TOOL_ALIASES = Object.freeze({
102
+ edit: ["edit"],
103
+ glob: ["glob"],
104
+ grep: ["grep"],
105
+ question: ["question", "askuserquestion"],
106
+ read: ["read", "view"],
107
+ shell: ["shell", "bash"],
108
+ skill: ["skill"],
109
+ subagent: ["subagent", "agent", "task"],
110
+ webfetch: ["webfetch", "fetch"],
111
+ websearch: ["websearch"],
112
+ write: ["write"],
113
+ patch: ["patch", "applypatch"]
114
+ });
115
+ var FOLDED_OFFICIAL_ALIASES = (() => {
116
+ const index = /* @__PURE__ */ new Map();
117
+ for (const [official, spellings] of Object.entries(OFFICIAL_TOOL_ALIASES)) {
118
+ for (const spelling of spellings) {
119
+ const folded = foldToolName(spelling);
120
+ if (!index.has(folded)) index.set(folded, official);
121
+ }
122
+ }
123
+ return index;
124
+ })();
125
+ function officialForClientName(name) {
126
+ if (typeof name !== "string" || name === "") return null;
127
+ return FOLDED_OFFICIAL_ALIASES.get(foldToolName(name)) ?? null;
128
+ }
101
129
  var ToolRenameMap = class extends Map {
102
130
  constructor() {
103
131
  super();
@@ -518,6 +546,18 @@ function projectID(project) {
518
546
  const sum = crypto3.createHash("sha256").update(`opencode-project\0${project}`).digest();
519
547
  return sum.subarray(0, 20).toString("hex");
520
548
  }
549
+ function stableProjectID(session) {
550
+ return projectID(String(session ?? ""));
551
+ }
552
+ function traceHeaders() {
553
+ const traceId = crypto3.randomBytes(16).toString("hex");
554
+ const spanId = crypto3.randomBytes(8).toString("hex");
555
+ const parentId = crypto3.randomBytes(8).toString("hex");
556
+ return {
557
+ "traceparent": `00-${traceId}-${spanId}-01`,
558
+ "b3": `${traceId}-${spanId}-1-${parentId}`
559
+ };
560
+ }
521
561
  function gatewayHeaders({ session, childSession, project, stream, accept, anthropic, userAgent } = {}) {
522
562
  const headers = {
523
563
  "content-type": "application/json",
@@ -528,9 +568,10 @@ function gatewayHeaders({ session, childSession, project, stream, accept, anthro
528
568
  "x-session-id": session,
529
569
  "x-opencode-session": session,
530
570
  "x-opencode-session-id": childSession ?? session,
531
- "x-opencode-project": projectID(project ?? randomProjectID())
571
+ "x-opencode-project": projectID(project ?? stableProjectID(session)),
572
+ ...traceHeaders()
532
573
  };
533
- if (accept) headers["accept"] = accept;
574
+ if (accept !== null) headers["accept"] = accept ?? "*/*";
534
575
  if (anthropic) headers["anthropic-beta"] = "interleaved-thinking-2025-05-14";
535
576
  return headers;
536
577
  }
@@ -538,16 +579,18 @@ function gatewayHeaders({ session, childSession, project, stream, accept, anthro
538
579
  // src/o2a/toolshape.js
539
580
  function renameAndCompleteTools(payload, shape, { keep } = {}) {
540
581
  if (shape !== "chat" && shape !== "flat" && shape !== "claude") {
541
- return { renames: new ToolRenameMap(), injected: [] };
582
+ return { renames: new ToolRenameMap(), injected: [], known: /* @__PURE__ */ new Set() };
542
583
  }
543
584
  const renames = new ToolRenameMap();
544
585
  const used = /* @__PURE__ */ new Set();
545
586
  const keepNames = keep instanceof Set ? keep : /* @__PURE__ */ new Set();
587
+ const officialUsed = new Set(keepNames);
546
588
  let items = Array.isArray(payload.tools) ? [...payload.tools] : [];
547
589
  if ((shape === "chat" || shape === "flat") && Array.isArray(payload.functions)) {
548
590
  items = items.concat(convertLegacyFunctions(payload.functions, shape, renames, used));
549
591
  delete payload.functions;
550
592
  }
593
+ const declaredClientTools = items.filter((item) => renamableToolName(shape, item) !== null).length;
551
594
  const shaped = [];
552
595
  for (const item of items) {
553
596
  const name = renamableToolName(shape, item);
@@ -559,6 +602,13 @@ function renameAndCompleteTools(payload, shape, { keep } = {}) {
559
602
  shaped.push(item);
560
603
  continue;
561
604
  }
605
+ const official = officialForClientName(name);
606
+ if (official !== null && officialUsed.has(official) === false && officialToolDefinition(official) !== void 0) {
607
+ officialUsed.add(official);
608
+ renames.add(name, official);
609
+ shaped.push(withToolName(shape, item, official));
610
+ continue;
611
+ }
562
612
  let wireName = renames.wireOf(name);
563
613
  if (wireName === void 0) {
564
614
  wireName = clientToolWireName(name, used);
@@ -568,13 +618,158 @@ function renameAndCompleteTools(payload, shape, { keep } = {}) {
568
618
  }
569
619
  const injected = [];
570
620
  for (const name of V2_OFFICIAL_TOOL_ORDER) {
571
- if (keepNames.has(name)) continue;
621
+ if (officialUsed.has(name)) continue;
572
622
  shaped.push(officialTool(shape, name));
573
623
  injected.push(name);
624
+ officialUsed.add(name);
574
625
  }
575
626
  payload.tools = shaped;
576
627
  rewriteToolReferences(payload, shape, renames);
577
- return { renames, injected };
628
+ const known = new Set(officialUsed);
629
+ for (const wire of renames.keys()) known.add(wire);
630
+ if (declaredClientTools > 0) sanitizeToolHistory(payload, shape, known);
631
+ dropDanglingToolChoice(payload, shape, known);
632
+ return { renames, injected, known };
633
+ }
634
+ function sanitizeToolHistory(payload, shape, known) {
635
+ const fateOf = (name) => {
636
+ if (typeof name !== "string" || name === "") return { verdict: "drop" };
637
+ if (known.has(name)) return { verdict: "keep" };
638
+ const official = officialForClientName(name);
639
+ if (official !== null && known.has(official)) return { verdict: "rename", official };
640
+ return { verdict: "drop" };
641
+ };
642
+ if (shape === "chat") {
643
+ const messages2 = Array.isArray(payload.messages) ? payload.messages : null;
644
+ if (messages2 === null) return;
645
+ const droppedCalls2 = /* @__PURE__ */ new Set();
646
+ const droppedFunctions = /* @__PURE__ */ new Set();
647
+ let changed2 = false;
648
+ for (let i = 0; i < messages2.length; i += 1) {
649
+ const message = messages2[i];
650
+ if (!message || typeof message !== "object") continue;
651
+ if (Array.isArray(message.tool_calls)) {
652
+ const kept = [];
653
+ let touched = false;
654
+ for (const call of message.tool_calls) {
655
+ const fate = fateOf(call?.function?.name);
656
+ if (fate.verdict === "keep") {
657
+ kept.push(call);
658
+ continue;
659
+ }
660
+ if (fate.verdict === "rename") {
661
+ const fn = call.function ?? {};
662
+ kept.push({ ...call, function: { ...fn, name: fate.official } });
663
+ touched = true;
664
+ continue;
665
+ }
666
+ if (typeof call?.id === "string" && call.id !== "") droppedCalls2.add(call.id);
667
+ touched = true;
668
+ }
669
+ if (touched) {
670
+ messages2[i] = { ...message, tool_calls: kept };
671
+ changed2 = true;
672
+ }
673
+ }
674
+ if (message.function_call && typeof message.function_call === "object") {
675
+ const fate = fateOf(message.function_call.name);
676
+ if (fate.verdict === "rename") {
677
+ messages2[i] = { ...message, function_call: { ...message.function_call, name: fate.official } };
678
+ changed2 = true;
679
+ } else if (fate.verdict === "drop") {
680
+ if (typeof message.function_call.name === "string") droppedFunctions.add(message.function_call.name);
681
+ const { function_call: _dropped, ...rest } = messages2[i];
682
+ messages2[i] = rest;
683
+ changed2 = true;
684
+ }
685
+ }
686
+ if (message.role === "tool" && typeof message.tool_call_id === "string" && droppedCalls2.has(message.tool_call_id)) {
687
+ messages2.splice(i, 1);
688
+ i -= 1;
689
+ changed2 = true;
690
+ }
691
+ if (message.role === "function" && typeof message.name === "string" && droppedFunctions.has(message.name)) {
692
+ messages2.splice(i, 1);
693
+ i -= 1;
694
+ changed2 = true;
695
+ }
696
+ }
697
+ if (changed2 && messages2.length === 0) payload.messages = [];
698
+ return;
699
+ }
700
+ if (shape === "flat") {
701
+ const input = Array.isArray(payload.input) ? payload.input : null;
702
+ if (input === null) return;
703
+ const droppedCalls2 = /* @__PURE__ */ new Set();
704
+ let changed2 = false;
705
+ for (let i = 0; i < input.length; i += 1) {
706
+ const item = input[i];
707
+ const type = typeof item?.type === "string" ? item.type : "";
708
+ if (type === "function_call") {
709
+ const fate = fateOf(item.name);
710
+ if (fate.verdict === "rename") {
711
+ input[i] = { ...item, name: fate.official };
712
+ changed2 = true;
713
+ } else if (fate.verdict === "drop") {
714
+ if (typeof item.call_id === "string" && item.call_id !== "") droppedCalls2.add(item.call_id);
715
+ input.splice(i, 1);
716
+ i -= 1;
717
+ changed2 = true;
718
+ }
719
+ continue;
720
+ }
721
+ if (type === "function_call_output" && typeof item.call_id === "string" && droppedCalls2.has(item.call_id)) {
722
+ input.splice(i, 1);
723
+ i -= 1;
724
+ changed2 = true;
725
+ }
726
+ }
727
+ if (changed2 && input.length === 0) payload.input = [];
728
+ return;
729
+ }
730
+ const messages = Array.isArray(payload.messages) ? payload.messages : null;
731
+ if (messages === null) return;
732
+ const droppedCalls = /* @__PURE__ */ new Set();
733
+ let changed = false;
734
+ for (let i = 0; i < messages.length; i += 1) {
735
+ const message = messages[i];
736
+ if (!message || typeof message !== "object" || !Array.isArray(message.content)) continue;
737
+ const kept = [];
738
+ let touched = false;
739
+ for (const block of message.content) {
740
+ if (block?.type === "tool_use") {
741
+ const fate = fateOf(block.name);
742
+ if (fate.verdict === "keep") {
743
+ kept.push(block);
744
+ continue;
745
+ }
746
+ if (fate.verdict === "rename") {
747
+ kept.push({ ...block, name: fate.official });
748
+ touched = true;
749
+ continue;
750
+ }
751
+ if (typeof block.id === "string" && block.id !== "") droppedCalls.add(block.id);
752
+ touched = true;
753
+ continue;
754
+ }
755
+ if (block?.type === "tool_result" && typeof block.tool_use_id === "string" && droppedCalls.has(block.tool_use_id)) {
756
+ touched = true;
757
+ continue;
758
+ }
759
+ kept.push(block);
760
+ }
761
+ if (touched) {
762
+ messages[i] = kept.length === 0 ? null : { ...message, content: kept };
763
+ changed = true;
764
+ }
765
+ }
766
+ if (changed) payload.messages = messages.filter((message) => message !== null);
767
+ }
768
+ function dropDanglingToolChoice(payload, shape, known) {
769
+ const choice = payload?.tool_choice;
770
+ if (choice === null || typeof choice !== "object") return;
771
+ const name = shape === "chat" && choice.function && typeof choice.function === "object" ? choice.function.name : choice.name;
772
+ if (typeof name === "string" && name !== "" && !known.has(name)) delete payload.tool_choice;
578
773
  }
579
774
  function withToolName(shape, entry, name) {
580
775
  if (shape === "chat") {
@@ -720,6 +915,7 @@ export {
720
915
  O2A_VERSION,
721
916
  OFFICIAL_PROMPT_MARKER,
722
917
  OFFICIAL_SKILLS,
918
+ OFFICIAL_TOOL_ALIASES,
723
919
  SESSION_PHASE_MS,
724
920
  SESSION_TIMESTAMP_WINDOW_MS,
725
921
  TOOL_WIRE_NAME_LIMIT,
@@ -740,6 +936,7 @@ export {
740
936
  injectOfficialPrompt,
741
937
  isOfficialSessionID,
742
938
  officialEnvironment,
939
+ officialForClientName,
743
940
  officialPrompt,
744
941
  officialSystemPrompt,
745
942
  officialTool,
@@ -752,6 +949,8 @@ export {
752
949
  renamableToolName,
753
950
  renameAndCompleteTools,
754
951
  sessionPhaseMs,
952
+ stableProjectID,
755
953
  textFragments,
954
+ traceHeaders,
756
955
  v2ShellDescription
757
956
  };