mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-09-09 19:08:12 +08:00
204 lines
7.3 KiB
JavaScript
204 lines
7.3 KiB
JavaScript
/**
|
||
* OpenCode 受管运行时与模型配置适配器。
|
||
*
|
||
* 本模块只负责纯配置转换,不读取 ClawPanel 密钥,也不启动外部进程。
|
||
* Web/Tauri 后端负责把密钥写入独立 0600 文件,并对写入结果做回读核对。
|
||
*/
|
||
|
||
export const OPENCODE_PACKAGE_NAME = 'opencode-ai'
|
||
export const OPENCODE_PACKAGE_VERSION = '1.18.21'
|
||
export const OPENCODE_DEFAULT_PORT = 4096
|
||
export const OPENCODE_CONFIG_SCHEMA = 'https://opencode.ai/config.json'
|
||
|
||
/** 只接受 npm 正式版本格式,避免把仓库返回值直接拼进安装参数。 */
|
||
export function normalizeOpenCodeVersion(value) {
|
||
const version = String(value || '').trim()
|
||
return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version) ? version : ''
|
||
}
|
||
|
||
function parseVersion(value) {
|
||
const version = normalizeOpenCodeVersion(value)
|
||
if (!version) return null
|
||
const coreAndPre = version.split('+')[0]
|
||
const separator = coreAndPre.indexOf('-')
|
||
const core = separator >= 0 ? coreAndPre.slice(0, separator) : coreAndPre
|
||
const prerelease = separator >= 0 ? coreAndPre.slice(separator + 1) : ''
|
||
return {
|
||
core: core.split('.').map(Number),
|
||
prerelease: prerelease ? prerelease.split('.') : [],
|
||
}
|
||
}
|
||
|
||
/** 足够覆盖 OpenCode npm 正式版的 SemVer 比较。 */
|
||
export function compareOpenCodeVersions(left, right) {
|
||
const a = parseVersion(left)
|
||
const b = parseVersion(right)
|
||
if (!a || !b) return 0
|
||
for (let index = 0; index < 3; index += 1) {
|
||
if (a.core[index] !== b.core[index]) return a.core[index] > b.core[index] ? 1 : -1
|
||
}
|
||
if (!a.prerelease.length && !b.prerelease.length) return 0
|
||
if (!a.prerelease.length) return 1
|
||
if (!b.prerelease.length) return -1
|
||
const length = Math.max(a.prerelease.length, b.prerelease.length)
|
||
for (let index = 0; index < length; index += 1) {
|
||
const av = a.prerelease[index]
|
||
const bv = b.prerelease[index]
|
||
if (av === undefined) return -1
|
||
if (bv === undefined) return 1
|
||
if (av === bv) continue
|
||
const an = /^\d+$/.test(av) ? Number(av) : null
|
||
const bn = /^\d+$/.test(bv) ? Number(bv) : null
|
||
if (an !== null && bn !== null) return an > bn ? 1 : -1
|
||
if (an !== null) return -1
|
||
if (bn !== null) return 1
|
||
return av > bv ? 1 : -1
|
||
}
|
||
return 0
|
||
}
|
||
|
||
export function buildOpenCodeUpdateInfo(currentVersion, latestVersion, extra = {}) {
|
||
const current = normalizeOpenCodeVersion(currentVersion)
|
||
const latest = normalizeOpenCodeVersion(latestVersion)
|
||
return {
|
||
currentVersion: current,
|
||
latestVersion: latest,
|
||
updateAvailable: Boolean(current && latest && compareOpenCodeVersions(latest, current) > 0),
|
||
...extra,
|
||
}
|
||
}
|
||
|
||
const OPENCODE_PROVIDER_PACKAGES = {
|
||
'openai-completions': '@ai-sdk/openai-compatible',
|
||
ollama: '@ai-sdk/openai-compatible',
|
||
'openai-responses': '@ai-sdk/openai',
|
||
'anthropic-messages': '@ai-sdk/anthropic',
|
||
}
|
||
|
||
function asObject(value) {
|
||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
||
}
|
||
|
||
function positiveInteger(value) {
|
||
const number = Number(value)
|
||
return Number.isInteger(number) && number > 0 ? number : undefined
|
||
}
|
||
|
||
export function normalizeOpenCodePort(value = OPENCODE_DEFAULT_PORT) {
|
||
const port = Number(value)
|
||
if (!Number.isInteger(port) || port < 1024 || port > 65535) {
|
||
throw new Error('OpenCode 端口必须是 1024-65535 的整数')
|
||
}
|
||
return port
|
||
}
|
||
|
||
export function openCodePackageForApiType(apiType) {
|
||
return OPENCODE_PROVIDER_PACKAGES[String(apiType || '').trim()] || null
|
||
}
|
||
|
||
export function openCodeSyncSupported(channel) {
|
||
return !channel?.apiKeyRef && Boolean(openCodePackageForApiType(channel?.apiType))
|
||
}
|
||
|
||
/** 生成稳定且不会覆盖内置 Provider 的 ID。 */
|
||
export function openCodeProviderKey(channel) {
|
||
const source = String(channel?.id || channel?.presetKey || channel?.name || 'model')
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-+|-+$/g, '')
|
||
const suffix = (source || 'model').slice(0, 52).replace(/-+$/g, '') || 'model'
|
||
return `clawpanel-${suffix}`
|
||
}
|
||
|
||
export function openCodeCredentialFileName(provider) {
|
||
const normalized = String(provider || '').trim()
|
||
if (!/^clawpanel-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(normalized)) {
|
||
throw new Error(`OpenCode Provider ID 无效: ${normalized || '-'}`)
|
||
}
|
||
return `${normalized}.key`
|
||
}
|
||
|
||
function normalizeCredentialPath(value) {
|
||
const path = String(value || '').trim().replace(/\\/g, '/')
|
||
if (!path || /[\r\n{}]/.test(path)) throw new Error('OpenCode 凭据文件路径无效')
|
||
return path
|
||
}
|
||
|
||
/** 把 ClawPanel 模型渠道转换为 OpenCode Provider。 */
|
||
export function buildOpenCodeProvider(channel, credentialPath, provider = openCodeProviderKey(channel)) {
|
||
const npm = openCodePackageForApiType(channel?.apiType)
|
||
if (!npm) throw new Error(`OpenCode 暂不支持该 API 类型: ${channel?.apiType || '-'}`)
|
||
const baseURL = String(channel?.baseUrl || '').trim().replace(/\/+$/, '')
|
||
if (!/^https?:\/\//.test(baseURL)) throw new Error('OpenCode Base URL 必须以 http:// 或 https:// 开头')
|
||
|
||
const models = {}
|
||
for (const raw of Array.isArray(channel?.models) ? channel.models : []) {
|
||
const model = typeof raw === 'string' ? { id: raw } : asObject(raw)
|
||
const id = String(model.id || '').trim()
|
||
if (!id || models[id]) continue
|
||
const next = {}
|
||
const name = String(model.name || '').trim()
|
||
if (name) next.name = name
|
||
const context = positiveInteger(model.contextWindow || model.contextTokens)
|
||
const output = positiveInteger(model.maxTokens)
|
||
if (context || output) next.limit = {
|
||
...(context ? { context } : {}),
|
||
...(output ? { output } : {}),
|
||
}
|
||
models[id] = next
|
||
}
|
||
if (!Object.keys(models).length) throw new Error('OpenCode 至少需要一个模型')
|
||
|
||
return {
|
||
npm,
|
||
name: String(channel?.name || provider).trim() || provider,
|
||
options: {
|
||
baseURL,
|
||
apiKey: `{file:${normalizeCredentialPath(credentialPath)}}`,
|
||
},
|
||
models,
|
||
}
|
||
}
|
||
|
||
/** 合并受管 Provider,保留用户手工配置和其他 Provider。 */
|
||
export function mergeOpenCodeProviderConfig(current, {
|
||
channel,
|
||
credentialPath,
|
||
setDefault = false,
|
||
} = {}) {
|
||
const providerId = openCodeProviderKey(channel)
|
||
const provider = buildOpenCodeProvider(channel, credentialPath, providerId)
|
||
const firstModel = Object.keys(provider.models)[0] || ''
|
||
const requestedDefault = String(channel?.defaultModel || '').trim()
|
||
const defaultModel = provider.models[requestedDefault] ? requestedDefault : firstModel
|
||
const existing = asObject(current)
|
||
const next = {
|
||
...existing,
|
||
$schema: existing.$schema || OPENCODE_CONFIG_SCHEMA,
|
||
autoupdate: false,
|
||
provider: {
|
||
...asObject(existing.provider),
|
||
[providerId]: provider,
|
||
},
|
||
}
|
||
if (setDefault && defaultModel) next.model = `${providerId}/${defaultModel}`
|
||
return { config: next, providerId, defaultModel, modelCount: Object.keys(provider.models).length }
|
||
}
|
||
|
||
/** 返回不包含凭据内容的配置摘要。 */
|
||
export function readOpenCodeSummary(config) {
|
||
const value = asObject(config)
|
||
const providers = asObject(value.provider)
|
||
const configuredProviders = Object.keys(providers)
|
||
const managedProviders = configuredProviders.filter(id => id.startsWith('clawpanel-'))
|
||
const modelCount = configuredProviders.reduce((sum, id) => {
|
||
return sum + Object.keys(asObject(asObject(providers[id]).models)).length
|
||
}, 0)
|
||
return {
|
||
configuredProviders,
|
||
managedProviders,
|
||
defaultModel: String(value.model || ''),
|
||
modelCount,
|
||
}
|
||
}
|