Files
clawpanel/scripts/opencode.js
2026-08-24 18:28:50 +08:00

204 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
}
}