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.
- package/LICENSE +23 -0
- package/NOTICE.md +9 -0
- package/README.md +196 -2
- package/assets/icon.svg +9 -0
- package/index.mjs +23 -0
- package/lib/parameters.mjs +108 -0
- package/lib/ua-protocol.mjs +43 -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/accounts.mjs +281 -0
- package/src/connection.mjs +144 -0
- package/src/diagnostics.mjs +37 -0
- package/src/headers.mjs +60 -0
- package/src/newapi.mjs +237 -0
- package/src/options.mjs +48 -0
- package/src/plugin.mjs +374 -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 +465 -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 +207 -0
- package/src/ua-runtime.mjs +229 -0
- package/src/ua-service.mjs +97 -0
- package/src/ua-store.mjs +657 -0
- package/src/ua-tests.mjs +153 -0
- package/src/ua-transform.mjs +478 -0
- package/ui/accounts.css +75 -0
- package/ui/accounts.html +57 -0
- package/ui/accounts.js +582 -0
- package/ui/pro.css +1682 -0
- package/ui/pro.html +348 -0
- package/ui/pro.js +830 -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,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
|
+
}
|