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.
- package/LICENSE +23 -0
- package/NOTICE.md +9 -0
- package/README.md +200 -2
- package/assets/icon.svg +9 -0
- package/index.mjs +19 -0
- package/lib/parameters.mjs +108 -0
- package/lib/ua-protocol.mjs +51 -0
- package/lib/ua-test-plan.mjs +232 -0
- package/package.json +80 -4
- package/setup.mjs +39 -0
- package/src/account-profile.mjs +73 -0
- package/src/account-usage.mjs +33 -0
- package/src/accounts.mjs +354 -0
- package/src/connection.mjs +144 -0
- package/src/diagnostics.mjs +37 -0
- package/src/headers.mjs +60 -0
- package/src/key-usage.mjs +73 -0
- package/src/newapi.mjs +330 -0
- package/src/options.mjs +48 -0
- package/src/plugin.mjs +344 -0
- package/src/profile.mjs +217 -0
- package/src/runtime.mjs +39 -0
- package/src/schedule.mjs +88 -0
- package/src/scheduler.mjs +141 -0
- package/src/setup-server.mjs +478 -0
- package/src/store.mjs +58 -0
- package/src/transport.mjs +84 -0
- package/src/ua-model-catalog.mjs +218 -0
- package/src/ua-plugin.mjs +250 -0
- package/src/ua-runtime.mjs +229 -0
- package/src/ua-service.mjs +129 -0
- package/src/ua-store.mjs +660 -0
- package/src/ua-tests.mjs +153 -0
- package/src/ua-transform.mjs +478 -0
- package/src/ua-usage.mjs +64 -0
- package/ui/accounts.css +75 -0
- package/ui/accounts.html +67 -0
- package/ui/accounts.js +786 -0
- package/ui/pro.css +1683 -0
- package/ui/pro.html +347 -0
- package/ui/pro.js +900 -0
- package/ui/theme.js +39 -0
|
@@ -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
|
+
}
|