@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/README.md +12 -7
- package/client.js +894 -6
- package/index.js +245 -8
- package/package.json +41 -3
- package/src/adapter.js +54 -6
- package/src/aggregate.js +53 -13
- package/src/bun-fetch.js +33 -10
- package/src/catalog.js +140 -16
- package/src/core.js +122 -33
- 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 +14 -3
- package/src/kilo.js +55 -5
- package/src/magpie-engine.js +73 -3
- package/src/magpie-models.js +31 -3
- package/src/model-test.js +3 -1
- package/src/o2a.js +207 -8
- 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 +11 -2
- package/src/updater.js +5 -33
- package/src/upstream.js +43 -0
- package/vendor/channel-pack/NOTICE.md +1 -1
- package/vendor/channel-pack/pack.js +7242 -3952
- package/vendor/magpie/NOTICE.md +221 -5
- package/vendor/magpie/engine.json +35 -15
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
29
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
}
|
package/src/magpie-engine.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/magpie-models.js
CHANGED
|
@@ -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,
|
|
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
|
|
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'
|
|
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.
|
|
3
|
-
var O2A_COMMIT = "
|
|
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.
|
|
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 ??
|
|
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 (
|
|
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
|
-
|
|
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
|
};
|