Skip to content

Commit 641bc1b

Browse files
committed
feat(zcode): add full ZCode install-target support across all surfaces
1 parent 4bb8556 commit 641bc1b

9 files changed

Lines changed: 560 additions & 74 deletions

File tree

pnpm-lock.yaml

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/core/endpoint-installer.js

Lines changed: 182 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ import { ensureDir, readJson as sharedReadJson } from './shared-helpers.js'
5454
const DIRECT_INSTALL_UNSUPPORTED_PROVIDERS = new Set(['replicate'])
5555
// 📖 Install Endpoints only lists tools whose persisted config shape is actually supported here.
5656
// 📖 Launch-only tools stay out: the Web dashboard configures endpoints, it never starts CLIs.
57-
const INSTALL_TARGET_MODES = ['opencode', 'opencode-desktop', 'opencode-web', 'openclaw', 'crush', 'goose', 'pi', 'aider', 'qwen', 'openhands', 'amp', 'forgecode', 'fcm_router']
57+
const INSTALL_TARGET_MODES = ['opencode', 'opencode-desktop', 'opencode-web', 'openclaw', 'crush', 'goose', 'pi', 'aider', 'qwen', 'openhands', 'amp', 'forgecode', 'fcm_router', 'zcode']
5858

5959
function getDefaultPaths() {
6060
const home = homedir()
@@ -70,6 +70,8 @@ function getDefaultPaths() {
7070
ampConfigPath: join(home, '.config', 'amp', 'settings.json'),
7171
qwenConfigPath: join(home, '.qwen', 'settings.json'),
7272
forgeCodeConfigPath: join(home, '.forge', '.forge.toml'),
73+
zcodeConfigPath: join(home, '.zcode', 'v2', 'config.json'),
74+
zcodeModelCachePath: join(home, '.zcode', 'v2', 'bots-model-cache.v2.json'),
7375
}
7476
}
7577

@@ -625,6 +627,183 @@ function installIntoFcmRouter(providerKey, models, apiKey) {
625627
return { path: `FCM Router (${baseUrl})`, backupPath: null, providerId: providerKey, modelCount: models.length }
626628
}
627629

630+
// 📖 installIntoZCode: Writes provider + models into ZCode's config.json and updates
631+
// 📖 bots-model-cache.v2.json so the models appear immediately in ZCode's model picker.
632+
// 📖 Uses deterministic provider ID (fcm-{providerKey}) so re-running replaces/merges
633+
// 📖 without duplicating the provider entry.
634+
// 📖 When scope === 'selected' — merges models with existing ones.
635+
// 📖 When scope === 'all' — replaces all models (full sync).
636+
function installIntoZCode(providerKey, models, apiKey, paths, scope) {
637+
const configPath = paths.zcodeConfigPath
638+
const cachePath = paths.zcodeModelCachePath
639+
const providerId = `fcm-${providerKey}`
640+
const baseUrl = resolveProviderBaseUrl(providerKey)
641+
const providerLabel = getManagedProviderLabel(providerKey)
642+
643+
if (!baseUrl) {
644+
throw new Error(`Cannot resolve base URL for ${getProviderLabel(providerKey)}`)
645+
}
646+
647+
function buildModelEntry(model) {
648+
const ctx = parseContextWindow(model.ctx)
649+
const entry = {
650+
id: model.modelId,
651+
name: model.label || model.modelId,
652+
kinds: ['openai-compatible'],
653+
defaultKind: 'openai-compatible',
654+
modalities: { input: ['text'], output: ['text'] },
655+
contextWindow: ctx,
656+
}
657+
if (ctx > 8192) {
658+
entry.maxOutputTokens = getDefaultMaxTokens(ctx)
659+
}
660+
return entry
661+
}
662+
663+
function buildConfigModelEntry(model) {
664+
const ctx = parseContextWindow(model.ctx)
665+
const entry = {
666+
limit: { context: ctx },
667+
modalities: { input: ['text'], output: ['text'] },
668+
}
669+
if (ctx > 8192) {
670+
entry.limit.output = getDefaultMaxTokens(ctx)
671+
}
672+
return entry
673+
}
674+
675+
const newModelIds = new Set(models.map((m) => m.modelId))
676+
let configModified = false
677+
let cacheModified = false
678+
679+
// ── 1. Write to config.json ───────────────────────────────────────────────
680+
const config = readJson(configPath, { $schema: 'https://opencode.ai/config.json' })
681+
if (!config.provider || typeof config.provider !== 'object') config.provider = {}
682+
683+
const existingProvider = config.provider[providerId]
684+
685+
if (scope === 'selected' && existingProvider) {
686+
// 📖 Merge mode: add/update selected models, keep existing ones
687+
for (const model of models) {
688+
existingProvider.models[model.modelId] = buildConfigModelEntry(model)
689+
}
690+
configModified = true
691+
} else if (scope === 'selected' && !existingProvider) {
692+
// 📖 No existing provider, but scope is selected — create with only selected models
693+
config.provider[providerId] = {
694+
name: providerLabel,
695+
kind: 'openai-compatible',
696+
options: { apiKey, baseURL: baseUrl, apiKeyRequired: true },
697+
enabled: true,
698+
source: 'custom',
699+
models: Object.fromEntries(models.map((m) => [m.modelId, buildConfigModelEntry(m)])),
700+
}
701+
configModified = true
702+
} else {
703+
// 📖 scope === 'all' — replace all models (full sync), skip if identical
704+
const existingModelIds = existingProvider ? Object.keys(existingProvider.models || {}) : []
705+
const modelsUnchanged = existingProvider
706+
&& existingModelIds.length === models.length
707+
&& models.every((m) => existingModelIds.includes(m.modelId))
708+
709+
if (!modelsUnchanged) {
710+
config.provider[providerId] = {
711+
name: providerLabel,
712+
kind: 'openai-compatible',
713+
options: { apiKey, baseURL: baseUrl, apiKeyRequired: true },
714+
enabled: true,
715+
source: 'custom',
716+
models: Object.fromEntries(models.map((m) => [m.modelId, buildConfigModelEntry(m)])),
717+
}
718+
configModified = true
719+
}
720+
}
721+
722+
const configBackupPath = configModified ? writeJson(configPath, config) : null
723+
724+
// ── 2. Write to bots-model-cache.v2.json ──────────────────────────────────
725+
const cache = readJson(cachePath, { version: 2, updatedAt: Date.now(), providers: [] })
726+
if (!Array.isArray(cache.providers)) cache.providers = []
727+
728+
const existingCacheIdx = cache.providers.findIndex((p) => p?.id === providerId)
729+
730+
if (scope === 'selected') {
731+
// 📖 Merge mode: add/update selected models in cache, keep existing ones
732+
if (existingCacheIdx >= 0) {
733+
const existingModels = cache.providers[existingCacheIdx].models || []
734+
for (const model of models) {
735+
const modelIdx = existingModels.findIndex((m) => m.id === model.modelId)
736+
if (modelIdx >= 0) {
737+
existingModels[modelIdx] = buildModelEntry(model)
738+
} else {
739+
existingModels.push(buildModelEntry(model))
740+
}
741+
}
742+
cache.providers[existingCacheIdx].updatedAt = Date.now()
743+
cacheModified = true
744+
} else {
745+
cache.providers.push({
746+
id: providerId,
747+
name: providerLabel,
748+
enabled: true,
749+
endpoints: { baseURL: baseUrl, paths: { 'openai-compatible': '/chat/completions' } },
750+
apiFormat: 'openai-chat-completions',
751+
source: 'custom',
752+
apiKeyRequired: true,
753+
apiKey: '__zcode_cached_api_key_present__',
754+
defaultKind: 'openai-compatible',
755+
models: models.map(buildModelEntry),
756+
createdAt: Date.now(),
757+
updatedAt: Date.now(),
758+
})
759+
cacheModified = true
760+
}
761+
} else {
762+
// 📖 scope === 'all' — replace all models, skip if identical
763+
const cachedModelIds = existingCacheIdx >= 0
764+
? (cache.providers[existingCacheIdx].models || []).map((m) => m.id)
765+
: []
766+
const cacheUnchanged = existingCacheIdx >= 0
767+
&& cachedModelIds.length === models.length
768+
&& models.every((m) => cachedModelIds.includes(m.modelId))
769+
770+
if (!cacheUnchanged) {
771+
if (existingCacheIdx >= 0) {
772+
cache.providers[existingCacheIdx].models = models.map(buildModelEntry)
773+
cache.providers[existingCacheIdx].updatedAt = Date.now()
774+
} else {
775+
cache.providers.push({
776+
id: providerId,
777+
name: providerLabel,
778+
enabled: true,
779+
endpoints: { baseURL: baseUrl, paths: { 'openai-compatible': '/chat/completions' } },
780+
apiFormat: 'openai-chat-completions',
781+
source: 'custom',
782+
apiKeyRequired: true,
783+
apiKey: '__zcode_cached_api_key_present__',
784+
defaultKind: 'openai-compatible',
785+
models: models.map(buildModelEntry),
786+
createdAt: Date.now(),
787+
updatedAt: Date.now(),
788+
})
789+
}
790+
cache.updatedAt = Date.now()
791+
cacheModified = true
792+
}
793+
}
794+
795+
const cacheBackupPath = cacheModified ? writeJson(cachePath, cache) : null
796+
797+
return {
798+
path: configPath,
799+
backupPath: configBackupPath,
800+
providerId,
801+
modelCount: models.length,
802+
extraPath: cachePath,
803+
extraBackupPath: cacheBackupPath,
804+
}
805+
}
806+
628807
export function installProviderEndpoints(config, providerKey, toolMode, options = {}) {
629808
const canonicalToolMode = canonicalizeToolMode(toolMode)
630809
const support = getDirectInstallSupport(providerKey)
@@ -664,6 +843,8 @@ export function installProviderEndpoints(config, providerKey, toolMode, options
664843
installResult = installIntoFcmRouter(providerKey, models, apiKey)
665844
} else if (canonicalToolMode === 'forgecode') {
666845
installResult = installIntoForgeCode(providerKey, models, apiKey, paths)
846+
} else if (canonicalToolMode === 'zcode') {
847+
installResult = installIntoZCode(providerKey, models, apiKey, paths, scope)
667848
} else {
668849
throw new Error(`Unsupported install target: ${toolMode}`)
669850
}

0 commit comments

Comments
 (0)