@botconnector/bccli 0.5.3 → 0.5.4

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/branding.mjs CHANGED
@@ -5,7 +5,6 @@ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, r
5
5
  import { basename, dirname, join } from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
7
7
  import { homedir } from 'node:os'
8
- import { BCCLI_MODEL_COMMAND } from './model-command.mjs'
9
8
  import { BCCLI_PROVIDER_COMMAND } from './provider-command.mjs'
10
9
  import { BCCLI_PROVIDER_DIALOG } from './provider-dialog.mjs'
11
10
 
@@ -33,7 +32,7 @@ const SOURCE_HASHES = Object.freeze({
33
32
  'chunks/chunk-B3ZFUGTC.js': 'f3f57aafbaeab56ca58f5d3173751d30e5fef815aeaf8dc9b46e6ea9e85eb528',
34
33
  'chunks/chunk-GGVWG4SI.js': '945c7f4e9b3697003c165b7bc5d007c9b87140d1fad7be084d7b253d8d2b476c',
35
34
  })
36
- const BRAND_REVISION = 'bccli-20261031-win-bootstrap-session-v15'
35
+ const BRAND_REVISION = 'bccli-20261031-opentui-provider-fallback-v17'
37
36
  // Deliberately not Qwen's blue→purple→rose gradient. BotConnector-facing
38
37
  // coding CLI uses a distinct teal→green→amber palette in dark/light themes.
39
38
  export const BCCLI_COLORS = Object.freeze(['#00AFA0', '#22C55E', '#EAB308'])
@@ -66,9 +65,11 @@ function replaceOnce(text, oldValue, newValue, label) {
66
65
 
67
66
 
68
67
  // Embedded slash commands are part of the upstream renderer bundle. BCCLI's
69
- // Gateway owns authentication, model eligibility and release management, so
70
- // those commands must not open arbitrary upstream provider settings or invoke
71
- // the upstream npm / standalone auto-updater.
68
+ // Gateway owns authentication and release management, so those commands must
69
+ // not open arbitrary upstream provider settings or invoke the upstream npm /
70
+ // standalone auto-updater. /model intentionally stays upstream-owned: the
71
+ // interactive picker is fed by the gateway catalog that BCCLI writes into the
72
+ // session's modelProviders (see dialog-models.mjs).
72
73
  export function guardUpstreamProviderCommands(text) {
73
74
  const replacements = [
74
75
  {
@@ -76,11 +77,6 @@ export function guardUpstreamProviderCommands(text) {
76
77
  detect: 'dialog:"auth"',
77
78
  code: BCCLI_PROVIDER_COMMAND,
78
79
  },
79
- {
80
- command: 'model',
81
- detect: 'dialog:"model"',
82
- code: BCCLI_MODEL_COMMAND,
83
- },
84
80
  {
85
81
  command: 'settings',
86
82
  detect: 'dialog:"settings"',
package/cli.mjs CHANGED
@@ -1,13 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from 'node:fs'
3
- import { DEFAULT_MODEL, assertCandidateRuntimeSupported, discoverModels, launchQwen, parseArgs, resolveQwenEntrypoint, resolveTrustedMcpConfig, selectModel } from './core.mjs'
3
+ import { DEFAULT_MODEL, assertCandidateRuntimeSupported, discoverModels, launchQwen, parseArgs, resolveQwenEntrypoint, resolveTrustedMcpConfig, selectModel, sessionHome } from './core.mjs'
4
4
  import { prepareBrandedQwen } from './branding.mjs'
5
+ import { readDialogSelectedModel } from './dialog-models.mjs'
5
6
  import { botConnectorCredentialStatus } from './credentials.mjs'
6
7
  import { resolveProvider, selectedProvider, providerDefinition, isConfigured } from './providers.mjs'
7
8
  import { runProviderCommand } from './provider-cli.mjs'
8
9
  import { startupMenu, autoSelectStartup, PROVIDER_SWITCH_EXIT } from './startup-menu.mjs'
9
10
  import { legacyCompatibilityMode, runLegacyCli } from './legacy-compat.mjs'
10
11
 
12
+ // An unreadable or unsafe settings file must not block startup; the picker
13
+ // simply falls back to the legacy active-model record.
14
+ function dialogModel(providerId) {
15
+ try { return readDialogSelectedModel(sessionHome(providerId, process.env)) } catch { return undefined }
16
+ }
17
+
11
18
  function help() {
12
19
  console.log([
13
20
  'BCCLI | BotConnector Coding Agent',
@@ -84,18 +91,22 @@ async function main() {
84
91
  if (!args.prompt && !process.stdin.isTTY) throw Error('Interactive mode requires a TTY; use -p "prompt".')
85
92
  const mcpConfig = resolveTrustedMcpConfig(args.mcpConfig)
86
93
  let chosen
94
+ let catalog
87
95
  const showPicker = args.command === 'chat' && !args.prompt && !args.model
88
96
  if (showPicker) {
89
97
  chosen = forceChoose ? undefined : await autoSelectStartup({
90
98
  ...(args.provider ? { providerId: args.provider } : {}),
99
+ dialogModel,
91
100
  })
92
101
  if (!chosen) chosen = await startupMenu({
93
102
  ...(args.provider ? { currentProvider: () => args.provider } : {}),
103
+ dialogModel,
94
104
  })
95
105
  } else {
96
106
  const providerId = args.provider ?? selectedProvider()
97
107
  const profile = resolveProvider(providerId)
98
108
  const models = await discoverModels({ key: profile.key, endpoint: profile.endpoint })
109
+ catalog = models
99
110
  const requested = args.model ??
100
111
  (providerId === 'botconnector' ? (process.env.BOTCONNECTOR_MODEL_ID ?? DEFAULT_MODEL) : undefined)
101
112
  if (!requested) throw Error('Use --model MODEL_ID for ' + providerId + '.')
@@ -109,15 +120,16 @@ async function main() {
109
120
  const code = await launchQwen({
110
121
  key: chosen.key, model: chosen.model, prompt: args.prompt,
111
122
  mcpConfig, entrypoint, upstream: chosen.endpoint,
112
- providerId: chosen.providerId, continueSession, resumeSession,
123
+ providerId: chosen.providerId, continueSession, resumeSession, catalog,
113
124
  })
114
125
  if (code !== PROVIDER_SWITCH_EXIT) return code
115
126
  if (args.prompt || !process.stdin.isTTY) throw Error('Provider menu requires an interactive terminal.')
116
127
  // Older cached engine fallback only. Current /provider switches the live
117
128
  // parent proxy and model Config without exiting or exposing API keys.
118
129
  resumeSession = undefined // Native picker is used only on the first launch.
130
+ catalog = undefined // The next provider has its own eligible model list.
119
131
  const previousProvider = chosen.providerId
120
- chosen = await startupMenu()
132
+ chosen = await startupMenu({ dialogModel })
121
133
  continueSession = previousProvider === chosen.providerId
122
134
  }
123
135
  }
package/core.mjs CHANGED
@@ -7,12 +7,27 @@ import { dirname, join, isAbsolute } from 'node:path'
7
7
  import { fileURLToPath } from 'node:url'
8
8
  import { startGatewayProxy } from './proxy.mjs'
9
9
  import { GATEWAY } from './gateway.mjs'
10
- import { listProviderDefinitions } from './providers.mjs'
10
+ import { listProviderDefinitions, providerRoot } from './providers.mjs'
11
11
  import { ensureIsolatedSessionSettings } from './session-settings.mjs'
12
+ import { syncDialogModelCatalog } from './dialog-models.mjs'
12
13
 
13
14
  export { GATEWAY } from './gateway.mjs'
14
15
  export const DEFAULT_MODEL = 'ling-3.0-flash'
15
16
 
17
+ // One engine home per provider keeps /model persistence provider-scoped.
18
+ // BCCLI_PREVIEW_HOME (already the owner config/key root) also relocates the
19
+ // engine home so isolated runs and tests never write to the real user profile.
20
+ export function sessionHome(providerId, env = process.env) {
21
+ const override = typeof env.BCCLI_PREVIEW_HOME === 'string' ? env.BCCLI_PREVIEW_HOME.trim() : ''
22
+ if (override) {
23
+ if (!isAbsolute(override)) throw Error('BCCLI_PREVIEW_HOME must be absolute.')
24
+ return providerId === 'botconnector' ? override : join(override, 'sessions-' + providerId)
25
+ }
26
+ return providerId === 'botconnector'
27
+ ? join(homedir(), '.bccli-preview')
28
+ : join(homedir(), '.bccli-preview', 'sessions-' + providerId)
29
+ }
30
+
16
31
  // Qwen Code 0.26.3 Windows tool-loop exits cleanly with Node 24.22.1
17
32
  // (GitHub Actions), but repeatedly crashes on Windows Node 24.18.0
18
33
  // with libuv UV_HANDLE_CLOSING after delivering the tool result.
@@ -210,7 +225,7 @@ export function childEnvironment(base, key, model, { endpoint = GATEWAY, home, p
210
225
  env.OPENAI_BASE_URL = endpoint
211
226
  env.OPENAI_MODEL = model
212
227
  env.QWEN_MODEL = model
213
- env.QWEN_HOME = home ?? (providerId === 'botconnector' ? join(homedir(), '.bccli-preview') : join(homedir(), '.bccli-preview', 'sessions-' + providerId))
228
+ env.QWEN_HOME = home ?? sessionHome(providerId, env)
214
229
  env.BCCLI_ACTIVE_PROVIDER = providerId
215
230
  env.BCCLI_WEB_SEARCH_AVAILABLE = searchAvailable ? '1' : '0'
216
231
  env.CLI_TITLE = 'BCCLI - ' + process.cwd().split(/[\\/]/).filter(Boolean).at(-1)
@@ -220,8 +235,9 @@ export function childEnvironment(base, key, model, { endpoint = GATEWAY, home, p
220
235
 
221
236
  // Explicit MCP servers run local commands on startup, so they must originate
222
237
  // from a config file the user deliberately placed outside any cloned project.
223
- // Full engine auto-discovery is subject to upstream workspace trust.
224
- export function resolveTrustedMcpConfig(input, { home = join(homedir(), '.bccli-preview') } = {}) {
238
+ // Full engine auto-discovery is subject to upstream workspace trust. The owner
239
+ // config root follows BCCLI_PREVIEW_HOME so an isolated home is fully isolated.
240
+ export function resolveTrustedMcpConfig(input, { home = providerRoot() } = {}) {
225
241
  if (!input) return undefined
226
242
  if (!isAbsolute(input)) throw Error('MCP config must use an absolute path.')
227
243
  let realHome, file, stats
@@ -260,7 +276,7 @@ export function resolveQwenEntrypoint() {
260
276
  return join(dirname(require.resolve('@qwen-code/qwen-code/package.json')), 'cli-entry.js')
261
277
  }
262
278
 
263
- export async function launchQwen({ key, model, prompt, mcpConfig, continueSession = false, resumeSession, upstream = GATEWAY, providerId = 'botconnector', env = process.env, entrypoint = resolveQwenEntrypoint(), spawnImpl = spawn, proxyFactory = startGatewayProxy }) {
279
+ export async function launchQwen({ key, model, prompt, mcpConfig, continueSession = false, resumeSession, upstream = GATEWAY, providerId = 'botconnector', env = process.env, entrypoint = resolveQwenEntrypoint(), spawnImpl = spawn, proxyFactory = startGatewayProxy, catalog }) {
264
280
  // The real API key never enters Qwen, its tools, or any MCP child process.
265
281
  // Only this launcher retains it while the ephemeral proxy is running.
266
282
  const searchApiKey = env.BRAVE_SEARCH_API_KEY
@@ -269,6 +285,17 @@ export async function launchQwen({ key, model, prompt, mcpConfig, continueSessio
269
285
  const args = [entrypoint, ...qwenArguments(model, prompt, proxy.baseUrl, { mcpConfig, continueSession, resumeSession, projectDir: process.cwd() })]
270
286
  const childEnv = childEnvironment(env, proxy.token, model, { endpoint: proxy.baseUrl, providerId, searchAvailable: Boolean(searchApiKey?.trim()) })
271
287
  ensureIsolatedSessionSettings(childEnv.QWEN_HOME)
288
+ // The upstream /model picker needs the catalog in modelProviders; without
289
+ // it the dialog only knows the single OPENAI_MODEL route. Never block a
290
+ // launch on catalog failure: chat still works with the startup model.
291
+ if (proxyFactory === startGatewayProxy) {
292
+ try {
293
+ const dialogCatalog = catalog ?? await discoverModels({ key, endpoint: upstream })
294
+ syncDialogModelCatalog(childEnv.QWEN_HOME, { models: dialogCatalog, baseUrl: proxy.baseUrl })
295
+ } catch (error) {
296
+ if (env.BCCLI_DEBUG) console.error('BCCLI: model picker catalog unavailable: ' + (error instanceof Error ? error.message : 'unknown'))
297
+ }
298
+ }
272
299
  const child = spawnImpl(process.execPath, args, {
273
300
  cwd: process.cwd(), env: childEnv,
274
301
  stdio: 'inherit', windowsHide: false, shell: false,
@@ -0,0 +1,114 @@
1
+ // The upstream /model picker lists models from settings.modelProviders. BCCLI's
2
+ // engine can only reach the parent-owned loopback proxy, so publish the live
3
+ // gateway catalog into the per-provider QWEN_HOME as one OpenAI-compatible
4
+ // provider. The picker itself stays upstream-owned (arrow keys, search,
5
+ // --global/--project and one-off prompts); BCCLI only supplies the eligible
6
+ // model set and reads back the persisted selection.
7
+ import { lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
8
+ import { randomBytes } from 'node:crypto'
9
+ import { join } from 'node:path'
10
+
11
+ const MODEL_ID = /^[a-zA-Z0-9][a-zA-Z0-9._:/+-]{0,255}$/
12
+ const LOOPBACK_ENDPOINT = /^http:\/\/127\.0\.0\.1:[0-9]{1,5}\/v1$/
13
+ const MAX_SETTINGS_BYTES = 1024 * 1024
14
+
15
+ export function buildDialogProviders(models, baseUrl) {
16
+ if (typeof baseUrl !== 'string' || !LOOPBACK_ENDPOINT.test(baseUrl)) {
17
+ throw Error('Model picker requires the local loopback provider endpoint.')
18
+ }
19
+ if (!Array.isArray(models) || models.length === 0) {
20
+ throw Error('Model picker requires a non-empty provider catalog.')
21
+ }
22
+ return models.map((item) => {
23
+ const id = item?.id
24
+ if (typeof id !== 'string' || !MODEL_ID.test(id)) {
25
+ throw Error('Invalid provider model id for the model picker.')
26
+ }
27
+ const name = typeof item?.name === 'string' && item.name.trim() ? item.name.trim().slice(0, 90) : id
28
+ return { id, name, baseUrl, envKey: 'OPENAI_API_KEY' }
29
+ })
30
+ }
31
+
32
+ // Keep unrelated settings and other configured providers byte-for-byte; only
33
+ // the OpenAI-compatible entry is owned by the live loopback proxy.
34
+ export function mergeDialogProviders(settings, providers) {
35
+ const base = settings && typeof settings === 'object' && !Array.isArray(settings) ? settings : {}
36
+ const existing = base.modelProviders
37
+ const modelProviders = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {}
38
+ return { ...base, modelProviders: { ...modelProviders, openai: providers } }
39
+ }
40
+
41
+ function readSettings(home) {
42
+ const info = lstatSync(home)
43
+ if (!info.isDirectory() || info.isSymbolicLink()) {
44
+ throw Error('BCCLI session directory must not be a symlink.')
45
+ }
46
+ const file = join(home, 'settings.json')
47
+ let stats
48
+ try {
49
+ stats = lstatSync(file)
50
+ } catch {
51
+ return {}
52
+ }
53
+ if (!stats.isFile() || stats.isSymbolicLink()) {
54
+ throw Error('BCCLI session settings must be a regular file.')
55
+ }
56
+ if (stats.size > MAX_SETTINGS_BYTES) throw Error('BCCLI session settings are too large.')
57
+ const text = readFileSync(file, 'utf8').trim()
58
+ if (!text) return {}
59
+ const parsed = JSON.parse(text)
60
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
61
+ throw Error('BCCLI session settings must contain a JSON object.')
62
+ }
63
+ return parsed
64
+ }
65
+
66
+ function writeSettings(home, settings) {
67
+ const file = join(home, 'settings.json')
68
+ const tmp = file + '.' + randomBytes(8).toString('hex') + '.tmp'
69
+ try {
70
+ writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n', { flag: 'wx', encoding: 'utf8', mode: 0o600 })
71
+ renameSync(tmp, file)
72
+ } finally {
73
+ rmSync(tmp, { force: true })
74
+ }
75
+ }
76
+
77
+ // A picker selection persists model.baseUrl alongside the id. The proxy port is
78
+ // ephemeral, so clear only an endpoint this module previously injected once the
79
+ // loopback port changes; unrelated endpoints are never rewritten.
80
+ function clearStalePickerEndpoint(settings, baseUrl) {
81
+ const injected = settings.modelProviders?.openai
82
+ if (!Array.isArray(injected)) return settings
83
+ const previous = new Set(injected
84
+ .map((entry) => entry?.baseUrl)
85
+ .filter((url) => typeof url === 'string' && LOOPBACK_ENDPOINT.test(url)))
86
+ const model = settings.model
87
+ if (!model || typeof model !== 'object' || Array.isArray(model)) return settings
88
+ if (typeof model.baseUrl !== 'string' || !previous.has(model.baseUrl) || model.baseUrl === baseUrl) return settings
89
+ return { ...settings, model: { ...model, baseUrl: '' } }
90
+ }
91
+
92
+ // Returns true only when the catalog actually changed, so a normal relaunch
93
+ // does not touch the user's settings file.
94
+ export function syncDialogModelCatalog(home, { models, baseUrl }) {
95
+ if (typeof home !== 'string' || !home.trim()) throw Error('BCCLI session home missing.')
96
+ const providers = buildDialogProviders(models, baseUrl)
97
+ mkdirSync(home, { recursive: true, mode: 0o700 })
98
+ const current = clearStalePickerEndpoint(readSettings(home), baseUrl)
99
+ const next = mergeDialogProviders(current, providers)
100
+ if (JSON.stringify(next) === JSON.stringify(current)) return false
101
+ writeSettings(home, next)
102
+ return true
103
+ }
104
+
105
+ // The upstream picker persists the chosen id to settings.model.name. Read it
106
+ // back so the next launch (and the pre-launch menu) starts on the same model.
107
+ export function readDialogSelectedModel(home) {
108
+ if (typeof home !== 'string' || !home.trim()) return undefined
109
+ const settings = readSettings(home)
110
+ const name = settings.model?.name
111
+ if (typeof name !== 'string') return undefined
112
+ const trimmed = name.trim()
113
+ return MODEL_ID.test(trimmed) ? trimmed : undefined
114
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botconnector/bccli",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "description": "BCCLI | Full coding engine and legacy SDK with verified Apache-2.0 bundled runtime",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,7 @@
24
24
  "test": "node --test test/*.test.mjs",
25
25
  "test:security": "node --test test/security-e2e.test.mjs test/proxy-mcp-e2e.test.mjs test/trusted-mcp.test.mjs test/proxy.test.mjs",
26
26
  "test:integration": "node --test test-integration/*.test.mjs",
27
- "build": "node --check cli.mjs && node --check core.mjs && node --check proxy.mjs && node --check gateway.mjs && node --check branding.mjs && node --check credentials.mjs && node --check model-command.mjs && node --check providers.mjs && node --check provider-cli.mjs && node --check startup-menu.mjs",
27
+ "build": "node --check cli.mjs && node --check core.mjs && node --check proxy.mjs && node --check gateway.mjs && node --check branding.mjs && node --check credentials.mjs && node --check dialog-models.mjs && node --check providers.mjs && node --check provider-cli.mjs && node --check startup-menu.mjs",
28
28
  "rebuild": "npm run build && npm test && npm pack --ignore-scripts"
29
29
  },
30
30
  "license": "Apache-2.0",
@@ -43,7 +43,7 @@
43
43
  "gateway.mjs",
44
44
  "branding.mjs",
45
45
  "credentials.mjs",
46
- "model-command.mjs",
46
+ "dialog-models.mjs",
47
47
  "providers.mjs",
48
48
  "provider-cli.mjs",
49
49
  "startup-menu.mjs",
@@ -81,8 +81,8 @@
81
81
  },
82
82
  "bccliRuntimeSource": {
83
83
  "repository": "https://github.com/farijarifriyanto-debug/bccli-runtime",
84
- "commit": "eb5e057b3f568e9def717cdc3d8a3b11ce82544f",
85
- "release": "bccli-v0.5.3",
86
- "originalSha256": "3a185b33af625ef9df3552e40a9390d4d271b0b977971433357b2f592b5553bc"
84
+ "commit": "999fe7d6179954f45e9492c55a8384cd93a51737",
85
+ "release": "bccli-v0.5.4",
86
+ "originalSha256": "52702af547846044b9aa36d0f7be33816d3fd26cdcb05f8bb3bdd30b3d290da3"
87
87
  }
88
88
  }
@@ -21,12 +21,18 @@ export const BCCLI_PROVIDER_COMMAND = String.raw`var authCommand={name:"provider
21
21
  if(!Array.isArray(data?.providers))return response("error","Provider list unavailable.");
22
22
  var providers=data.providers;
23
23
  var current=data.active||"(unknown)";
24
+ // OpenTUI renders the upstream AuthDialog for dialog:"auth", which would
25
+ // expose provider settings BCCLI does not own. Keep the same gateway-owned
26
+ // text flow there instead of opening that dialog.
27
+ var openTui=process.env.QWEN_TUI_RENDERER==="opentui";
28
+ var listText=()=>"BCCLI providers — active: "+current+"\n\n"+providers.map((p,i)=>(i+1)+". "+p.id+" — "+p.name+(p.active?" [ACTIVE]":"")+(p.configured?" [configured]":" [API key required]")).join("\n")+"\n\nChoose: /provider NUMBER or /provider NAME\nSave/replace key: /provider add NAME\nDelete saved key: /provider remove NAME\nSelect AI: /provider NAME MODEL_ID\nKeys open a hidden Windows dialog; NEVER paste keys into chat.";
24
29
  if(!params.length){
30
+ if(openTui)return response("info",listText());
25
31
  delete process.env.BCCLI_PROVIDER_PICKER_FOCUS;
26
32
  return {type:"dialog",dialog:"auth"};
27
33
  }
28
34
  if(params[0]==="list"){
29
- return response("info","BCCLI providers — active: "+current+"\n\n"+providers.map((p,i)=>(i+1)+". "+p.id+" — "+p.name+(p.active?" [ACTIVE]":"")+(p.configured?" [configured]":" [API key required]")).join("\n")+"\n\nChoose: /provider NUMBER or /provider NAME\nSave/replace key: /provider add NAME\nDelete saved key: /provider remove NAME\nSelect AI: /provider NAME MODEL_ID\nKeys open a hidden Windows dialog; NEVER paste keys into chat.");
35
+ return response("info",listText());
30
36
  }
31
37
  if(params.length===1&&params[0].toLowerCase()==="add"){
32
38
  const created=await request("/bccli/provider/custom/add",{method:"POST",timeoutMs:190000,
@@ -53,7 +59,7 @@ export const BCCLI_PROVIDER_COMMAND = String.raw`var authCommand={name:"provider
53
59
  return response("error","The key operation was not confirmed.");
54
60
  }
55
61
  var id=/^[0-9]+$/.test(params[0])?providers[Number(params[0])-1]?.id:params[0].toLowerCase();
56
- if(params.length===1&&providers.some(p=>p.id===id)){
62
+ if(params.length===1&&providers.some(p=>p.id===id)&&!openTui){
57
63
  process.env.BCCLI_PROVIDER_PICKER_FOCUS=id;
58
64
  return {type:"dialog",dialog:"auth"};
59
65
  }
package/startup-menu.mjs CHANGED
@@ -14,19 +14,24 @@ export async function autoSelectStartup({
14
14
  providerId = selectedProvider(),
15
15
  configured = isConfigured,
16
16
  remembered = selectedModel,
17
+ dialogModel = () => undefined,
17
18
  resolve = resolveProvider,
18
19
  list = discoverModels,
19
20
  defaultModel = DEFAULT_MODEL,
20
21
  env = process.env,
21
22
  } = {}) {
22
23
  if (!configured(providerId)) return undefined
23
- const requested = providerId === 'botconnector'
24
- ? (env.BOTCONNECTOR_MODEL_ID || remembered(providerId) || defaultModel)
25
- : remembered(providerId)
26
- if (!requested) return undefined
24
+ // A model chosen in the upstream /model picker is persisted in the engine's
25
+ // per-provider settings; it wins over the older active-model file, but only
26
+ // when the live catalog still offers it.
27
+ const candidates = providerId === 'botconnector'
28
+ ? [env.BOTCONNECTOR_MODEL_ID, dialogModel(providerId), remembered(providerId), defaultModel]
29
+ : [dialogModel(providerId), remembered(providerId)]
30
+ if (!candidates.some(Boolean)) return undefined
27
31
  const profile = resolve(providerId)
28
32
  const models = await list({ key: profile.key, endpoint: profile.endpoint })
29
- if (!models.some(item => item.id === requested)) return undefined
33
+ const requested = candidates.find(id => id && models.some(item => item.id === id))
34
+ if (!requested) return undefined
30
35
  return { providerId, model: selectModel(models, requested), key: profile.key, endpoint: profile.endpoint }
31
36
  }
32
37
 
@@ -52,6 +57,7 @@ export async function startupMenu({
52
57
  saveKey = saveProviderKey,
53
58
  use = useProvider,
54
59
  remembered = selectedModel,
60
+ dialogModel = () => undefined,
55
61
  saveModel = saveSelectedModel,
56
62
  resolve = resolveProvider,
57
63
  list = discoverModels,
@@ -90,7 +96,7 @@ export async function startupMenu({
90
96
  const models = await list({ key: profile.key, endpoint: profile.endpoint })
91
97
  if (!models.length) throw Error('Tidak ada model untuk provider ini.')
92
98
  const all = [...models].sort((a, b) => a.id.localeCompare(b.id))
93
- const previous = remembered(providerId)
99
+ const previous = dialogModel(providerId) ?? remembered(providerId)
94
100
  const preferred = all.some(m => m.id === previous) ? previous :
95
101
  providerId === 'botconnector' && all.some(m => m.id === defaultModel) ? defaultModel : undefined
96
102
  let filtered = all
package/model-command.mjs DELETED
@@ -1,61 +0,0 @@
1
- // Generated, version-pinned Qwen slash command overlay for /model.
2
- // Fetches the authenticated model catalog from the existing local BotConnector
3
- // proxy on each invocation. Selection changes Qwen Config in the current
4
- // session; it never opens upstream provider settings or writes project config.
5
- export const BCCLI_MODEL_COMMAND = String.raw`var bccliModelLastList=[];var modelCommand={name:"model",get description(){return "Choose an AI model from the active provider"},argumentHint:"[number|model-id|search WORD|page N]",kind:"built-in",supportedModes:["interactive","non_interactive","acp"],action:__name(async(context,argumentText)=>{
6
- var response=(type,content)=>({type:"message",messageType:type,content});
7
- try{
8
- var base=process.env.OPENAI_BASE_URL||"";
9
- var key=process.env.OPENAI_API_KEY||"";
10
- if(!/^http:\/\/127\.0\.0\.1:[0-9]{1,5}\/v1$/.test(base)||!key){
11
- return response("error","BCCLI requires an authenticated local provider session.");
12
- }
13
- var result=await fetch(base+"/models",{method:"GET",headers:{Authorization:"Bearer "+key,Accept:"application/json"},redirect:"error",signal:AbortSignal.timeout(10000)});
14
- if(!result.ok)return response("error","Provider model catalog is unavailable (HTTP "+result.status+"). No provider fallback.");
15
- var payload=await result.json();
16
- if(!Array.isArray(payload?.data))return response("error","Invalid provider model catalog.");
17
- var catalog=new Map();
18
- for(var entry of payload.data){
19
- if(typeof entry?.id==="string" && /^[a-zA-Z0-9][a-zA-Z0-9._:/+-]{0,255}$/.test(entry.id) && !catalog.has(entry.id)){
20
- catalog.set(entry.id,{id:entry.id,name:typeof entry.name==="string"?entry.name.slice(0,90):entry.id});
21
- }
22
- }
23
- var all=[...catalog.values()].sort((a,b)=>a.id.localeCompare(b.id));
24
- if(!all.length)return response("error","No provider models available for this API key.");
25
- var text=String(context.invocation?.args||argumentText||"").trim();
26
- var config=context.services?.config;
27
- if(!config||typeof config.setModel!=="function")return response("error","BCCLI model switch is unavailable in this session.");
28
- var current=config.getModel?.()||process.env.OPENAI_MODEL||"(unknown)";
29
- var list=(entries,page=1)=>{
30
- bccliModelLastList=entries.map(x=>x.id);
31
- var size=12;
32
- var pages=Math.max(1,Math.ceil(entries.length/size));
33
- if(!Number.isSafeInteger(page)||page<1||page>pages)return response("error","Invalid page. Use /model page 1 ... "+pages);
34
- var start=(page-1)*size;
35
- var rows=entries.slice(start,start+size).map((x,i)=>(start+i+1)+". "+x.id+(x.id===current?" [ACTIVE]":""));
36
- return response("info","Provider AI models ("+entries.length+" available; page "+page+"/"+pages+")\nActive: "+current+"\n\n"+rows.join("\n")+"\n\nUse /model NUMBER or /model MODEL_ID to switch now; /model page N; /model search KEYWORD. To manage providers and their keys in this chat, use /provider, /provider add NAME or /provider remove NAME; then /provider NAME MODEL_ID.");
37
- };
38
- if(!text)return list(all);
39
- var page=text.match(/^page\s+([0-9]+)$/i);
40
- if(page)return list(all,Number(page[1]));
41
- var search=text.match(/^search\s+(.{1,80})$/i);
42
- if(search)return list(all.filter(x=>x.id.toLowerCase().includes(search[1].toLowerCase())||x.name.toLowerCase().includes(search[1].toLowerCase())));
43
- var item;
44
- if(/^[0-9]+$/.test(text)){
45
- var index=Number(text);
46
- item=Number.isSafeInteger(index)&&index>=1?catalog.get(bccliModelLastList[index-1]||all[index-1]?.id):undefined;
47
- }else if(/^[a-zA-Z0-9][a-zA-Z0-9._:/+-]{0,255}$/.test(text)){
48
- item=catalog.get(text);
49
- }
50
- if(!item)return response("error","That model is not available in your active provider catalog. Run /model to view allowed models.");
51
- if(item.id===current)return response("info","Already using "+item.id+" via the active provider.");
52
- // Raw model IDs are supported by Config.setModel; this preserves OpenAI
53
- // auth/loopback proxy instead of switching providers. It refreshes the
54
- // live content generator, so the next message uses the new model.
55
- await config.setModel(item.id,{reason:"bccli-gateway-model-picker"});
56
- if(config.getModel?.()!==item.id)return response("error","Model switch did not take effect; previous model preserved.");
57
- return response("info","Switched to "+item.id+" in the current session. Routing and billing remain with the active provider.");
58
- }catch{
59
- return response("error","Provider model selection failed; no upstream provider fallback was used.");
60
- }
61
- },"action")};`