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

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,250 @@
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 { createUAUsage } from './ua-usage.mjs'
16
+ import { createKeyUsage } from './key-usage.mjs'
17
+ import { resolveProfilePath } from './runtime.mjs'
18
+
19
+ const PREFIX = 'ua-pro:connection:'
20
+ const observed = new Map()
21
+ const stores = new Map()
22
+ const modelRoutes = new Map()
23
+ const loadedProviders = new Map()
24
+ const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 20)
25
+ export function uaStorePath(options = {}, env = process.env) {
26
+ const path = options.uaFile ?? env.UA_PRO_FILE ?? fileURLToPath(new URL('../runtime/ua-pro.json', import.meta.url))
27
+ if (typeof path !== 'string' || !isAbsolute(path)) throw new Error('UA PRO 配置路径必须为绝对路径')
28
+ return path
29
+ }
30
+ function sharedStore(path) {
31
+ if (!stores.has(path)) stores.set(path, createUAStore(path))
32
+ return stores.get(path)
33
+ }
34
+ function providerOf(connection) { return connection.independent && connection.siteId ? `ua-pro-${connection.siteId}` : 'ua-pro' }
35
+ function safeModel(model) { return typeof model === 'string' && model && !/[\x00-\x1f\x7f]/.test(model) }
36
+
37
+ export async function createUAPlugin(input = {}, options = {}, dependencies = {}, siteId = '') {
38
+ const allowed = ['uaFile', 'profileFile', 'accountBindings', 'baseURL', 'userAgent', 'headers', 'models', 'parameters', 'searches']
39
+ if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some(key => !allowed.includes(key))) throw new Error('UA PRO 插件选项无效')
40
+ const path = uaStorePath(options)
41
+ const store = dependencies.uaStore ?? sharedStore(path)
42
+ await store.initialize()
43
+ const id = siteId ? `ua-pro-${siteId}` : 'ua-pro'
44
+ if (!loadedProviders.has(store.path)) loadedProviders.set(store.path, new Set())
45
+ loadedProviders.get(store.path).add(id)
46
+ const legacyStore = dependencies.legacyStore ?? createProfileStore(resolveProfilePath({ configPath: options.profileFile }))
47
+ const accountManager = dependencies.accountManager ?? createAccountManager({ path: join(dirname(path), 'accounts.json') })
48
+ if (input.magpie && !siteId) (dependencies.startScheduler ?? ensureCheckinScheduler)({ accountManager, stateDirectory: join(dirname(path), 'schedule-claims'), onError: () => {} })
49
+ const fetchImpl = dependencies.fetchImpl ?? globalThis.fetch
50
+ const modelCatalog = dependencies.modelCatalog ?? createUAModelCatalog(store.path)
51
+ const contexts = new Map()
52
+ const contextHeader = `x-magpie-ua-pro-context-${randomUUID()}`
53
+ const auths = async () => {
54
+ if (dependencies.readHostAuth) return dependencies.readHostAuth()
55
+ if (!input.directory) return {}
56
+ 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 }
57
+ catch (error) { if (error.code === 'ENOENT') return {}; throw new Error('无法读取宿主账号,停止操作以保护已有凭据') }
58
+ }
59
+ const readAuth = async (provider, account) => (await auths())[account || provider]
60
+ const connectionStatus = async (connection, all = {}) => {
61
+ const provider = providerOf(connection)
62
+ const account = connection.auth?.source === 'host' ? connection.auth.account : `${provider}#${digest(connection.id)}`
63
+ const base = { connectionId: connection.id, provider, account, name: connection.name }
64
+ if (!connection.enabled) return { ...base, status: 'disabled' }
65
+ if (!loadedProviders.get(store.path)?.has(provider)) return { ...base, status: 'reload-required', message: '新增独立站点需要在 Magpie 重载此插件后载入' }
66
+ if (connection.auth?.source === 'host') {
67
+ const saved = all[account]
68
+ const endpoint = saved?.metadata?.baseURL ?? saved?.metadata?.endpoint
69
+ if (saved?.type !== 'api' || !saved.key || !endpoint || siteRoot(endpoint) !== siteRoot(connection.baseURL)) return { ...base, status: 'unregistered', message: '该连接引用的宿主账号尚未确认,请重新打开账号配置' }
70
+ return { ...base, status: 'registered' }
71
+ }
72
+ const saved = all[account]
73
+ if (saved && (saved.type !== 'api' || saved.key !== PREFIX + connection.id)) return { ...base, status: 'conflict', message: '目标宿主账号已被其他凭据占用,未覆盖' }
74
+ return { ...base, status: saved ? 'registered' : 'unregistered' }
75
+ }
76
+ const register = async connection => {
77
+ const all = await auths()
78
+ const current = await connectionStatus(connection, all)
79
+ if (current.status === 'conflict') throw new Error(current.message)
80
+ if (['disabled', 'reload-required'].includes(current.status) || connection.auth?.source === 'host') return current
81
+ if (!input.client?.auth?.set) return { ...current, status: 'host-unavailable', message: '请从 Magpie 打开管理页后同步连接' }
82
+ const existing = all[current.account]
83
+ if (current.status === 'registered' && existing.accountId === connection.name && existing.metadata?.uaConnectionId === connection.id) return current
84
+ const body = { ...existing, type: 'api', key: PREFIX + connection.id, accountId: connection.name, metadata: { ...existing?.metadata, uaConnectionId: connection.id } }
85
+ const response = await input.client.auth.set({ path: { id: current.account }, body })
86
+ const confirmed = (await auths())[current.account]
87
+ if (response?.data !== true || !isDeepStrictEqual(confirmed, body)) throw new Error('宿主未确认连接注册,请重载并核对')
88
+ return { ...current, status: 'registered' }
89
+ }
90
+ const syncConnections = async () => {
91
+ const snapshot = await store.snapshot()
92
+ const connections = []
93
+ for (const connection of snapshot.connections) {
94
+ try { connections.push(await register(connection)) }
95
+ catch { connections.push({ connectionId: connection.id, provider: providerOf(connection), status: 'failed', message: '宿主同步未完成;配置和凭据保留,请重载插件后核对' }) }
96
+ }
97
+ return { connections, message: connections.some(item => item.status === 'reload-required') ? '已同步可用账号;新增独立站点需要重载 UA PRO 插件' : '宿主同步结果已核对' }
98
+ }
99
+ const routeKey = scope => `${store.path}:${scope}`
100
+ const modelFetchFor = scope => {
101
+ const key = routeKey(scope)
102
+ const saved = modelRoutes.get(key)
103
+ if (saved && Date.now() - saved.at <= 15 * 60 * 1000) return saved.fetch
104
+ modelRoutes.delete(key)
105
+ return fetchImpl
106
+ }
107
+ const service = createUAService({ store, legacyStore, accountManager, fetchImpl, readAuth, modelCatalog, modelFetchFor, onConnectionSaved: input.client?.auth?.set ? register : undefined,
108
+ syncConnections: input.client?.auth?.set ? syncConnections : undefined,
109
+ runtime: async snapshot => {
110
+ const all = await auths()
111
+ return { mode: input.magpie ? 'host' : 'standalone', storagePath: store.path,
112
+ connections: await Promise.all((snapshot?.connections ?? []).map(connection => connectionStatus(connection, all))),
113
+ observed: [...observed.values()].filter(item => item.provider === id) }
114
+ } })
115
+ let declared = {}
116
+ const getNativeConnection = async (auth, provider = {}) => {
117
+ if (auth?.type !== 'api' || typeof auth.key !== 'string' || !auth.key.trim()) return null
118
+ const snapshot = await store.snapshot({ secrets: true })
119
+ const selected = auth.metadata?.uaConnectionId ?? (auth.key.startsWith(PREFIX) ? auth.key.slice(PREFIX.length) : null)
120
+ if (selected) {
121
+ const connection = snapshot.connections.find(item => item.id === selected)
122
+ if (!connection || providerOf(connection) !== id || connection.enabled === false) throw new Error('此连接已移除、停用或属于其他站点')
123
+ return connection
124
+ }
125
+ const configuredSite = siteId ? snapshot.connections.find(item => item.siteId === siteId && item.independent) : null
126
+ const baseURL = configuredSite?.baseURL ?? auth.metadata?.baseURL ?? auth.metadata?.endpoint ?? provider.options?.baseURL ?? provider.api ?? declared.api ?? options.baseURL
127
+ if (!baseURL) throw new Error('请为当前账号填写上游 Base URL')
128
+ siteRoot(baseURL)
129
+ const connectionId = `host.${digest([id, baseURL, auth.key])}`
130
+ let connection = snapshot.connections.find(item => item.id === connectionId)
131
+ if (!connection) {
132
+ const all = await auths()
133
+ const scope = input.magpie?.account?.(id)
134
+ const matches = Object.entries(all).filter(([slot, saved]) => (slot === id || slot.startsWith(id + '#')) && saved.type === 'api' && saved.key === auth.key
135
+ && (saved.metadata?.baseURL ?? saved.metadata?.endpoint ?? baseURL) === (auth.metadata?.baseURL ?? auth.metadata?.endpoint ?? baseURL))
136
+ const account = scope && all[scope]?.key === auth.key ? scope : matches.length === 1 ? matches[0][0] : matches.length === 0 && !Object.keys(all).length ? id : null
137
+ if (!account) throw new Error('无法唯一识别当前宿主账号,请从明确的账号入口重新打开')
138
+ const result = await store.saveConnection({ connection: { id: connectionId, name: auth.accountId ?? `${id} 账号`, enabled: true, siteId, independent: Boolean(siteId),
139
+ baseURL, protocol: auth.metadata?.protocol ?? 'auto', apiPaths: {}, modelProtocols: {}, models: [],
140
+ auth: { source: 'host', mode: 'api-key', provider: id, account, key: '', value: '' }, modelBindings: {} } })
141
+ connection = result.connection
142
+ }
143
+ return connection
144
+ }
145
+ const executeWithAuth = (auth, connection, fallback = readAuth) => async (provider, account) => connection.auth.source === 'host' && connection.auth.provider === provider && connection.auth.account === account
146
+ ? { ...auth, metadata: { baseURL: connection.baseURL, ...auth.metadata } } : fallback(provider, account)
147
+ const management = async () => {
148
+ const account = input.magpie?.account?.(id)
149
+ const originalAuth = account ? (await auths())[account] : undefined
150
+ const hostBinding = originalAuth ? { account, selected: originalAuth.metadata?.newApiAccountId ?? null, changed: false } : undefined
151
+ const session = await (dependencies.startSetup ?? startSetupServer)({ store: legacyStore, accountManager, hostBinding, uaService: service, finishOnSave: false })
152
+ return { url: new URL('pro', session.url).href, method: 'auto', instructions: '在 UA PRO 中管理连接和模板;保存版本与应用连接分开。完成后关闭页面返回 Magpie。',
153
+ async callback(_code, { signal } = {}) {
154
+ let stop
155
+ try {
156
+ 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)
157
+ if (result.status === 'timeout') return { type: 'failed', error: '配置会话已超时;已保存的更改保留' }
158
+ if (signal?.aborted) return { type: 'failed', error: '配置已取消;此前保存的更改保留' }
159
+ if (hostBinding?.changed) {
160
+ const current = (await auths())[account]
161
+ if (!isDeepStrictEqual(current, originalAuth)) return { type: 'failed', error: '宿主账号已改变,余额绑定未写入;请重新打开核对' }
162
+ const updated = { ...current, metadata: { ...current.metadata, newApiAccountId: hostBinding.selected ?? '' } }
163
+ const written = await input.client?.auth?.set?.({ path: { id: account }, body: updated })
164
+ if (written?.data !== true || !isDeepStrictEqual((await auths())[account], updated)) return { type: 'failed', error: '宿主未确认余额绑定保存' }
165
+ }
166
+ return { type: 'success' }
167
+ } finally { if (stop) signal.removeEventListener('abort', stop); await session.close() }
168
+ } }
169
+ }
170
+ const resolveAccount = async auth => {
171
+ const matches = Object.entries(await auths()).filter(([slot, saved]) => (slot === id || slot.startsWith(id + '#')) && isDeepStrictEqual(saved, auth))
172
+ return matches.length === 1 ? matches[0][0] : null
173
+ }
174
+ const usageHooks = createUAUsage({ providerId: id, input, options, accountManager, getConnection: getNativeConnection, resolveAccount,
175
+ queryKeyUsage: (connection, auth) => createKeyUsage({ fetchImpl, readAuth: executeWithAuth(auth, connection) })(connection) })
176
+ return {
177
+ async config(config) {
178
+ const snapshot = await store.snapshot({ secrets: true })
179
+ for (const connection of snapshot.connections.filter(item => item.enabled && providerOf(item) === id)) await register(connection)
180
+ config.provider ??= {}
181
+ config.provider[id] ??= { name: siteId ? `UA PRO · ${siteId}` : 'UA PRO', npm: '@ai-sdk/openai-compatible', models: {} }
182
+ declared = config.provider[id]
183
+ },
184
+ auth: {
185
+ provider: id,
186
+ 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)' } } }] },
187
+ { type: 'oauth', label: '打开 UA PRO 管理', authorize: management }],
188
+ async loader(getAuth, provider) {
189
+ const auth = await getAuth()
190
+ const connection = await getNativeConnection(auth, provider)
191
+ if (!connection) return {}
192
+ return { baseURL: siteRoot(connection.baseURL) + '/v1', apiKey: '',
193
+ fetch: async (url, init = {}) => {
194
+ const current = await getAuth()
195
+ const bound = await getNativeConnection(current, provider)
196
+ if (!bound) throw new Error('当前账号未登录')
197
+ const headers = new Headers(init.headers)
198
+ const token = headers.get(contextHeader)
199
+ const host = contexts.get(token)?.value ?? {}
200
+ contexts.delete(token)
201
+ headers.delete(contextHeader)
202
+ const transport = createUATransport({ getSnapshot: () => store.snapshot({ secrets: true }), getConnection: snapshot => snapshot.connections.find(item => item.id === bound.id),
203
+ readAuth: executeWithAuth(current, bound), fetchImpl, getContext: () => host,
204
+ observe: value => { observed.set(`${id}:${bound.id}`, { ...value, provider: id, at: new Date().toISOString() }); if (observed.size > 256) observed.delete(observed.keys().next().value) } })
205
+ return transport(url, { ...init, headers })
206
+ } }
207
+ },
208
+ usage: usageHooks.usage,
209
+ checkin: usageHooks.checkin,
210
+ },
211
+ provider: { id, async models(provider = {}, { auth } = {}) {
212
+ const connection = await getNativeConnection(auth, provider)
213
+ if (!connection) return provider.models ?? {}
214
+ const queryAuth = executeWithAuth(auth, connection)
215
+ if (input.magpie) {
216
+ const key = routeKey(await modelQueryScope(connection, queryAuth))
217
+ // Keep the host's account proxy context for management callbacks. Never
218
+ // bind credentials here; queryUAModels resolves them for each request.
219
+ modelRoutes.delete(key)
220
+ modelRoutes.set(key, { at: Date.now(), fetch: AsyncResource.bind((url, init) => fetchImpl(url, init)) })
221
+ if (modelRoutes.size > 128) modelRoutes.delete(modelRoutes.keys().next().value)
222
+ }
223
+ const list = await discoverUA(connection, { fetchImpl, readAuth: queryAuth, catalog: modelCatalog, source: 'host' })
224
+ return Object.fromEntries(list.filter(item => safeModel(item.id)).map(item => {
225
+ const protocol = protocolForModel(connection, item.id)
226
+ const existing = provider.models?.[item.id] ?? {}
227
+ return [item.id, { ...existing, ...item, api: { ...(existing.api ?? {}), id: existing.api?.id ?? item.id, url: apiBase(connection, protocol), npm: SDK_BY_PROTOCOL[protocol] },
228
+ provider: { api: apiBase(connection, protocol), npm: SDK_BY_PROTOCOL[protocol] } }]
229
+ }))
230
+ } },
231
+ async 'chat.headers'(context, output) {
232
+ if (context?.model?.providerID !== id && context?.provider?.info?.id !== id) return
233
+ const now = Date.now()
234
+ for (const [key, item] of contexts) if (now - item.at > 60000) contexts.delete(key)
235
+ if (contexts.size >= 2048) contexts.delete(contexts.keys().next().value)
236
+ const token = randomUUID()
237
+ contexts.set(token, { at: now, value: { sessionID: context.sessionID, model: context.model?.id ?? context.model?.api?.id } })
238
+ output.headers[contextHeader] = token
239
+ },
240
+ }
241
+ }
242
+
243
+ export async function createUASite(input, options, slot) {
244
+ const store = sharedStore(uaStorePath(options))
245
+ await store.initialize()
246
+ const state = await store.snapshot()
247
+ const ids = [...new Set(state.connections.filter(item => item.independent && item.siteId).map(item => item.siteId))]
248
+ const id = ids[slot]
249
+ return id ? createUAPlugin(input, options, { uaStore: store }, id) : {}
250
+ }