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
package/src/ua-tests.mjs
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { performance } from 'node:perf_hooks'
|
|
2
|
+
import { prepareUpstream, protocolForModel, lookupUAModels, readModelCatalog } from './ua-runtime.mjs'
|
|
3
|
+
import { createTestPlan, createTestSample, validateTestSample } from '../lib/ua-test-plan.mjs'
|
|
4
|
+
import { createUAModelCatalog } from './ua-model-catalog.mjs'
|
|
5
|
+
import { validateTemplate } from './ua-transform.mjs'
|
|
6
|
+
|
|
7
|
+
const sensitive = /^(?:authorization|proxy-authorization|api[-_]?key|x-api-key|x-goog-api-key|key|value|access[_-]?token|refresh[_-]?token|token|secret|cookie|set-cookie|credential|password)$/i
|
|
8
|
+
const error = message => Object.assign(new Error(message), { status: 400 })
|
|
9
|
+
|
|
10
|
+
function safeValue(value, secrets = [], depth = 0) {
|
|
11
|
+
if (depth > 24) return '[depth limited]'
|
|
12
|
+
if (typeof value === 'string') {
|
|
13
|
+
let text = value
|
|
14
|
+
for (const secret of secrets) if (secret) text = text.split(secret).join('[redacted]')
|
|
15
|
+
return text.length > 32000 ? text.slice(0, 32000) + '…' : text
|
|
16
|
+
}
|
|
17
|
+
if (Array.isArray(value)) return value.map(item => safeValue(item, secrets, depth + 1))
|
|
18
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sensitive.test(key) ? '[redacted]' : safeValue(item, secrets, depth + 1)]))
|
|
19
|
+
return value
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function responseDiagnostic(status, text, plan) {
|
|
23
|
+
let message = ''
|
|
24
|
+
try {
|
|
25
|
+
const data = JSON.parse(text)
|
|
26
|
+
message = typeof data.error?.message === 'string' ? data.error.message : typeof data.error === 'string' ? data.error : typeof data.message === 'string' ? data.message : ''
|
|
27
|
+
} catch { /* HTML bodies remain in the bounded response details. */ }
|
|
28
|
+
message = message.slice(0, 800)
|
|
29
|
+
if (status === 404) return { title: '上游接口或模型未找到', message: `本次已请求 ${plan.url}。填写站点地址不表示支持 ${plan.protocol} 接口。${message ? ` 上游说明:${message}` : ''}`, suggestions: ['核对该站点支持的协议及当前模型的接口。', '若站点使用专用部署前缀,可在本次协议路径中填写根路径后重新确认。', '不要把本地模板冲突、旧结果或模型列表成功等同于此接口可用。'] }
|
|
30
|
+
if ([401,403].includes(status)) return { title: '上游认证或权限拒绝', message: message || '站点拒绝了当前连接凭据或模型权限。', suggestions: ['核对当前连接的 key 与该协议所需认证方式;不自动更换账号。'] }
|
|
31
|
+
if (status === 429 || status >= 500) return { title: '上游暂时不可用', message: message || `上游返回 HTTP ${status},不是模型目录为空。`, suggestions: ['查看上游限流、负载或服务状态;本次不自动重试或改模型。'] }
|
|
32
|
+
if (status >= 400) return { title: '上游拒绝请求格式', message: message || `上游返回 HTTP ${status}。`, suggestions: ['核对本次协议样例和上游要求;用户自定义正文不会被自动改写。'] }
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createUATestRunner({ store, fetchImpl = globalThis.fetch, readAuth, modelFetchFor } = {}) {
|
|
37
|
+
return async function run(raw, { signal } = {}) {
|
|
38
|
+
if (!raw || !['preview', 'check', 'infer'].includes(raw.kind)) throw error('请选择预览、连接检查或实际请求测试')
|
|
39
|
+
if (raw.kind !== 'preview' && raw.confirmNetwork !== true) throw error('网络测试需要明确确认')
|
|
40
|
+
const snapshot = await store.snapshot({ secrets: true })
|
|
41
|
+
const connection = snapshot.connections.find(item => item.id === raw.connectionId)
|
|
42
|
+
if (raw.expectedGeneration !== undefined && raw.expectedGeneration !== snapshot.generation) throw Object.assign(error('配置已在确认后改变,请重新读取并核对本次请求'), { status: 409 })
|
|
43
|
+
if (connection && raw.expectedConnectionRevision !== undefined && raw.expectedConnectionRevision !== connection.revision) throw Object.assign(error('当前连接已改变,请重新读取后测试'), { status: 409 })
|
|
44
|
+
if (raw.kind !== 'preview' && !connection) throw error('请选择要直测的连接')
|
|
45
|
+
const selected = connection ?? { id: 'preview-only', name: '本地预览', enabled: true, baseURL: 'https://preview.invalid', protocol: raw.protocol ?? 'anthropic', apiPaths: {}, modelProtocols: {}, models: [], auth: { source: 'stored', mode: 'none', key: '', value: '' }, binding: { mode: 'auto', resolvedVersions: { responses: { id: 'builtin.codex', revision: 1 }, anthropic: { id: 'builtin.claude-code', revision: 1 } } }, modelBindings: {} }
|
|
46
|
+
let suppliedModel
|
|
47
|
+
try {
|
|
48
|
+
const supplied = typeof raw.request?.body === 'string' ? JSON.parse(raw.request.body) : raw.request?.body
|
|
49
|
+
suppliedModel = typeof supplied?.model === 'string' ? supplied.model.trim() : ''
|
|
50
|
+
} catch { suppliedModel = '' }
|
|
51
|
+
let model = typeof raw.model === 'string' && raw.model.trim() ? raw.model.trim() : suppliedModel || selected.models[0] || ''
|
|
52
|
+
if (!model && connection && raw.kind !== 'check') {
|
|
53
|
+
const catalog = await readModelCatalog(connection, { catalog: createUAModelCatalog(store.path), readAuth })
|
|
54
|
+
model = catalog?.models[0]?.id ?? ''
|
|
55
|
+
}
|
|
56
|
+
if (!model && raw.kind !== 'check') throw error('此连接暂无可测试模型,请先获取模型或明确输入模型 ID')
|
|
57
|
+
const protocol = raw.protocol ?? protocolForModel(selected, model)
|
|
58
|
+
const operation = raw.operation ?? (protocol === 'responses' ? 'responses' : protocol === 'anthropic' ? 'messages' : protocol === 'gemini' ? raw.stream ? 'streamGenerateContent' : 'generateContent' : 'chat/completions')
|
|
59
|
+
const template = raw.template === null ? null : raw.template ? validateTemplate(raw.template) : undefined
|
|
60
|
+
const plan = createTestPlan({ snapshot, connection: selected, kind: raw.kind, protocol, operation, model, stream: raw.stream === true, templateMode: raw.templateMode ?? 'binding', template, templateRef: raw.templateRef, apiPath: raw.apiPath })
|
|
61
|
+
const safeConnection = raw.apiPath ? { ...selected, apiPaths: { ...selected.apiPaths, [plan.protocol]: raw.apiPath } } : selected
|
|
62
|
+
const started = performance.now()
|
|
63
|
+
const testedAt = new Date().toISOString()
|
|
64
|
+
const timeout = AbortSignal.timeout(raw.kind === 'infer' ? 60000 : 15000)
|
|
65
|
+
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout
|
|
66
|
+
const secrets = [selected.auth?.key, selected.auth?.value].filter(Boolean)
|
|
67
|
+
if (raw.kind === 'check') {
|
|
68
|
+
const info = { kind: raw.kind, connectionId: selected.id, connectionName: selected.name, model: '', protocol: plan.protocol, testedAt,
|
|
69
|
+
request: { url: plan.url, method: 'GET' }, generation: snapshot.generation, resolution: plan.resolution }
|
|
70
|
+
try {
|
|
71
|
+
const result = await lookupUAModels(safeConnection, { readAuth, fetchImpl, modelFetchFor, signal: combined, allowCached: false })
|
|
72
|
+
return { ...info, status: 'passed', models: result.models, httpStatus: 200, durationMs: Math.round(performance.now() - started), message: '只读取模型列表,实际生成尚未验证' }
|
|
73
|
+
} catch (cause) {
|
|
74
|
+
return { ...info, status: 'failed', durationMs: Math.round(performance.now() - started), error: combined.aborted ? signal?.aborted ? '用户已取消连接检查' : '连接检查超时' : ['dns', 'connection-refused', 'connection-reset', 'timeout', 'tls', 'network', 'invalid-model-response'].includes(cause.code) ? cause.message : cause.status ? `模型列表返回 HTTP ${cause.status}` : '模型列表检查未完成', httpStatus: cause.code === 'upstream-http' ? cause.status : undefined }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const provided = raw.request?.body ?? createTestSample(protocol, model, raw.stream === true, operation)
|
|
78
|
+
if (typeof provided !== 'string' && (!provided || typeof provided !== 'object' || Array.isArray(provided))) throw error('测试正文必须是 JSON 对象或字符串')
|
|
79
|
+
const body = typeof provided === 'string' ? provided : JSON.stringify(provided)
|
|
80
|
+
if (Buffer.byteLength(body) > 192 * 1024) throw error('测试样例最多 192 KiB')
|
|
81
|
+
let parsed
|
|
82
|
+
try { parsed = JSON.parse(body) } catch { throw error('测试正文必须是有效的 JSON 对象') }
|
|
83
|
+
validateTestSample(parsed, plan)
|
|
84
|
+
let url = plan.url
|
|
85
|
+
if (protocol === 'gemini' && raw.stream) url += '?alt=sse'
|
|
86
|
+
const prepared = await prepareUpstream({ snapshot, connection: safeConnection, input: url,
|
|
87
|
+
init: { method: 'POST', headers: { 'content-type': 'application/json', ...(raw.request?.headers ?? {}) }, body, signal: combined },
|
|
88
|
+
context: { model, sessionID: 'ua-pro-test-session', requestId: 'ua-pro-test-request' }, readAuth,
|
|
89
|
+
template: plan.template, protocol, operation, model })
|
|
90
|
+
secrets.push(...prepared.credentialValues)
|
|
91
|
+
const outgoing = Object.fromEntries(new Headers(prepared.init.headers))
|
|
92
|
+
// Final credential values are removed even when supplied by a host callback.
|
|
93
|
+
for (const [name, value] of Object.entries(outgoing)) if (sensitive.test(name)) secrets.push(value)
|
|
94
|
+
const report = { kind: raw.kind, status: 'passed', scope: raw.kind === 'preview' ? 'local-preview' : 'direct-connection', protocol,
|
|
95
|
+
connectionId: selected.id, connectionName: selected.name, model, operation, testedAt, resolution: plan.resolution,
|
|
96
|
+
generation: snapshot.generation, templateId: prepared.summary?.templateId ?? null, revision: prepared.summary?.revision ?? null,
|
|
97
|
+
callerIdentity: prepared.summary?.callerIdentity, sourceAvailability: prepared.summary?.sourceAvailability,
|
|
98
|
+
request: { url: String(prepared.input instanceof Request ? prepared.input.url : prepared.input), method: prepared.init.method ?? 'POST', headers: safeValue(outgoing, secrets), body: safeValue(prepared.init.body, secrets) },
|
|
99
|
+
changes: prepared.summary?.changes ?? [], durationMs: Math.round(performance.now() - started) }
|
|
100
|
+
if (raw.kind === 'preview') return report
|
|
101
|
+
try {
|
|
102
|
+
const response = await fetchImpl(prepared.input, prepared.init)
|
|
103
|
+
report.httpStatus = response.status
|
|
104
|
+
report.responseHeaders = safeValue(Object.fromEntries(response.headers), secrets)
|
|
105
|
+
report.firstChunkMs = null
|
|
106
|
+
const reader = response.body?.getReader()
|
|
107
|
+
const chunks = []
|
|
108
|
+
let size = 0
|
|
109
|
+
const cancel = () => { void reader?.cancel().catch(() => {}) }
|
|
110
|
+
combined.addEventListener('abort', cancel, { once: true })
|
|
111
|
+
try {
|
|
112
|
+
if (reader) for (;;) {
|
|
113
|
+
combined.throwIfAborted()
|
|
114
|
+
const { done, value } = await reader.read()
|
|
115
|
+
combined.throwIfAborted()
|
|
116
|
+
if (done) break
|
|
117
|
+
if (report.firstChunkMs === null) report.firstChunkMs = Math.round(performance.now() - started)
|
|
118
|
+
size += value.byteLength
|
|
119
|
+
if (size > 1024 * 1024) throw error('测试响应超过 1 MiB')
|
|
120
|
+
chunks.push(Buffer.from(value))
|
|
121
|
+
}
|
|
122
|
+
} finally { combined.removeEventListener('abort', cancel); cancel(); reader?.releaseLock() }
|
|
123
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
124
|
+
report.responseText = safeValue(text, secrets)
|
|
125
|
+
const type = response.headers.get('content-type') ?? ''
|
|
126
|
+
if (raw.stream) {
|
|
127
|
+
const data = text.split(/\r?\n/).filter(line => line.startsWith('data:')).map(line => line.slice(5).trim()).filter(value => value && value !== '[DONE]')
|
|
128
|
+
report.protocolValid = type.includes('text/event-stream') && data.length > 0 && data.every(value => { try { const event = JSON.parse(value); return !event.error && event.type !== 'error' && event.type !== 'response.failed' } catch { return false } })
|
|
129
|
+
const last = data.map(value => { try { return JSON.parse(value) } catch { return {} } }).reverse().find(value => value.usage || value.usageMetadata || value.response?.usage || value.message?.usage)
|
|
130
|
+
if (last) report.usage = last.usage ?? last.usageMetadata ?? last.response?.usage ?? last.message?.usage
|
|
131
|
+
} else {
|
|
132
|
+
try {
|
|
133
|
+
const data = JSON.parse(text)
|
|
134
|
+
const shape = operation === 'count_tokens' ? Number.isFinite(data.input_tokens) && data.input_tokens >= 0
|
|
135
|
+
: operation === 'countTokens' ? Number.isFinite(data.totalTokens) && data.totalTokens >= 0
|
|
136
|
+
: protocol === 'anthropic' ? data.type === 'message' && Array.isArray(data.content)
|
|
137
|
+
: protocol === 'responses' ? Array.isArray(data.output) && data.status !== 'failed'
|
|
138
|
+
: protocol === 'chat-completions' ? Array.isArray(data.choices) : Array.isArray(data.candidates)
|
|
139
|
+
report.protocolValid = Boolean(shape && !data.error && data.success !== false)
|
|
140
|
+
report.usage = data.usage ?? data.usageMetadata
|
|
141
|
+
} catch { report.protocolValid = false }
|
|
142
|
+
}
|
|
143
|
+
report.status = response.ok && report.protocolValid ? 'passed' : 'failed'
|
|
144
|
+
if (!response.ok) { report.error = `上游返回 HTTP ${response.status}`; report.diagnostic = responseDiagnostic(response.status, text, plan) }
|
|
145
|
+
else if (!report.protocolValid) report.error = '响应不是预期的 JSON 或 SSE 格式'
|
|
146
|
+
} catch {
|
|
147
|
+
report.status = 'failed'
|
|
148
|
+
report.error = combined.aborted ? signal?.aborted ? '用户已取消测试;若请求已到上游,仍可能计费,不自动重试' : '本次测试超时,不自动重试' : '请求或响应读取失败,不自动重试'
|
|
149
|
+
}
|
|
150
|
+
report.durationMs = Math.round(performance.now() - started)
|
|
151
|
+
return safeValue(report, secrets)
|
|
152
|
+
}
|
|
153
|
+
}
|