opencode-claude-code-ua 0.0.0-stage → 0.3.6

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.
@@ -0,0 +1,218 @@
1
+ import * as fsPromises from "node:fs/promises"
2
+ import { constants } from "node:fs"
3
+ import { randomUUID } from "node:crypto"
4
+ import { dirname, isAbsolute, resolve } from "node:path"
5
+
6
+ const MAX_BYTES = 4 * 1024 * 1024
7
+ const MAX_ENTRIES = 128
8
+ const MAX_MODELS = 2048
9
+ const MAX_ID = 256
10
+ const MAX_NAME = 512
11
+ const TTL = 24 * 60 * 60 * 1000
12
+ const queues = new Map()
13
+ const sources = new Set(["host", "management"])
14
+ const controls = /[\u0000-\u001f\u007f-\u009f]/u
15
+
16
+ function unsafe() {
17
+ // Filesystem/JSON errors can contain paths or file contents; never expose them.
18
+ return Object.assign(new Error("UA model catalog cannot be safely read or written"), { code: "ERR_UA_MODEL_CATALOG" })
19
+ }
20
+
21
+ function validScope(scope) {
22
+ return typeof scope === "string" && scope.length === 64 && /^[a-f0-9]+$/.test(scope)
23
+ }
24
+
25
+ function record(value) {
26
+ return value !== null && typeof value === "object" && !Array.isArray(value)
27
+ }
28
+
29
+ function fields(value, names) {
30
+ return record(value) && Object.keys(value).length === names.length && names.every(name => Object.hasOwn(value, name))
31
+ }
32
+
33
+ function text(value, limit) {
34
+ if (typeof value !== "string" || controls.test(value)) return null
35
+ const trimmed = value.trim()
36
+ return trimmed.length > 0 && trimmed.length <= limit ? trimmed : null
37
+ }
38
+
39
+ function timestamp(value, now) {
40
+ let date = new Date(NaN)
41
+ if (typeof value === "string" || typeof value === "number" || value instanceof Date) date = new Date(value)
42
+ if (!Number.isFinite(date.getTime())) date = new Date(now())
43
+ if (!Number.isFinite(date.getTime())) throw unsafe()
44
+ return date.toISOString()
45
+ }
46
+
47
+ function normalize(input, now) {
48
+ if (!record(input) || !Array.isArray(input.models) || !sources.has(input.source)) throw unsafe()
49
+ const models = []
50
+ const seen = new Set()
51
+ for (const model of input.models) {
52
+ if (!record(model)) continue
53
+ const id = text(model.id, MAX_ID)
54
+ if (!id || seen.has(id)) continue
55
+ seen.add(id)
56
+ models.push({ id, name: text(model.name, MAX_NAME) || id })
57
+ if (models.length === MAX_MODELS) break
58
+ }
59
+ return { models, checkedAt: timestamp(input.checkedAt, now), source: input.source }
60
+ }
61
+
62
+ function validate(document) {
63
+ if (!fields(document, ["schemaVersion", "entries"]) || document.schemaVersion !== 1 || !record(document.entries)) throw unsafe()
64
+ const entries = Object.entries(document.entries)
65
+ if (entries.length > MAX_ENTRIES) throw unsafe()
66
+ for (const [scope, entry] of entries) {
67
+ if (!validScope(scope) || !fields(entry, ["models", "checkedAt", "source"]) || !sources.has(entry.source)) throw unsafe()
68
+ const date = typeof entry.checkedAt === "string" ? new Date(entry.checkedAt) : new Date(NaN)
69
+ if (!Number.isFinite(date.getTime()) || date.toISOString() !== entry.checkedAt) throw unsafe()
70
+ if (!Array.isArray(entry.models) || entry.models.length > MAX_MODELS) throw unsafe()
71
+ const seen = new Set()
72
+ for (const model of entry.models) {
73
+ if (!fields(model, ["id", "name"]) || !model.id || !model.name || text(model.id, MAX_ID) !== model.id || text(model.name, MAX_NAME) !== model.name || seen.has(model.id)) throw unsafe()
74
+ seen.add(model.id)
75
+ }
76
+ }
77
+ return document
78
+ }
79
+
80
+ function regular(stat) {
81
+ if (!stat.isFile() || stat.isSymbolicLink() || !Number.isSafeInteger(stat.size) || stat.size < 0 || stat.size > MAX_BYTES) throw unsafe()
82
+ return stat
83
+ }
84
+
85
+ function sameFile(left, right) {
86
+ if (!left || !right) return left === right
87
+ return ["dev", "ino", "size", "mtimeMs", "ctimeMs"].every(key => left[key] === right[key])
88
+ }
89
+
90
+ function serialize(key, operation) {
91
+ const previous = queues.get(key) || Promise.resolve()
92
+ const task = previous.then(operation)
93
+ const settled = task.then(() => {}, () => {})
94
+ queues.set(key, settled)
95
+ return task.finally(() => {
96
+ if (queues.get(key) === settled) queues.delete(key)
97
+ })
98
+ }
99
+
100
+ function encode(entries, scope) {
101
+ // Reject individually oversized results; otherwise evict strictly by age.
102
+ if (Buffer.byteLength(JSON.stringify({ schemaVersion: 1, entries: { [scope]: entries[scope] } })) > MAX_BYTES) throw unsafe()
103
+ const oldest = Object.keys(entries).sort((a, b) => Date.parse(entries[a].checkedAt) - Date.parse(entries[b].checkedAt) || a.localeCompare(b))
104
+ let remaining = Object.keys(entries).length
105
+ while (remaining > MAX_ENTRIES) {
106
+ delete entries[oldest.shift()]
107
+ remaining--
108
+ }
109
+ let json = JSON.stringify({ schemaVersion: 1, entries })
110
+ while (Buffer.byteLength(json) > MAX_BYTES) {
111
+ const key = oldest.shift()
112
+ if (!key) throw unsafe()
113
+ delete entries[key]
114
+ json = JSON.stringify({ schemaVersion: 1, entries })
115
+ }
116
+ return json
117
+ }
118
+
119
+ /**
120
+ * Cache only normalized model names, timestamps, sources and caller-made SHA256 scopes.
121
+ * IDs are limited to 256 UTF-16 units and names to 512. Invalid names fall back to IDs.
122
+ * The queue coordinates this module's instances in one process, not other processes.
123
+ */
124
+ export function createUAModelCatalog(statePath, { io = fsPromises, now = Date.now } = {}) {
125
+ if (typeof statePath !== "string" || !isAbsolute(statePath) || statePath.includes("\0")) throw unsafe()
126
+ const path = `${statePath}.models.json`
127
+ const absolute = resolve(path)
128
+ const queueKey = process.platform === "win32" ? absolute.toLowerCase() : absolute
129
+
130
+ async function inspect() {
131
+ let stat
132
+ try {
133
+ stat = await io.lstat(path)
134
+ } catch (error) {
135
+ if (error.code === "ENOENT") return null
136
+ throw error
137
+ }
138
+ return regular(stat)
139
+ }
140
+
141
+ async function load() {
142
+ const stat = await inspect()
143
+ if (!stat) return { stat: null, document: { schemaVersion: 1, entries: {} } }
144
+ // O_NOFOLLOW is used where supported; lstat/fstat checks also cover Windows.
145
+ const handle = await io.open(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0))
146
+ try {
147
+ if (!sameFile(stat, regular(await handle.stat()))) throw unsafe()
148
+ // Read at most the observed length plus one byte, never an unbounded readFile.
149
+ const buffer = Buffer.allocUnsafe(stat.size + 1)
150
+ let length = 0
151
+ while (length < buffer.length) {
152
+ const { bytesRead } = await handle.read(buffer, length, Math.min(64 * 1024, buffer.length - length), null)
153
+ if (!bytesRead) break
154
+ length += bytesRead
155
+ }
156
+ if (length !== stat.size || !sameFile(stat, regular(await handle.stat())) || !sameFile(stat, await inspect())) throw unsafe()
157
+ const json = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, length))
158
+ return { stat, document: validate(JSON.parse(json)) }
159
+ } finally {
160
+ await handle.close()
161
+ }
162
+ }
163
+
164
+ async function guarded(scope, operation) {
165
+ if (!validScope(scope)) throw unsafe()
166
+ try {
167
+ return await serialize(queueKey, operation)
168
+ } catch {
169
+ throw unsafe()
170
+ }
171
+ }
172
+
173
+ return {
174
+ path,
175
+ async read(scope) {
176
+ return guarded(scope, async () => {
177
+ const { document } = await load()
178
+ const entry = document.entries[scope]
179
+ if (!entry) return null
180
+ const clock = now()
181
+ if (!Number.isFinite(clock)) throw unsafe()
182
+ return clock - Date.parse(entry.checkedAt) >= TTL ? null : entry
183
+ })
184
+ },
185
+ async write(scope, input) {
186
+ return guarded(scope, async () => {
187
+ const entry = normalize(input, now)
188
+ const original = await load()
189
+ const entries = { ...original.document.entries, [scope]: entry }
190
+ const json = encode(entries, scope)
191
+ await io.mkdir(dirname(path), { recursive: true, mode: 0o700 })
192
+ const temporary = `${path}.${randomUUID()}.tmp`
193
+ let handle
194
+ let owned = false
195
+ try {
196
+ handle = await io.open(temporary, "wx", 0o600)
197
+ owned = true
198
+ await handle.writeFile(json, "utf8")
199
+ await handle.sync()
200
+ await handle.close()
201
+ handle = null
202
+ // Revalidate immediately before replacing; this is not cross-process CAS.
203
+ const current = await load()
204
+ if (!sameFile(original.stat, current.stat)) throw unsafe()
205
+ await io.rename(temporary, path)
206
+ owned = false
207
+ return entry
208
+ } finally {
209
+ try {
210
+ if (handle) await handle.close()
211
+ } finally {
212
+ if (owned) await io.unlink(temporary)
213
+ }
214
+ }
215
+ })
216
+ },
217
+ }
218
+ }
@@ -0,0 +1,207 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { AsyncResource } from 'node:async_hooks'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { dirname, join, isAbsolute } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { createUAStore } from './ua-store.mjs'
7
+ import { createUAService } from './ua-service.mjs'
8
+ import { createUAModelCatalog } from './ua-model-catalog.mjs'
9
+ import { createUATransport, protocolForModel, SDK_BY_PROTOCOL, apiBase, discoverUA, siteRoot, modelQueryScope } from './ua-runtime.mjs'
10
+ import { startSetupServer } from './setup-server.mjs'
11
+ import { createProfileStore } from './store.mjs'
12
+ import { createAccountManager } from './accounts.mjs'
13
+ import { ensureCheckinScheduler } from './scheduler.mjs'
14
+ import { isDeepStrictEqual } from 'node:util'
15
+ import { createPlugin as createLegacyPlugin } from './plugin.mjs'
16
+ import { resolveProfilePath } from './runtime.mjs'
17
+
18
+ const PREFIX = 'ua-pro:connection:'
19
+ const observed = new Map()
20
+ const stores = new Map()
21
+ const modelRoutes = new Map()
22
+ const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 20)
23
+ export function uaStorePath(options = {}, env = process.env) {
24
+ const path = options.uaFile ?? env.UA_PRO_FILE ?? fileURLToPath(new URL('../runtime/ua-pro.json', import.meta.url))
25
+ if (typeof path !== 'string' || !isAbsolute(path)) throw new Error('UA PRO 配置路径必须为绝对路径')
26
+ return path
27
+ }
28
+ function sharedStore(path) {
29
+ if (!stores.has(path)) stores.set(path, createUAStore(path))
30
+ return stores.get(path)
31
+ }
32
+ function providerOf(connection) { return connection.independent && connection.siteId ? `ua-pro-${connection.siteId}` : 'ua-pro' }
33
+ function safeModel(model) { return typeof model === 'string' && model && !/[\x00-\x1f\x7f]/.test(model) }
34
+
35
+ export async function createUAPlugin(input = {}, options = {}, dependencies = {}, siteId = '') {
36
+ const allowed = ['uaFile', 'profileFile', 'accountBindings', 'baseURL', 'userAgent', 'headers', 'models', 'parameters', 'searches']
37
+ if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some(key => !allowed.includes(key))) throw new Error('UA PRO 插件选项无效')
38
+ const path = uaStorePath(options)
39
+ const store = dependencies.uaStore ?? sharedStore(path)
40
+ await store.initialize()
41
+ const id = siteId ? `ua-pro-${siteId}` : 'ua-pro'
42
+ const legacyStore = dependencies.legacyStore ?? createProfileStore(resolveProfilePath({ configPath: options.profileFile }))
43
+ const accountManager = dependencies.accountManager ?? createAccountManager({ path: join(dirname(path), 'accounts.json') })
44
+ if (input.magpie && !siteId) (dependencies.startScheduler ?? ensureCheckinScheduler)({ accountManager, stateDirectory: join(dirname(path), 'schedule-claims'), onError: () => {} })
45
+ const fetchImpl = dependencies.fetchImpl ?? globalThis.fetch
46
+ const modelCatalog = dependencies.modelCatalog ?? createUAModelCatalog(store.path)
47
+ const contexts = new Map()
48
+ const contextHeader = `x-magpie-ua-pro-context-${randomUUID()}`
49
+ const auths = async () => {
50
+ if (dependencies.readHostAuth) return dependencies.readHostAuth()
51
+ if (!input.directory) return {}
52
+ try { const value = JSON.parse(await readFile(join(input.directory, 'plugin-auth.json'), 'utf8')); if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(); return value }
53
+ catch (error) { if (error.code === 'ENOENT') return {}; throw new Error('无法读取宿主账号,停止操作以保护已有凭据') }
54
+ }
55
+ const readAuth = async (provider, account) => (await auths())[account || provider]
56
+ const register = async connection => {
57
+ if (!input.client?.auth?.set || connection.auth?.source === 'host') return
58
+ const provider = providerOf(connection)
59
+ const account = `${provider}#${digest(connection.id)}`
60
+ const existing = (await auths())[account]
61
+ const key = PREFIX + connection.id
62
+ if (existing && existing.key !== key) throw new Error('目标宿主账号已被其他凭据占用')
63
+ if (existing) return
64
+ const response = await input.client.auth.set({ path: { id: account }, body: { type: 'api', key, accountId: connection.name, metadata: { uaConnectionId: connection.id } } })
65
+ if (response?.data !== true) throw new Error('宿主未确认连接注册,请重载并核对')
66
+ }
67
+ const routeKey = scope => `${store.path}:${scope}`
68
+ const modelFetchFor = scope => {
69
+ const key = routeKey(scope)
70
+ const saved = modelRoutes.get(key)
71
+ if (saved && Date.now() - saved.at <= 15 * 60 * 1000) return saved.fetch
72
+ modelRoutes.delete(key)
73
+ return fetchImpl
74
+ }
75
+ const service = createUAService({ store, legacyStore, accountManager, fetchImpl, readAuth, modelCatalog, modelFetchFor, onConnectionSaved: input.client?.auth?.set ? register : undefined,
76
+ runtime: () => ({ mode: input.magpie ? 'host' : 'standalone', observed: [...observed.values()].filter(item => item.provider === id) }) })
77
+ let declared = {}
78
+ const getNativeConnection = async (auth, provider = {}) => {
79
+ if (auth?.type !== 'api' || typeof auth.key !== 'string' || !auth.key.trim()) return null
80
+ const snapshot = await store.snapshot({ secrets: true })
81
+ const selected = auth.metadata?.uaConnectionId ?? (auth.key.startsWith(PREFIX) ? auth.key.slice(PREFIX.length) : null)
82
+ if (selected) {
83
+ const connection = snapshot.connections.find(item => item.id === selected)
84
+ if (!connection || providerOf(connection) !== id || connection.enabled === false) throw new Error('此连接已移除、停用或属于其他站点')
85
+ return connection
86
+ }
87
+ const configuredSite = siteId ? snapshot.connections.find(item => item.siteId === siteId && item.independent) : null
88
+ const baseURL = configuredSite?.baseURL ?? auth.metadata?.baseURL ?? auth.metadata?.endpoint ?? provider.options?.baseURL ?? provider.api ?? declared.api ?? options.baseURL
89
+ if (!baseURL) throw new Error('请为当前账号填写上游 Base URL')
90
+ siteRoot(baseURL)
91
+ const connectionId = `host.${digest([id, baseURL, auth.key])}`
92
+ let connection = snapshot.connections.find(item => item.id === connectionId)
93
+ if (!connection) {
94
+ const all = await auths()
95
+ const scope = input.magpie?.account?.(id)
96
+ const matches = Object.entries(all).filter(([slot, saved]) => (slot === id || slot.startsWith(id + '#')) && saved.type === 'api' && saved.key === auth.key
97
+ && (saved.metadata?.baseURL ?? saved.metadata?.endpoint ?? baseURL) === (auth.metadata?.baseURL ?? auth.metadata?.endpoint ?? baseURL))
98
+ const account = scope && all[scope]?.key === auth.key ? scope : matches.length === 1 ? matches[0][0] : matches.length === 0 && !Object.keys(all).length ? id : null
99
+ if (!account) throw new Error('无法唯一识别当前宿主账号,请从明确的账号入口重新打开')
100
+ const result = await store.saveConnection({ connection: { id: connectionId, name: auth.accountId ?? `${id} 账号`, enabled: true, siteId, independent: Boolean(siteId),
101
+ baseURL, protocol: auth.metadata?.protocol ?? 'auto', apiPaths: {}, modelProtocols: {}, models: [],
102
+ auth: { source: 'host', mode: 'api-key', provider: id, account, key: '', value: '' }, modelBindings: {} } })
103
+ connection = result.connection
104
+ }
105
+ return connection
106
+ }
107
+ const executeWithAuth = (auth, connection, fallback = readAuth) => async (provider, account) => connection.auth.source === 'host' && connection.auth.provider === provider && connection.auth.account === account
108
+ ? { ...auth, metadata: { baseURL: connection.baseURL, ...auth.metadata } } : fallback(provider, account)
109
+ const management = async () => {
110
+ const account = input.magpie?.account?.(id)
111
+ const originalAuth = account ? (await auths())[account] : undefined
112
+ const hostBinding = originalAuth ? { account, selected: originalAuth.metadata?.newApiAccountId ?? null, changed: false } : undefined
113
+ const session = await (dependencies.startSetup ?? startSetupServer)({ store: legacyStore, accountManager, hostBinding, uaService: service, finishOnSave: false })
114
+ return { url: new URL('pro', session.url).href, method: 'auto', instructions: '在 UA PRO 中管理连接和模板;保存版本与应用连接分开。完成后关闭页面返回 Magpie。',
115
+ async callback(_code, { signal } = {}) {
116
+ let stop
117
+ try {
118
+ const result = await (signal ? Promise.race([session.finished, new Promise(resolve => { stop = () => resolve({ status: 'cancelled' }); if (signal.aborted) stop(); else signal.addEventListener('abort', stop, { once: true }) })]) : session.finished)
119
+ if (result.status === 'timeout') return { type: 'failed', error: '配置会话已超时;已保存的更改保留' }
120
+ if (signal?.aborted) return { type: 'failed', error: '配置已取消;此前保存的更改保留' }
121
+ if (hostBinding?.changed) {
122
+ const current = (await auths())[account]
123
+ if (!isDeepStrictEqual(current, originalAuth)) return { type: 'failed', error: '宿主账号已改变,余额绑定未写入;请重新打开核对' }
124
+ const updated = { ...current, metadata: { ...current.metadata, newApiAccountId: hostBinding.selected ?? '' } }
125
+ const written = await input.client?.auth?.set?.({ path: { id: account }, body: updated })
126
+ if (written?.data !== true || !isDeepStrictEqual((await auths())[account], updated)) return { type: 'failed', error: '宿主未确认余额绑定保存' }
127
+ }
128
+ return { type: 'success' }
129
+ } finally { if (stop) signal.removeEventListener('abort', stop); await session.close() }
130
+ } }
131
+ }
132
+ const usageHooks = await createLegacyPlugin({ ...input, magpie: undefined }, { profileFile: legacyStore.path, ...(options.accountBindings ? { accountBindings: options.accountBindings } : {}) }, { store: legacyStore, accountManager })
133
+ return {
134
+ async config(config) {
135
+ const snapshot = await store.snapshot({ secrets: true })
136
+ for (const connection of snapshot.connections.filter(item => item.enabled && providerOf(item) === id)) await register(connection)
137
+ config.provider ??= {}
138
+ config.provider[id] ??= { name: siteId ? `UA PRO · ${siteId}` : 'UA PRO', npm: '@ai-sdk/openai-compatible', models: {} }
139
+ declared = config.provider[id]
140
+ },
141
+ auth: {
142
+ provider: id,
143
+ methods: [{ type: 'api', label: '连接上游 API key', placeholder: '此上游的模型 API key', prompts: [{ type: 'text', key: 'baseURL', message: '上游 Base URL', placeholder: 'https://your-service.example', validate(value) { try { siteRoot(value); return undefined } catch { return '请填写有效的 HTTPS Base URL(本机可用 HTTP)' } } }] },
144
+ { type: 'oauth', label: '打开 UA PRO 管理', authorize: management }],
145
+ async loader(getAuth, provider) {
146
+ const auth = await getAuth()
147
+ const connection = await getNativeConnection(auth, provider)
148
+ if (!connection) return {}
149
+ return { baseURL: siteRoot(connection.baseURL) + '/v1', apiKey: '',
150
+ fetch: async (url, init = {}) => {
151
+ const current = await getAuth()
152
+ const bound = await getNativeConnection(current, provider)
153
+ if (!bound) throw new Error('当前账号未登录')
154
+ const headers = new Headers(init.headers)
155
+ const token = headers.get(contextHeader)
156
+ const host = contexts.get(token)?.value ?? {}
157
+ contexts.delete(token)
158
+ headers.delete(contextHeader)
159
+ const transport = createUATransport({ getSnapshot: () => store.snapshot({ secrets: true }), getConnection: snapshot => snapshot.connections.find(item => item.id === bound.id),
160
+ readAuth: executeWithAuth(current, bound), fetchImpl, getContext: () => host,
161
+ observe: value => { observed.set(`${id}:${bound.id}`, { ...value, provider: id, at: new Date().toISOString() }); if (observed.size > 256) observed.delete(observed.keys().next().value) } })
162
+ return transport(url, { ...init, headers })
163
+ } }
164
+ },
165
+ usage: usageHooks.auth.usage,
166
+ checkin: usageHooks.auth.checkin,
167
+ },
168
+ provider: { id, async models(provider = {}, { auth } = {}) {
169
+ const connection = await getNativeConnection(auth, provider)
170
+ if (!connection) return provider.models ?? {}
171
+ const queryAuth = executeWithAuth(auth, connection)
172
+ if (input.magpie) {
173
+ const key = routeKey(await modelQueryScope(connection, queryAuth))
174
+ // Keep the host's account proxy context for management callbacks. Never
175
+ // bind credentials here; queryUAModels resolves them for each request.
176
+ modelRoutes.delete(key)
177
+ modelRoutes.set(key, { at: Date.now(), fetch: AsyncResource.bind((url, init) => fetchImpl(url, init)) })
178
+ if (modelRoutes.size > 128) modelRoutes.delete(modelRoutes.keys().next().value)
179
+ }
180
+ const list = await discoverUA(connection, { fetchImpl, readAuth: queryAuth, catalog: modelCatalog, source: 'host' })
181
+ return Object.fromEntries(list.filter(item => safeModel(item.id)).map(item => {
182
+ const protocol = protocolForModel(connection, item.id)
183
+ const existing = provider.models?.[item.id] ?? {}
184
+ return [item.id, { ...existing, ...item, api: { ...(existing.api ?? {}), id: existing.api?.id ?? item.id, url: apiBase(connection, protocol), npm: SDK_BY_PROTOCOL[protocol] },
185
+ provider: { api: apiBase(connection, protocol), npm: SDK_BY_PROTOCOL[protocol] } }]
186
+ }))
187
+ } },
188
+ async 'chat.headers'(context, output) {
189
+ if (context?.model?.providerID !== id && context?.provider?.info?.id !== id) return
190
+ const now = Date.now()
191
+ for (const [key, item] of contexts) if (now - item.at > 60000) contexts.delete(key)
192
+ if (contexts.size >= 2048) contexts.delete(contexts.keys().next().value)
193
+ const token = randomUUID()
194
+ contexts.set(token, { at: now, value: { sessionID: context.sessionID, model: context.model?.id ?? context.model?.api?.id } })
195
+ output.headers[contextHeader] = token
196
+ },
197
+ }
198
+ }
199
+
200
+ export async function createUASite(input, options, slot) {
201
+ const store = sharedStore(uaStorePath(options))
202
+ await store.initialize()
203
+ const state = await store.snapshot()
204
+ const ids = [...new Set(state.connections.filter(item => item.independent && item.siteId).map(item => item.siteId))]
205
+ const id = ids[slot]
206
+ return id ? createUAPlugin(input, options, { uaStore: store }, id) : {}
207
+ }
@@ -0,0 +1,229 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { transformRequest } from './ua-transform.mjs'
3
+ import { resolveTemplate } from './ua-store.mjs'
4
+
5
+ import { SDK_BY_PROTOCOL, PATHS, protocolForModel, siteRoot, apiBase, operationURL } from '../lib/ua-protocol.mjs'
6
+ export { SDK_BY_PROTOCOL, protocolForModel, siteRoot, apiBase, operationURL } from '../lib/ua-protocol.mjs'
7
+ const authHeaders = ['authorization', 'x-api-key', 'x-goog-api-key', 'api-key', 'cookie', 'proxy-authorization']
8
+ const err = (message, status = 400) => Object.assign(new Error(message), { status })
9
+
10
+ export function operationForURL(input) {
11
+ const path = new URL(input instanceof Request ? input.url : input).pathname
12
+ if (/\/messages\/count_tokens$/.test(path)) return { protocol: 'anthropic', operation: 'count_tokens' }
13
+ if (/\/messages$/.test(path)) return { protocol: 'anthropic', operation: 'messages' }
14
+ if (/\/responses$/.test(path)) return { protocol: 'responses', operation: 'responses' }
15
+ if (/\/chat\/completions$/.test(path)) return { protocol: 'chat-completions', operation: 'chat/completions' }
16
+ const gemini = /\/models\/(.+):(generateContent|streamGenerateContent|countTokens)$/.exec(path)
17
+ if (gemini) return { protocol: 'gemini', operation: gemini[2], model: decodeURIComponent(gemini[1]) }
18
+ throw err('请求路径不是支持的模型操作')
19
+ }
20
+
21
+ async function credential(connection, readAuth) {
22
+ let auth = connection.auth ?? {}
23
+ if (auth.source === 'host') {
24
+ const current = await readAuth?.(auth.provider, auth.account)
25
+ if (current?.type !== 'api' || typeof current.key !== 'string' || !current.key.trim()) throw err('当前宿主账号缺少 API key,请重新登录', 401)
26
+ const endpoint = current.metadata?.baseURL ?? current.metadata?.endpoint
27
+ if (!endpoint || new URL(endpoint).href.replace(/\/+$/, '') !== new URL(connection.baseURL).href.replace(/\/+$/, '')) throw err('宿主账号地址已改变,此旧连接不能使用当前凭据', 409)
28
+ auth = { ...auth, key: current.key }
29
+ }
30
+ if (['api-key', 'bearer'].includes(auth.mode) && !auth.key?.trim()) throw err('此连接缺少 API key', 400)
31
+ if (auth.mode === 'custom' && !auth.value?.trim()) throw err('此连接缺少自定义 Authorization', 400)
32
+ return auth
33
+ }
34
+
35
+ export function addAuthentication(headers, auth, protocol) {
36
+ const output = new Headers(headers)
37
+ for (const name of authHeaders) output.delete(name)
38
+ for (const name of [...output.keys()]) if (name.startsWith('x-magpie-')) output.delete(name)
39
+ if (auth.mode === 'bearer') output.set('authorization', `Bearer ${auth.key}`)
40
+ else if (auth.mode === 'custom') output.set('authorization', auth.value)
41
+ else if (auth.mode === 'api-key') output.set(protocol === 'anthropic' ? 'x-api-key' : protocol === 'gemini' ? 'x-goog-api-key' : 'authorization', ['responses', 'chat-completions'].includes(protocol) ? `Bearer ${auth.key}` : auth.key)
42
+ return output
43
+ }
44
+
45
+ function bodyModel(init) {
46
+ try {
47
+ const value = typeof init.body === 'string' ? JSON.parse(init.body) : ArrayBuffer.isView(init.body) && init.body.byteLength <= 2 * 1024 * 1024 ? JSON.parse(new TextDecoder().decode(init.body)) : undefined
48
+ return typeof value?.model === 'string' ? value.model : ''
49
+ } catch { return '' }
50
+ }
51
+
52
+ export async function prepareUpstream({ snapshot, connection, input, init = {}, context = {}, readAuth, template, templateRef, protocol: forcedProtocol, operation: forcedOperation, model: chosenModel }) {
53
+ if (!connection || connection.enabled === false) throw err('连接不存在或已停用', 404)
54
+ const incoming = new URL(input instanceof Request ? input.url : input)
55
+ const root = new URL(siteRoot(connection.baseURL))
56
+ const prefixes = [...new Set(Object.values(PATHS).concat(Object.values(connection.apiPaths ?? {})))].map(path => new URL(root.href.replace(/\/$/, '') + path.replace(/\/$/, '') + '/'))
57
+ if (incoming.origin !== root.origin || incoming.username || incoming.password || incoming.hash || !prefixes.some(prefix => incoming.pathname.startsWith(prefix.pathname))) throw err('拒绝发送凭据到此连接以外的路径')
58
+ const detected = operationForURL(input)
59
+ const protocol = forcedProtocol ?? detected.protocol
60
+ const operation = forcedOperation ?? detected.operation
61
+ if (detected.protocol !== protocol || detected.operation !== operation) throw err('请求协议与选定测试协议不匹配')
62
+ const model = chosenModel || context.model || bodyModel(init) || detected.model || ''
63
+ const target = new URL(operationURL(connection, protocol, operation, model || detected.model || ''))
64
+ target.search = incoming.search
65
+ if (protocol === 'gemini') target.searchParams.delete('key')
66
+ const selected = template !== undefined ? template : templateRef ? snapshot.templates.find(item => item.id === templateRef.id && item.revision === templateRef.revision) : resolveTemplate(snapshot, connection, protocol, model)
67
+ if (templateRef && !selected) throw err('指定模板版本不存在', 404)
68
+ const auth = await credential(connection, readAuth)
69
+ const scope = createHash('sha256').update(JSON.stringify([connection.baseURL, auth.mode, auth.key ?? '', auth.value ?? ''])).digest('hex')
70
+ const destination = target.href === incoming.href ? input : input instanceof Request ? new Request(target.href, input) : target.href
71
+ const transformed = await transformRequest({ template: selected, protocol, operation, input: destination, init,
72
+ context: { ...context, baseURL: connection.baseURL, credentialScope: scope, model: model || detected.model || '', requestId: context.requestId ?? randomUUID() } })
73
+ transformed.init = { ...transformed.init, headers: addAuthentication(transformed.init.headers, auth, protocol), redirect: 'manual' }
74
+ const result = { ...transformed, protocol, operation, model, generation: snapshot.generation, connectionId: connection.id }
75
+ Object.defineProperty(result, 'credentialValues', { value: [auth.key, auth.value].filter(Boolean), enumerable: false })
76
+ return result
77
+ }
78
+
79
+ export function createUATransport({ getSnapshot, getConnection, readAuth, fetchImpl = globalThis.fetch, getContext = () => ({}), observe = () => {} }) {
80
+ return async (input, init = {}) => {
81
+ const snapshot = await getSnapshot()
82
+ const connection = await getConnection(snapshot)
83
+ const prepared = await prepareUpstream({ snapshot, connection, input, init, context: await getContext(input, init), readAuth })
84
+ observe({ connectionId: connection.id, generation: snapshot.generation, templateId: prepared.summary?.templateId, revision: prepared.summary?.revision })
85
+ return fetchImpl(prepared.input, prepared.init)
86
+ }
87
+ }
88
+
89
+ export async function boundedResponse(response, { limit = 1024 * 1024, signal } = {}) {
90
+ if (Number(response.headers.get('content-length')) > limit) { await response.body?.cancel(); throw err('响应超过允许大小') }
91
+ const reader = response.body?.getReader()
92
+ if (!reader) return ''
93
+ let size = 0
94
+ const chunks = []
95
+ const stop = () => { void reader.cancel().catch(() => {}) }
96
+ signal?.addEventListener('abort', stop, { once: true })
97
+ try {
98
+ for (;;) {
99
+ signal?.throwIfAborted()
100
+ const { done, value } = await reader.read()
101
+ signal?.throwIfAborted()
102
+ if (done) break
103
+ size += value.byteLength
104
+ if (size > limit) throw err('响应超过允许大小')
105
+ chunks.push(Buffer.from(value))
106
+ }
107
+ return Buffer.concat(chunks).toString('utf8')
108
+ } finally {
109
+ signal?.removeEventListener('abort', stop)
110
+ stop()
111
+ reader.releaseLock()
112
+ }
113
+ }
114
+
115
+ export async function discoverUA(connection, options = {}) {
116
+ if (connection.models?.length) return connection.models.map(id => ({ id, name: id }))
117
+ return queryUAModels(connection, options)
118
+ }
119
+
120
+ export async function modelQueryScope(connection, readAuth) {
121
+ return (await prepareModelQuery(connection, readAuth)).scope
122
+ }
123
+
124
+ async function prepareModelQuery(connection, readAuth) {
125
+ const auth = await credential(connection, readAuth)
126
+ const protocol = protocolForModel(connection)
127
+ const headers = addAuthentication({ accept: 'application/json', 'anthropic-version': '2023-06-01' }, auth, protocol)
128
+ const url = apiBase(connection, protocol) + '/models'
129
+ // Scope the discovery record to the actual request, including the credential.
130
+ // Responses and auto may share a list only when URL and authentication agree.
131
+ const scope = createHash('sha256').update(JSON.stringify([url, [...headers], auth.source, auth.provider ?? '', auth.account ?? ''])).digest('hex')
132
+ return { url, headers, protocol, scope, secrets: [auth.key, auth.value].filter(Boolean) }
133
+ }
134
+
135
+ function catalogResult(entry) {
136
+ return { models: entry.models, count: entry.models.length, checkedAt: entry.checkedAt, source: `${entry.source}-cache`, cached: true, stale: true }
137
+ }
138
+
139
+ export async function readModelCatalog(connection, { catalog, readAuth } = {}) {
140
+ if (!catalog) return null
141
+ try {
142
+ const query = await prepareModelQuery(connection, readAuth)
143
+ const entry = await catalog.read(query.scope)
144
+ return entry ? catalogResult(entry) : null
145
+ } catch { return null }
146
+ }
147
+
148
+ function modelNetworkError(cause) {
149
+ const codes = []
150
+ for (let current = cause, depth = 0; current && depth < 4; current = current.cause, depth++) {
151
+ if (typeof current.code === 'string') codes.push(current.code)
152
+ }
153
+ const known = [
154
+ ['dns', ['ENOTFOUND', 'EAI_AGAIN', 'DNSLookupError'], '无法解析上游域名,请检查地址、DNS 或代理设置'],
155
+ ['connection-refused', ['ECONNREFUSED', 'ConnectionRefused'], '上游或代理拒绝连接,请检查服务地址和代理设置'],
156
+ ['connection-reset', ['ECONNRESET', 'EPIPE', 'ConnectionClosed', 'UND_ERR_SOCKET'], '上游或代理中断了连接,请检查网络和代理设置'],
157
+ ['timeout', ['ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT'], '连接上游超时,请检查网络和代理设置'],
158
+ ['tls', ['CERT_HAS_EXPIRED', 'DEPTH_ZERO_SELF_SIGNED_CERT', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', 'ERR_TLS_CERT_ALTNAME_INVALID', 'CERT_SIGNATURE_FAILURE'], '上游 TLS 证书验证失败,请检查证书或代理配置'],
159
+ ]
160
+ const [code, , message] = known.find(([, matches]) => codes.some(value => matches.includes(value))) ?? ['network', [], '无法连接上游,请检查网络或代理设置']
161
+ return Object.assign(err(message), { code, catalogFallback: true })
162
+ }
163
+
164
+ async function fetchModelList(query, fetchImpl, signal) {
165
+ let response
166
+ try { response = await fetchImpl(query.url, { method: 'GET', headers: query.headers, signal, redirect: 'manual' }) }
167
+ catch (error) { signal.throwIfAborted(); throw modelNetworkError(error) }
168
+ if (!response.ok) {
169
+ void response.body?.cancel().catch(() => {})
170
+ const error = Object.assign(err(`模型列表返回 HTTP ${response.status}`, response.status), { code: 'upstream-http', catalogFallback: response.status >= 500 })
171
+ if (response.status === 401) error.signIn = 'expired'
172
+ throw error
173
+ }
174
+ let text
175
+ try { text = await boundedResponse(response, { signal }) }
176
+ catch (error) { signal.throwIfAborted(); if (error.status) throw error; throw modelNetworkError(error) }
177
+ let data
178
+ try { data = JSON.parse(text) } catch { throw Object.assign(err('模型列表不是有效的 JSON'), { code: 'invalid-model-response' }) }
179
+ const list = query.protocol === 'gemini' && Array.isArray(data?.models)
180
+ ? data.models.map(item => ({ id: typeof item?.name === 'string' ? item.name.replace(/^models\//, '') : '', display_name: item?.displayName, limit: { context: item?.inputTokenLimit, output: item?.outputTokenLimit } }))
181
+ : data?.data
182
+ if (!Array.isArray(list)) throw Object.assign(err('模型列表格式无效'), { code: 'invalid-model-response' })
183
+ const models = new Map()
184
+ const safeText = (value, limit = 512) => typeof value === 'string' && value.trim() && value.trim().length <= limit && !/[\x00-\x1f\x7f-\x9f]/.test(value) && !query.secrets.some(secret => value.includes(secret))
185
+ for (const item of list) {
186
+ if (!safeText(item?.id, 256)) continue
187
+ const id = item.id.trim()
188
+ if (models.has(id)) continue
189
+ const name = item.display_name ?? item.name
190
+ models.set(id, { id, name: safeText(name) ? name.trim() : id, ...(item.limit && typeof item.limit === 'object' ? { limit: item.limit } : {}) })
191
+ if (models.size > 2048) throw Object.assign(err('模型列表超过 2048 个,请使用手填模型缩小范围'), { code: 'invalid-model-response' })
192
+ }
193
+ return [...models.values()]
194
+ }
195
+
196
+ export async function lookupUAModels(connection, { readAuth, fetchImpl = globalThis.fetch, modelFetchFor, signal = AbortSignal.timeout(15000), catalog, source = 'management', allowCached = false } = {}) {
197
+ signal.throwIfAborted()
198
+ const query = await prepareModelQuery(connection, readAuth)
199
+ let models
200
+ try {
201
+ const send = await modelFetchFor?.(query.scope) ?? fetchImpl
202
+ signal.throwIfAborted()
203
+ models = await fetchModelList(query, send, signal)
204
+ signal.throwIfAborted()
205
+ }
206
+ catch (error) {
207
+ const timedOut = signal.aborted && signal.reason?.name === 'TimeoutError'
208
+ if (signal.aborted && !timedOut) signal.throwIfAborted()
209
+ if (timedOut) error = Object.assign(err('模型列表读取超时,请检查网络和代理设置', 408), { code: 'timeout', catalogFallback: true })
210
+ if (allowCached && error.catalogFallback && catalog) {
211
+ const entry = await catalog.read(query.scope).catch(() => null)
212
+ if (signal.aborted && !timedOut) signal.throwIfAborted()
213
+ if (entry) return { ...catalogResult(entry), refreshError: { code: error.code, message: error.message } }
214
+ }
215
+ throw error
216
+ }
217
+ const checkedAt = new Date().toISOString()
218
+ let catalogWarning
219
+ if (catalog) {
220
+ try { await catalog.write(query.scope, { models: models.map(({ id, name }) => ({ id, name })), checkedAt, source }) }
221
+ catch { catalogWarning = '模型目录未能保存,其他管理会话暂时无法共享本次结果;请检查目录权限或缓存文件' }
222
+ }
223
+ return { models, count: models.length, checkedAt, source: 'upstream', cached: false, stale: false, ...(catalogWarning ? { catalogWarning } : {}) }
224
+ }
225
+
226
+ export async function queryUAModels(connection, options = {}) {
227
+ const result = await lookupUAModels(connection, { ...options, allowCached: false })
228
+ return result.models
229
+ }