@botconnector/bccli 0.4.17 → 0.5.0
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 +203 -21
- package/THIRD_PARTY_NOTICES.md +9 -0
- package/branding.mjs +347 -0
- package/cli.mjs +128 -0
- package/core.mjs +260 -0
- package/credentials.mjs +52 -0
- package/gateway.mjs +2 -0
- package/legacy/LICENSE +21 -0
- package/legacy/package.json +77 -0
- package/legacy-compat.mjs +42 -0
- package/model-command.mjs +61 -0
- package/package.json +47 -51
- package/provider-cli.mjs +76 -0
- package/providers.mjs +179 -0
- package/proxy.mjs +242 -0
- package/startup-menu.mjs +121 -0
- /package/{README.en.md → legacy/README.en.md} +0 -0
- /package/{README.md → legacy/README.md} +0 -0
- /package/{dist → legacy/dist}/abort.d.ts +0 -0
- /package/{dist → legacy/dist}/acp-HWHF25KU.js +0 -0
- /package/{dist → legacy/dist}/acp.d.ts +0 -0
- /package/{dist → legacy/dist}/agent.d.ts +0 -0
- /package/{dist → legacy/dist}/agents.d.ts +0 -0
- /package/{dist → legacy/dist}/args-XA77XUMD.js +0 -0
- /package/{dist → legacy/dist}/args.d.ts +0 -0
- /package/{dist → legacy/dist}/budget.d.ts +0 -0
- /package/{dist → legacy/dist}/checkpoints.d.ts +0 -0
- /package/{dist → legacy/dist}/chunk-2IP5F3EK.js +0 -0
- /package/{dist → legacy/dist}/chunk-5CVHTYBQ.js +0 -0
- /package/{dist → legacy/dist}/chunk-6LO6YRDY.js +0 -0
- /package/{dist → legacy/dist}/chunk-6PJRWKVI.js +0 -0
- /package/{dist → legacy/dist}/chunk-7OTFZK4B.js +0 -0
- /package/{dist → legacy/dist}/chunk-7U7CMKKI.js +0 -0
- /package/{dist → legacy/dist}/chunk-A3NBWIEQ.js +0 -0
- /package/{dist → legacy/dist}/chunk-FY543EQW.js +0 -0
- /package/{dist → legacy/dist}/chunk-IDZLHEVL.js +0 -0
- /package/{dist → legacy/dist}/chunk-IF5RNXBI.js +0 -0
- /package/{dist → legacy/dist}/chunk-M5T4AM4I.js +0 -0
- /package/{dist → legacy/dist}/chunk-NGFWBVHD.js +0 -0
- /package/{dist → legacy/dist}/chunk-OCEQ2TRJ.js +0 -0
- /package/{dist → legacy/dist}/chunk-OK2USAXX.js +0 -0
- /package/{dist → legacy/dist}/chunk-PSO7R5AD.js +0 -0
- /package/{dist → legacy/dist}/chunk-RLSQAZBT.js +0 -0
- /package/{dist → legacy/dist}/chunk-VPFCIUC6.js +0 -0
- /package/{dist → legacy/dist}/chunk-XP5MTJN2.js +0 -0
- /package/{dist → legacy/dist}/chunk-YGBTNDPK.js +0 -0
- /package/{dist → legacy/dist}/chunk-YYTFJUO2.js +0 -0
- /package/{dist → legacy/dist}/chunk-ZXWRLN7E.js +0 -0
- /package/{dist → legacy/dist}/cli.d.ts +0 -0
- /package/{dist → legacy/dist}/cli.js +0 -0
- /package/{dist → legacy/dist}/clipboardImage.d.ts +0 -0
- /package/{dist → legacy/dist}/commands.d.ts +0 -0
- /package/{dist → legacy/dist}/completion-IRVEVW6H.js +0 -0
- /package/{dist → legacy/dist}/completion.d.ts +0 -0
- /package/{dist → legacy/dist}/config-OVWCUVY7.js +0 -0
- /package/{dist → legacy/dist}/config-S3CZUIBB.js +0 -0
- /package/{dist → legacy/dist}/config.d.ts +0 -0
- /package/{dist → legacy/dist}/context.d.ts +0 -0
- /package/{dist → legacy/dist}/contextWindow.d.ts +0 -0
- /package/{dist → legacy/dist}/diff.d.ts +0 -0
- /package/{dist → legacy/dist}/exit.d.ts +0 -0
- /package/{dist → legacy/dist}/extensions.d.ts +0 -0
- /package/{dist → legacy/dist}/ghBotCli-FYKJDJOD.js +0 -0
- /package/{dist → legacy/dist}/ghBotCli.d.ts +0 -0
- /package/{dist → legacy/dist}/ghBotDispatch.d.ts +0 -0
- /package/{dist → legacy/dist}/ghBotRunner.d.ts +0 -0
- /package/{dist → legacy/dist}/ghBotServe.d.ts +0 -0
- /package/{dist → legacy/dist}/ghBotWebhook.d.ts +0 -0
- /package/{dist → legacy/dist}/hooks.d.ts +0 -0
- /package/{dist → legacy/dist}/i18n/id.d.ts +0 -0
- /package/{dist → legacy/dist}/i18n.d.ts +0 -0
- /package/{dist → legacy/dist}/integrationCli-GY73S2K7.js +0 -0
- /package/{dist → legacy/dist}/integrationCli.d.ts +0 -0
- /package/{dist → legacy/dist}/keybinds.d.ts +0 -0
- /package/{dist → legacy/dist}/login-KSN63EM4.js +0 -0
- /package/{dist → legacy/dist}/login.d.ts +0 -0
- /package/{dist → legacy/dist}/lsp.d.ts +0 -0
- /package/{dist → legacy/dist}/mcp/catalog.d.ts +0 -0
- /package/{dist → legacy/dist}/mcp/config.d.ts +0 -0
- /package/{dist → legacy/dist}/mcp/manager.d.ts +0 -0
- /package/{dist → legacy/dist}/mcp/oauth.d.ts +0 -0
- /package/{dist → legacy/dist}/mcpCli-VWSCCDWT.js +0 -0
- /package/{dist → legacy/dist}/mcpCli.d.ts +0 -0
- /package/{dist → legacy/dist}/mcpServer/serve.d.ts +0 -0
- /package/{dist → legacy/dist}/mcpServer/server.d.ts +0 -0
- /package/{dist → legacy/dist}/models.d.ts +0 -0
- /package/{dist → legacy/dist}/modelsCli-4Q6KFWV2.js +0 -0
- /package/{dist → legacy/dist}/modelsCli.d.ts +0 -0
- /package/{dist → legacy/dist}/npmRunner-LU43GM3W.js +0 -0
- /package/{dist → legacy/dist}/npmRunner.d.ts +0 -0
- /package/{dist → legacy/dist}/oauthLogin-JWXQZTMZ.js +0 -0
- /package/{dist → legacy/dist}/oauthLogin.d.ts +0 -0
- /package/{dist → legacy/dist}/permissions.d.ts +0 -0
- /package/{dist → legacy/dist}/pluginSpec.d.ts +0 -0
- /package/{dist → legacy/dist}/plugins.d.ts +0 -0
- /package/{dist → legacy/dist}/pluginsCli-7XWDLJEL.js +0 -0
- /package/{dist → legacy/dist}/pluginsCli.d.ts +0 -0
- /package/{dist → legacy/dist}/prCli-FH7N26D4.js +0 -0
- /package/{dist → legacy/dist}/prCli.d.ts +0 -0
- /package/{dist → legacy/dist}/presets.d.ts +0 -0
- /package/{dist → legacy/dist}/print.d.ts +0 -0
- /package/{dist → legacy/dist}/provider.d.ts +0 -0
- /package/{dist → legacy/dist}/providerCli-YMG4G56R.js +0 -0
- /package/{dist → legacy/dist}/providerCli.d.ts +0 -0
- /package/{dist → legacy/dist}/providers.d.ts +0 -0
- /package/{dist → legacy/dist}/reasoning.d.ts +0 -0
- /package/{dist → legacy/dist}/repomap.d.ts +0 -0
- /package/{dist → legacy/dist}/sdk.d.ts +0 -0
- /package/{dist → legacy/dist}/sdk.js +0 -0
- /package/{dist → legacy/dist}/serve-HSFF6MGB.js +0 -0
- /package/{dist → legacy/dist}/serve-HUQNVOSP.js +0 -0
- /package/{dist → legacy/dist}/serve.d.ts +0 -0
- /package/{dist → legacy/dist}/serveHeartbeat.d.ts +0 -0
- /package/{dist → legacy/dist}/serveProtocol.d.ts +0 -0
- /package/{dist → legacy/dist}/session-YM36TGU7.js +0 -0
- /package/{dist → legacy/dist}/session.d.ts +0 -0
- /package/{dist → legacy/dist}/setup-3YB6EKYX.js +0 -0
- /package/{dist → legacy/dist}/setup.d.ts +0 -0
- /package/{dist → legacy/dist}/slash/copy.d.ts +0 -0
- /package/{dist → legacy/dist}/slash/diff.d.ts +0 -0
- /package/{dist → legacy/dist}/slash/doctor.d.ts +0 -0
- /package/{dist → legacy/dist}/slash/export.d.ts +0 -0
- /package/{dist → legacy/dist}/slash/info.d.ts +0 -0
- /package/{dist → legacy/dist}/slash/memory.d.ts +0 -0
- /package/{dist → legacy/dist}/statsCli-I4TSDU3X.js +0 -0
- /package/{dist → legacy/dist}/statsCli.d.ts +0 -0
- /package/{dist → legacy/dist}/textToolCalls.d.ts +0 -0
- /package/{dist → legacy/dist}/thinking.d.ts +0 -0
- /package/{dist → legacy/dist}/tokenBudget.d.ts +0 -0
- /package/{dist → legacy/dist}/tools/bash.d.ts +0 -0
- /package/{dist → legacy/dist}/tools/diagnostics.d.ts +0 -0
- /package/{dist → legacy/dist}/tools/edit.d.ts +0 -0
- /package/{dist → legacy/dist}/tools/fetch.d.ts +0 -0
- /package/{dist → legacy/dist}/tools/glob.d.ts +0 -0
- /package/{dist → legacy/dist}/tools/globSafety.d.ts +0 -0
- /package/{dist → legacy/dist}/tools/grep.d.ts +0 -0
- /package/{dist → legacy/dist}/tools/ignore.d.ts +0 -0
- /package/{dist → legacy/dist}/tools/index.d.ts +0 -0
- /package/{dist → legacy/dist}/tools/memory.d.ts +0 -0
- /package/{dist → legacy/dist}/tools/paths.d.ts +0 -0
- /package/{dist → legacy/dist}/tools/plan.d.ts +0 -0
- /package/{dist → legacy/dist}/tools/read.d.ts +0 -0
- /package/{dist → legacy/dist}/tools/repomap.d.ts +0 -0
- /package/{dist → legacy/dist}/tools/sandbox.d.ts +0 -0
- /package/{dist → legacy/dist}/tools/shell.d.ts +0 -0
- /package/{dist → legacy/dist}/tools/skill.d.ts +0 -0
- /package/{dist → legacy/dist}/tools/task.d.ts +0 -0
- /package/{dist → legacy/dist}/tools/todo.d.ts +0 -0
- /package/{dist → legacy/dist}/tools/types.d.ts +0 -0
- /package/{dist → legacy/dist}/tools/websearch.d.ts +0 -0
- /package/{dist → legacy/dist}/tools/write.d.ts +0 -0
- /package/{dist → legacy/dist}/transferCli-3B4LMOYI.js +0 -0
- /package/{dist → legacy/dist}/transferCli.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/App.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/DiffView.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/EditableText.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/LinePrompt.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/ListPicker.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/Markdown.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/McpMenu.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/ModelPicker.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/PermissionPrompt.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/PlanApproval.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/PromptInput.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/ProviderMenu.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/Spinner.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/StatusBar.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/TodoList.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/ToolBlock.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/complete.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/index.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/lineEdit.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/liveCap.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/markdownLayout.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/replay.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/stdinFilter.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/table.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/theme.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/thinkingMouse.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/transcript.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/tty.d.ts +0 -0
- /package/{dist → legacy/dist}/ui/vim.d.ts +0 -0
- /package/{dist → legacy/dist}/ui-TAZMRXRI.js +0 -0
- /package/{dist → legacy/dist}/update-5V425FIK.js +0 -0
- /package/{dist → legacy/dist}/update.d.ts +0 -0
- /package/{dist → legacy/dist}/verify.d.ts +0 -0
- /package/{dist → legacy/dist}/version.d.ts +0 -0
- /package/{dist → legacy/dist}/webBudget.d.ts +0 -0
- /package/{dist → legacy/dist}/worktree.d.ts +0 -0
package/package.json
CHANGED
|
@@ -1,29 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botconnector/bccli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "BCCLI
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "BCCLI | BotConnector multi-provider coding agent with stable CLI/SDK compatibility",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"bccli": "
|
|
7
|
+
"bccli": "./cli.mjs"
|
|
8
8
|
},
|
|
9
|
-
"files": [
|
|
10
|
-
"dist"
|
|
11
|
-
],
|
|
12
9
|
"engines": {
|
|
13
10
|
"node": ">=22"
|
|
14
11
|
},
|
|
15
|
-
"repository": {
|
|
16
|
-
"type": "git",
|
|
17
|
-
"url": "git+https://github.com/farijarifriyanto-debug/bccli.git"
|
|
18
|
-
},
|
|
19
|
-
"license": "MIT",
|
|
20
|
-
"scripts": {
|
|
21
|
-
"build": "tsup && tsc -p tsconfig.build.json",
|
|
22
|
-
"typecheck": "tsc --noEmit",
|
|
23
|
-
"lint": "biome lint --error-on-warnings src test",
|
|
24
|
-
"test": "vitest run",
|
|
25
|
-
"coverage": "vitest run --coverage"
|
|
26
|
-
},
|
|
27
12
|
"dependencies": {
|
|
28
13
|
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
29
14
|
"diff": "^9.0.0",
|
|
@@ -33,45 +18,56 @@
|
|
|
33
18
|
"undici": "^8.12.1",
|
|
34
19
|
"ws": "^8.22.0",
|
|
35
20
|
"yaml": "^2.9.1",
|
|
36
|
-
"zod": "^4.6.5"
|
|
21
|
+
"zod": "^4.6.5",
|
|
22
|
+
"@qwen-code/qwen-code": "0.26.3"
|
|
37
23
|
},
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"ink-testing-library": "^4.0.0",
|
|
45
|
-
"tsup": "^9.0.0",
|
|
46
|
-
"typescript": "^7.0.2",
|
|
47
|
-
"vitest": "^5.0.2"
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --test test/*.test.mjs",
|
|
26
|
+
"test:security": "node --test test/security-e2e.test.mjs test/proxy-mcp-e2e.test.mjs test/trusted-mcp.test.mjs test/proxy.test.mjs",
|
|
27
|
+
"test:integration": "node --test test-integration/*.test.mjs",
|
|
28
|
+
"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",
|
|
29
|
+
"rebuild": "npm run build && npm test && npm pack --ignore-scripts"
|
|
48
30
|
},
|
|
31
|
+
"license": "Apache-2.0",
|
|
49
32
|
"overrides": {
|
|
50
|
-
"esbuild": "0.28.2"
|
|
51
|
-
|
|
52
|
-
"main": "dist/sdk.js",
|
|
53
|
-
"types": "dist/sdk.d.ts",
|
|
54
|
-
"exports": {
|
|
55
|
-
".": {
|
|
56
|
-
"types": "./dist/sdk.d.ts",
|
|
57
|
-
"default": "./dist/sdk.js"
|
|
58
|
-
}
|
|
33
|
+
"esbuild": "0.28.2",
|
|
34
|
+
"sharp": "0.35.5"
|
|
59
35
|
},
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
36
|
+
"files": [
|
|
37
|
+
"LICENSE",
|
|
38
|
+
"THIRD_PARTY_NOTICES.md",
|
|
39
|
+
"cli.mjs",
|
|
40
|
+
"core.mjs",
|
|
41
|
+
"proxy.mjs",
|
|
42
|
+
"gateway.mjs",
|
|
43
|
+
"branding.mjs",
|
|
44
|
+
"credentials.mjs",
|
|
45
|
+
"model-command.mjs",
|
|
46
|
+
"providers.mjs",
|
|
47
|
+
"provider-cli.mjs",
|
|
48
|
+
"startup-menu.mjs",
|
|
49
|
+
"legacy-compat.mjs",
|
|
50
|
+
"legacy"
|
|
71
51
|
],
|
|
72
|
-
"
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "git+https://github.com/farijarifriyanto-debug/bccli-runtime.git"
|
|
55
|
+
},
|
|
56
|
+
"homepage": "https://github.com/farijarifriyanto-debug/bccli#readme",
|
|
73
57
|
"bugs": {
|
|
74
58
|
"url": "https://github.com/farijarifriyanto-debug/bccli/issues"
|
|
75
59
|
},
|
|
76
|
-
"
|
|
60
|
+
"author": "BICO Inovasi Digital / BotConnector",
|
|
61
|
+
"main": "./legacy/dist/sdk.js",
|
|
62
|
+
"types": "./legacy/dist/sdk.d.ts",
|
|
63
|
+
"exports": {
|
|
64
|
+
".": {
|
|
65
|
+
"types": "./legacy/dist/sdk.d.ts",
|
|
66
|
+
"default": "./legacy/dist/sdk.js"
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"publishConfig": {
|
|
70
|
+
"access": "public",
|
|
71
|
+
"tag": "next"
|
|
72
|
+
}
|
|
77
73
|
}
|
package/provider-cli.mjs
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Provider management is an explicitly invoked CLI operation, not a model prompt.
|
|
2
|
+
import { PROVIDERS, isConfigured, providerDefinition, removeProvider, saveProviderKey, selectedProvider, useProvider } from './providers.mjs'
|
|
3
|
+
|
|
4
|
+
export async function hiddenApiKey({ input = process.stdin, output = process.stdout } = {}) {
|
|
5
|
+
if (!input.isTTY || typeof input.setRawMode !== 'function') throw Error('Provider add requires an interactive terminal to enter a hidden API key.')
|
|
6
|
+
output.write('Paste API key (hidden; Enter to save, Ctrl+C to cancel): ')
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
let value = ''
|
|
9
|
+
const finish = (error) => {
|
|
10
|
+
input.off('data', onData)
|
|
11
|
+
input.off('end', onEnd)
|
|
12
|
+
input.setRawMode(false)
|
|
13
|
+
input.pause()
|
|
14
|
+
output.write('\n')
|
|
15
|
+
error ? reject(error) : resolve(value)
|
|
16
|
+
}
|
|
17
|
+
const onEnd = () => finish(Error('API key input ended before confirmation.'))
|
|
18
|
+
const onData = buffer => {
|
|
19
|
+
for (const ch of buffer.toString('utf8')) {
|
|
20
|
+
if (ch === '\u0003') return finish(Error('Cancelled.'))
|
|
21
|
+
if (ch === '\r' || ch === '\n') return finish(value ? null : Error('Empty API key; nothing saved.'))
|
|
22
|
+
if (ch === '\u007f' || ch === '\b') { value = value.slice(0, -1); continue }
|
|
23
|
+
if (ch.charCodeAt(0) < 32) continue
|
|
24
|
+
value += ch
|
|
25
|
+
if (value.length > 8192) return finish(Error('API key is too long; nothing saved.'))
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
input.on('data', onData)
|
|
29
|
+
input.on('end', onEnd)
|
|
30
|
+
input.setRawMode(true)
|
|
31
|
+
input.resume()
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function runProviderCommand(words, {
|
|
36
|
+
env = process.env,
|
|
37
|
+
getKey = hiddenApiKey,
|
|
38
|
+
out = text => console.log(text),
|
|
39
|
+
save = saveProviderKey,
|
|
40
|
+
select = useProvider,
|
|
41
|
+
remove = removeProvider,
|
|
42
|
+
configured = isConfigured,
|
|
43
|
+
active = selectedProvider,
|
|
44
|
+
} = {}) {
|
|
45
|
+
const [action = 'list', id, ...extra] = words
|
|
46
|
+
if (!['list', 'add', 'use', 'remove'].includes(action) || (action === 'list' ? words.length > 1 : !id || extra.length)) {
|
|
47
|
+
throw Error('Use: provider list | add PROVIDER | use PROVIDER | remove PROVIDER.')
|
|
48
|
+
}
|
|
49
|
+
if (action === 'list') {
|
|
50
|
+
const current = active({env})
|
|
51
|
+
for (const [name, info] of Object.entries(PROVIDERS)) {
|
|
52
|
+
out(name + ' - ' + info.name + ' [' + (configured(name,{env}) ? 'configured' : 'no key') + ']' + (name === current ? ' [ACTIVE]' : ''))
|
|
53
|
+
}
|
|
54
|
+
out('Add: bccli provider add openrouter; select: bccli provider use openrouter')
|
|
55
|
+
out('Provider keys are stored encrypted for your Windows user. Other systems: use the provider environment variable.')
|
|
56
|
+
return 0
|
|
57
|
+
}
|
|
58
|
+
providerDefinition(id)
|
|
59
|
+
if (action === 'add') {
|
|
60
|
+
out('Provider: ' + PROVIDERS[id].name + ' (API use may incur charges; entering a key does not send an AI prompt).')
|
|
61
|
+
const key = await getKey()
|
|
62
|
+
save(id, key)
|
|
63
|
+
select(id,{env})
|
|
64
|
+
out('API key securely saved and provider selected: ' + id + '. Key was not printed. No AI request sent.')
|
|
65
|
+
out('Run: bccli models, then bccli chat --model MODEL_ID')
|
|
66
|
+
return 0
|
|
67
|
+
}
|
|
68
|
+
if (action === 'use') {
|
|
69
|
+
select(id,{env})
|
|
70
|
+
out('Active provider: ' + id + '. New sessions only; existing chats are unchanged.')
|
|
71
|
+
return 0
|
|
72
|
+
}
|
|
73
|
+
remove(id,{env})
|
|
74
|
+
out('Saved provider key removed for ' + id + '. Environment variables, if set, are not changed.')
|
|
75
|
+
return 0
|
|
76
|
+
}
|
package/providers.mjs
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// BCCLI Preview provider keys: explicit OpenAI-compatible presets only.
|
|
2
|
+
// Windows stores provider API keys as per-user DPAPI encrypted blobs. The
|
|
3
|
+
// upstream Qwen process receives only an ephemeral loopback proxy token.
|
|
4
|
+
import { randomBytes } from 'node:crypto'
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { homedir } from 'node:os'
|
|
8
|
+
import { isAbsolute, join } from 'node:path'
|
|
9
|
+
import { botConnectorCredentialStatus, botConnectorKey } from './credentials.mjs'
|
|
10
|
+
import { GATEWAY } from './gateway.mjs'
|
|
11
|
+
|
|
12
|
+
export const PROVIDERS = Object.freeze({
|
|
13
|
+
botconnector: Object.freeze({ name: 'BotConnector Cloud', endpoint: GATEWAY }),
|
|
14
|
+
openai: Object.freeze({ name: 'OpenAI API', endpoint: 'https://api.openai.com/v1', env: 'OPENAI_API_KEY' }),
|
|
15
|
+
openrouter: Object.freeze({ name: 'OpenRouter', endpoint: 'https://openrouter.ai/api/v1', env: 'OPENROUTER_API_KEY' }),
|
|
16
|
+
deepseek: Object.freeze({ name: 'DeepSeek', endpoint: 'https://api.deepseek.com/v1', env: 'DEEPSEEK_API_KEY' }),
|
|
17
|
+
groq: Object.freeze({ name: 'Groq', endpoint: 'https://api.groq.com/openai/v1', env: 'GROQ_API_KEY' }),
|
|
18
|
+
xai: Object.freeze({ name: 'xAI', endpoint: 'https://api.x.ai/v1', env: 'XAI_API_KEY' }),
|
|
19
|
+
})
|
|
20
|
+
export const ALLOWED_PROVIDER_ENDPOINTS = Object.freeze(Object.values(PROVIDERS).map(p => p.endpoint))
|
|
21
|
+
const ID = /^[a-z][a-z0-9-]{0,31}$/
|
|
22
|
+
const MAX_BLOB_BYTES = 64 * 1024
|
|
23
|
+
const ENC_SCRIPT = '$ErrorActionPreference="Stop"; $plain=[Console]::In.ReadToEnd().Trim(); if(-not $plain){exit 1}; $sec=ConvertTo-SecureString -String $plain -AsPlainText -Force; [Console]::Out.Write((ConvertFrom-SecureString -SecureString $sec))'
|
|
24
|
+
const DEC_SCRIPT = '$ErrorActionPreference="Stop"; $encoded=[Console]::In.ReadToEnd().Trim(); $sec=ConvertTo-SecureString -String $encoded; $ptr=[Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec); try{[Console]::Out.Write([Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr))}finally{[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr)}'
|
|
25
|
+
|
|
26
|
+
export function providerRoot({ home = homedir(), env = process.env } = {}) {
|
|
27
|
+
const path = env.BCCLI_PREVIEW_HOME || join(home, '.bccli-preview')
|
|
28
|
+
if (!isAbsolute(path)) throw Error('BCCLI_PREVIEW_HOME must be absolute.')
|
|
29
|
+
return path
|
|
30
|
+
}
|
|
31
|
+
export function providerDefinition(id) {
|
|
32
|
+
if (!Object.hasOwn(PROVIDERS, id)) throw Error('Unknown provider. Choose from: ' + Object.keys(PROVIDERS).join(', '))
|
|
33
|
+
return PROVIDERS[id]
|
|
34
|
+
}
|
|
35
|
+
function ownerDirectory(root, { create = false } = {}) {
|
|
36
|
+
if (create) mkdirSync(root, { recursive: true, mode: 0o700 })
|
|
37
|
+
if (!existsSync(root)) return false
|
|
38
|
+
const info = lstatSync(root)
|
|
39
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw Error('Preview credential directory is not a safe regular directory.')
|
|
40
|
+
return true
|
|
41
|
+
}
|
|
42
|
+
function keyPath(root, id) {
|
|
43
|
+
if (!ID.test(id)) throw Error('Provider key storage requires a supported provider.')
|
|
44
|
+
return join(root, 'provider-' + id + '.dpapi')
|
|
45
|
+
}
|
|
46
|
+
function dpapi(script, input, { platform = process.platform, runner = spawnSync } = {}) {
|
|
47
|
+
if (platform !== 'win32') throw Error('Persisting provider API keys currently requires Windows DPAPI. Use the provider environment variable on other systems.')
|
|
48
|
+
const result = runner('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
49
|
+
input, encoding: 'utf8', timeout: 15000, maxBuffer: MAX_BLOB_BYTES, windowsHide: true,
|
|
50
|
+
env: { ...process.env, PSModulePath: process.env.PSModulePath },
|
|
51
|
+
})
|
|
52
|
+
if (result.error || result.status !== 0 || !result.stdout?.trim()) throw Error('Windows protected credential operation failed; no key was printed.')
|
|
53
|
+
return result.stdout.trim()
|
|
54
|
+
}
|
|
55
|
+
export function saveProviderKey(id, key, { root = providerRoot(), platform = process.platform, runner = spawnSync } = {}) {
|
|
56
|
+
providerDefinition(id)
|
|
57
|
+
if (typeof key !== 'string' || !key.trim() || key.length > 8192 || /[\r\n]/.test(key)) throw Error('API key cannot be empty, multiline, or oversized.')
|
|
58
|
+
if (platform !== 'win32') throw Error('On non-Windows systems, use the provider API key environment variable.')
|
|
59
|
+
ownerDirectory(root, { create: true })
|
|
60
|
+
const ciphertext = dpapi(ENC_SCRIPT, key.trim(), { platform, runner })
|
|
61
|
+
if (ciphertext.length > MAX_BLOB_BYTES || !/^[0-9a-f]+$/i.test(ciphertext)) throw Error('Windows credential encryption returned an invalid format.')
|
|
62
|
+
const path = keyPath(root, id)
|
|
63
|
+
if (existsSync(path) && (!lstatSync(path).isFile() || lstatSync(path).isSymbolicLink())) throw Error('Unsafe provider credentials path.')
|
|
64
|
+
const temporary = path + '.' + randomBytes(8).toString('hex') + '.tmp'
|
|
65
|
+
try {
|
|
66
|
+
writeFileSync(temporary, ciphertext + '\n', { encoding: 'utf8', flag: 'wx', mode: 0o600 })
|
|
67
|
+
renameSync(temporary, path)
|
|
68
|
+
} finally { rmSync(temporary, { force: true }) }
|
|
69
|
+
}
|
|
70
|
+
export function storedProviderKey(id, { root = providerRoot(), platform = process.platform, runner = spawnSync } = {}) {
|
|
71
|
+
providerDefinition(id)
|
|
72
|
+
if (!ownerDirectory(root)) throw Error('Provider not configured. Run provider add.')
|
|
73
|
+
const path = keyPath(root, id)
|
|
74
|
+
let info
|
|
75
|
+
try { info = lstatSync(path) }
|
|
76
|
+
catch { throw Error('Provider not configured. Run provider add.') }
|
|
77
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_BLOB_BYTES) throw Error('Unsafe provider credentials file.')
|
|
78
|
+
const cipher = readFileSync(path, 'utf8')
|
|
79
|
+
if (!/^[0-9a-f]+\s*$/i.test(cipher)) throw Error('Provider credentials are invalid.')
|
|
80
|
+
return dpapi(DEC_SCRIPT, cipher, { platform, runner })
|
|
81
|
+
}
|
|
82
|
+
export function isConfigured(id, { env = process.env, root = providerRoot({env}) } = {}) {
|
|
83
|
+
providerDefinition(id)
|
|
84
|
+
if (id === 'botconnector') {
|
|
85
|
+
if (env.BOTCONNECTOR_API_KEY?.trim()) return true
|
|
86
|
+
try {
|
|
87
|
+
if (ownerDirectory(root)) {
|
|
88
|
+
const info = lstatSync(keyPath(root, id))
|
|
89
|
+
if (info.isFile() && !info.isSymbolicLink() && info.size <= MAX_BLOB_BYTES) return true
|
|
90
|
+
}
|
|
91
|
+
} catch {}
|
|
92
|
+
return botConnectorCredentialStatus({ env }).configured
|
|
93
|
+
}
|
|
94
|
+
if (env[PROVIDERS[id].env]?.trim()) return true
|
|
95
|
+
try { return ownerDirectory(root) && lstatSync(keyPath(root, id)).isFile() && !lstatSync(keyPath(root, id)).isSymbolicLink() }
|
|
96
|
+
catch { return false }
|
|
97
|
+
}
|
|
98
|
+
export function selectedProvider({ env = process.env, root = providerRoot({env}) } = {}) {
|
|
99
|
+
if (!ownerDirectory(root)) return 'botconnector'
|
|
100
|
+
const path = join(root, 'active-provider')
|
|
101
|
+
if (!existsSync(path)) return 'botconnector'
|
|
102
|
+
const info = lstatSync(path)
|
|
103
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 128) throw Error('Unsafe provider selection file.')
|
|
104
|
+
const id = readFileSync(path, 'utf8').trim()
|
|
105
|
+
providerDefinition(id)
|
|
106
|
+
return id
|
|
107
|
+
}
|
|
108
|
+
// Model IDs are public selections, never credentials. Keep one per provider
|
|
109
|
+
// so a returning user goes directly to chat without repeating the model picker.
|
|
110
|
+
const MODEL_ID = /^[a-zA-Z0-9][a-zA-Z0-9._:/+-]{0,255}$/
|
|
111
|
+
export function selectedModel(id, { env = process.env, root = providerRoot({ env }) } = {}) {
|
|
112
|
+
providerDefinition(id)
|
|
113
|
+
if (!ownerDirectory(root)) return undefined
|
|
114
|
+
const path = join(root, 'active-model-' + id)
|
|
115
|
+
if (!existsSync(path)) return undefined
|
|
116
|
+
const info = lstatSync(path)
|
|
117
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 300) throw Error('Unsafe model selection file.')
|
|
118
|
+
const value = readFileSync(path, 'utf8').trim()
|
|
119
|
+
if (!MODEL_ID.test(value)) throw Error('Invalid saved model selection.')
|
|
120
|
+
return value
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function saveSelectedModel(id, model, { env = process.env, root = providerRoot({ env }) } = {}) {
|
|
124
|
+
providerDefinition(id)
|
|
125
|
+
if (!MODEL_ID.test(model)) throw Error('Invalid model ID.')
|
|
126
|
+
ownerDirectory(root, { create: true })
|
|
127
|
+
const path = join(root, 'active-model-' + id)
|
|
128
|
+
if (existsSync(path) && (!lstatSync(path).isFile() || lstatSync(path).isSymbolicLink())) {
|
|
129
|
+
throw Error('Unsafe model selection path.')
|
|
130
|
+
}
|
|
131
|
+
const tmp = path + '.' + randomBytes(8).toString('hex') + '.tmp'
|
|
132
|
+
try { writeFileSync(tmp, model + '\n', { flag: 'wx', encoding: 'utf8', mode: 0o600 }); renameSync(tmp, path) }
|
|
133
|
+
finally { rmSync(tmp, { force: true }) }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function useProvider(id, { env = process.env, root = providerRoot({env}) } = {}) {
|
|
137
|
+
providerDefinition(id)
|
|
138
|
+
if (!isConfigured(id, { env, root })) throw Error('Provider is not configured. Use: provider add ' + id + ' (or its API key environment variable).')
|
|
139
|
+
ownerDirectory(root, { create: true })
|
|
140
|
+
const path = join(root, 'active-provider')
|
|
141
|
+
if (existsSync(path) && (!lstatSync(path).isFile() || lstatSync(path).isSymbolicLink())) throw Error('Unsafe provider selection path.')
|
|
142
|
+
const tmp = path + '.' + randomBytes(8).toString('hex') + '.tmp'
|
|
143
|
+
try { writeFileSync(tmp, id + '\n', { flag: 'wx', encoding: 'utf8', mode: 0o600 }); renameSync(tmp, path) }
|
|
144
|
+
finally { rmSync(tmp, { force: true }) }
|
|
145
|
+
}
|
|
146
|
+
export function removeProvider(id, { env = process.env, root = providerRoot({env}) } = {}) {
|
|
147
|
+
providerDefinition(id)
|
|
148
|
+
if (!ownerDirectory(root)) return
|
|
149
|
+
const path = keyPath(root, id)
|
|
150
|
+
if (existsSync(path)) {
|
|
151
|
+
if (!lstatSync(path).isFile() || lstatSync(path).isSymbolicLink()) throw Error('Unsafe provider credential path.')
|
|
152
|
+
rmSync(path)
|
|
153
|
+
}
|
|
154
|
+
if (selectedProvider({env,root}) === id) {
|
|
155
|
+
const active = join(root, 'active-provider')
|
|
156
|
+
rmSync(active, {force:true})
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
export function resolveProvider(id, { env = process.env, root = providerRoot({env}) } = {}) {
|
|
160
|
+
const info = providerDefinition(id)
|
|
161
|
+
if (id === 'botconnector') {
|
|
162
|
+
// Saved keys take precedence; legacy credentials are read only as fallback.
|
|
163
|
+
try { return { id, ...info, key: storedProviderKey(id, { root }) } }
|
|
164
|
+
catch (error) {
|
|
165
|
+
if (error?.message !== 'Provider not configured. Run provider add.') throw error
|
|
166
|
+
return { id, ...info, key: botConnectorKey({ env }) }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// An explicit key saved through "provider add" must win over stale environment
|
|
170
|
+
// variables. Never silently fall back if the encrypted file is corrupted.
|
|
171
|
+
let key
|
|
172
|
+
try { key = storedProviderKey(id, {root}) }
|
|
173
|
+
catch (error) {
|
|
174
|
+
if (error?.message !== 'Provider not configured. Run provider add.') throw error
|
|
175
|
+
key = env[info.env]?.trim()
|
|
176
|
+
}
|
|
177
|
+
if (!key) throw Error('Provider not configured. Run provider add ' + id + ' or set ' + info.env + '.')
|
|
178
|
+
return { id, ...info, key }
|
|
179
|
+
}
|
package/proxy.mjs
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { createServer } from 'node:http'
|
|
3
|
+
import { GATEWAY } from './gateway.mjs'
|
|
4
|
+
import { ALLOWED_PROVIDER_ENDPOINTS } from './providers.mjs'
|
|
5
|
+
|
|
6
|
+
// This proxy lives only for one Qwen Code session. Real provider keys remain in
|
|
7
|
+
// the launcher process; MCP and bash subprocesses can see only a short-lived
|
|
8
|
+
// local bearer token. The proxy cannot be redirected to arbitrary hosts.
|
|
9
|
+
const ALLOWED_PATHS = new Set(['/v1/models', '/v1/chat/completions', '/v1/responses'])
|
|
10
|
+
const MAX_REQUEST_BYTES = 16 * 1024 * 1024
|
|
11
|
+
|
|
12
|
+
// Qwen 0.26.3 produces get_goal without parameters and read_file with
|
|
13
|
+
// optional JSON-schema type unions such as ["integer", "null"]. Strict
|
|
14
|
+
// OpenAI-compatible providers reject both formats while parsing tools.
|
|
15
|
+
// Normalize only the incompatible pieces, preserving all coding tools.
|
|
16
|
+
function normalizeOptionalNullableSchema(schema) {
|
|
17
|
+
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return schema
|
|
18
|
+
let out = schema
|
|
19
|
+
if (schema.properties && typeof schema.properties === 'object' && !Array.isArray(schema.properties)) {
|
|
20
|
+
const required = new Set(Array.isArray(schema.required) ? schema.required : [])
|
|
21
|
+
let properties = schema.properties
|
|
22
|
+
for (const [name, field] of Object.entries(schema.properties)) {
|
|
23
|
+
if (!field || typeof field !== 'object' || Array.isArray(field)) continue
|
|
24
|
+
let candidate = field
|
|
25
|
+
// The optional absence of a field already represents null; dropping
|
|
26
|
+
// "null" from this one optional JSON-Schema union is safe. Do not rewrite
|
|
27
|
+
// required nullable fields or multi-type unions (fail closed upstream).
|
|
28
|
+
if (!required.has(name) && Array.isArray(field.type) && field.type.length === 2
|
|
29
|
+
&& field.type.includes('null')) {
|
|
30
|
+
const primitive = field.type.find((type) => type !== 'null')
|
|
31
|
+
if (['string', 'number', 'integer', 'boolean', 'object', 'array'].includes(primitive)) {
|
|
32
|
+
candidate = { ...field, type: primitive }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
candidate = normalizeOptionalNullableSchema(candidate)
|
|
36
|
+
if (candidate !== field) {
|
|
37
|
+
if (properties === schema.properties) properties = { ...properties }
|
|
38
|
+
properties[name] = candidate
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (properties !== schema.properties) out = { ...out, properties }
|
|
42
|
+
}
|
|
43
|
+
if (schema.items && typeof schema.items === 'object' && !Array.isArray(schema.items)) {
|
|
44
|
+
const items = normalizeOptionalNullableSchema(schema.items)
|
|
45
|
+
if (items !== schema.items) out = { ...out, items }
|
|
46
|
+
}
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function normalizeZeroArgFunctionTools(requestBody) {
|
|
51
|
+
if (!requestBody || typeof requestBody !== 'object' || !Array.isArray(requestBody.tools)) return requestBody
|
|
52
|
+
let changed = false
|
|
53
|
+
const tools = requestBody.tools.map((tool) => {
|
|
54
|
+
const fn = tool?.type === 'function' ? tool.function : null
|
|
55
|
+
if (!fn || typeof fn.name !== 'string') return tool
|
|
56
|
+
const parameters = fn.parameters == null
|
|
57
|
+
? { type: 'object', properties: {} }
|
|
58
|
+
: normalizeOptionalNullableSchema(fn.parameters)
|
|
59
|
+
if (parameters === fn.parameters) return tool
|
|
60
|
+
changed = true
|
|
61
|
+
return { ...tool, function: { ...fn, parameters } }
|
|
62
|
+
})
|
|
63
|
+
return changed ? { ...requestBody, tools } : requestBody
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The model can be switched with /model without restarting the coding agent.
|
|
67
|
+
// Annotate each request with the ACTUAL model chosen by Qwen Config, rather
|
|
68
|
+
// than retaining a stale --append-system-prompt model from CLI startup.
|
|
69
|
+
export function attachRuntimeModelContext(payload) {
|
|
70
|
+
if (!payload || typeof payload !== 'object' || !Array.isArray(payload.messages)) return payload
|
|
71
|
+
const model = payload.model
|
|
72
|
+
if (typeof model !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._:/+-]{0,255}$/.test(model)) return payload
|
|
73
|
+
const index = payload.messages.findIndex(item => item?.role === 'system' && typeof item.content === 'string')
|
|
74
|
+
if (index === -1) return payload
|
|
75
|
+
const runtime = '\n\nRuntime metadata (not model instructions): active_model_id=' + model + '; client_app=BCCLI; transport=local-proxy.'
|
|
76
|
+
const messages = [...payload.messages]
|
|
77
|
+
messages[index] = { ...messages[index], content: messages[index].content + runtime }
|
|
78
|
+
return { ...payload, messages }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Qwen 0.26.3 sends max_tokens=64000 for many model IDs. Several Gateway
|
|
82
|
+
// upstream routes reject that value (e.g. GLM Flash). Bound the requested
|
|
83
|
+
// output conservatively for coding-agent interoperability; never increase it.
|
|
84
|
+
// This does not change the context window or input-token budget.
|
|
85
|
+
const DEFAULT_OUTPUT_TOKEN_CEILING = 4096
|
|
86
|
+
export function boundChatOutputTokens(payload) {
|
|
87
|
+
if (!payload || typeof payload !== 'object') return payload
|
|
88
|
+
const legacy = payload.max_tokens
|
|
89
|
+
const modern = payload.max_completion_tokens
|
|
90
|
+
const valid = (value) => Number.isInteger(value) && value > 0
|
|
91
|
+
|
|
92
|
+
// Real Gateway's exact Kimi route rejects legacy max_tokens. Keep parity
|
|
93
|
+
// with PAYG validation for positive decimal numeric strings, without
|
|
94
|
+
// changing the output-ceiling policy for any other model.
|
|
95
|
+
if (payload.model === 'kimi-k2.7-code') {
|
|
96
|
+
const parsed = (value) => {
|
|
97
|
+
if (typeof value !== 'number' && !(typeof value === 'string' && /^[0-9]+$/.test(value))) return null
|
|
98
|
+
const n = Number(value)
|
|
99
|
+
return Number.isSafeInteger(n) && n > 0 ? n : null
|
|
100
|
+
}
|
|
101
|
+
const legacyValue = parsed(legacy)
|
|
102
|
+
const modernValue = parsed(modern)
|
|
103
|
+
if (legacyValue != null) {
|
|
104
|
+
const requested = modernValue != null ? Math.min(legacyValue, modernValue) : legacyValue
|
|
105
|
+
const { max_tokens: _ignored, ...rest } = payload
|
|
106
|
+
return { ...rest, max_completion_tokens: Math.min(requested, DEFAULT_OUTPUT_TOKEN_CEILING) }
|
|
107
|
+
}
|
|
108
|
+
if (modernValue != null && (typeof modern !== 'number' || modernValue > DEFAULT_OUTPUT_TOKEN_CEILING)) {
|
|
109
|
+
return { ...payload, max_completion_tokens: Math.min(modernValue, DEFAULT_OUTPUT_TOKEN_CEILING) }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (valid(legacy) && legacy > DEFAULT_OUTPUT_TOKEN_CEILING) {
|
|
113
|
+
return { ...payload, max_tokens: DEFAULT_OUTPUT_TOKEN_CEILING }
|
|
114
|
+
}
|
|
115
|
+
if (valid(modern) && modern > DEFAULT_OUTPUT_TOKEN_CEILING) {
|
|
116
|
+
return { ...payload, max_completion_tokens: DEFAULT_OUTPUT_TOKEN_CEILING }
|
|
117
|
+
}
|
|
118
|
+
return payload
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function waitForDrainOrClose(res) {
|
|
122
|
+
if (res.destroyed) return Promise.resolve()
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
const done = () => {
|
|
125
|
+
res.off('drain', done)
|
|
126
|
+
res.off('close', done)
|
|
127
|
+
resolve()
|
|
128
|
+
}
|
|
129
|
+
res.once('drain', done)
|
|
130
|
+
res.once('close', done)
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function matchesToken(auth, token) {
|
|
135
|
+
if (typeof auth !== 'string') return false
|
|
136
|
+
const actual = Buffer.from(auth)
|
|
137
|
+
const expected = Buffer.from('Bearer ' + token)
|
|
138
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function startGatewayProxy({ key, endpoint = GATEWAY, token = randomBytes(32).toString('hex'), fetchImpl = fetch } = {}) {
|
|
142
|
+
if (!ALLOWED_PROVIDER_ENDPOINTS.includes(endpoint)) throw Error('Unapproved provider endpoint; local proxy refused to start.')
|
|
143
|
+
if (typeof key !== 'string' || !key.trim()) throw Error('Gateway key is required for local proxy.')
|
|
144
|
+
if (typeof token !== 'string' || token.length < 6 || token === key) throw Error('Invalid or non-isolated proxy token.')
|
|
145
|
+
|
|
146
|
+
const server = createServer(async (req, res) => {
|
|
147
|
+
const fail = (status, body) => {
|
|
148
|
+
if (res.headersSent || res.destroyed) return
|
|
149
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
|
|
150
|
+
res.end(JSON.stringify({ error: body }))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!matchesToken(req.headers.authorization, token)) {
|
|
154
|
+
fail(401, 'Local session token is required.')
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
if (!ALLOWED_PATHS.has(req.url ?? '')) {
|
|
158
|
+
fail(404, 'Unsupported local gateway route.')
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
const method = req.url === '/v1/models' ? 'GET' : 'POST'
|
|
162
|
+
if (req.method !== method) {
|
|
163
|
+
fail(405, 'Unsupported request method.')
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
if (req.headers['content-length'] && Number(req.headers['content-length']) > MAX_REQUEST_BYTES) {
|
|
167
|
+
fail(413, 'Request too large.')
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const abort = new AbortController()
|
|
172
|
+
const onClose = () => { if (!res.writableEnded) abort.abort() }
|
|
173
|
+
res.once('close', onClose)
|
|
174
|
+
try {
|
|
175
|
+
let body
|
|
176
|
+
if (method === 'POST') {
|
|
177
|
+
const buffers = []
|
|
178
|
+
let bytes = 0
|
|
179
|
+
for await (const chunk of req) {
|
|
180
|
+
bytes += chunk.length
|
|
181
|
+
if (bytes > MAX_REQUEST_BYTES) {
|
|
182
|
+
fail(413, 'Request too large.')
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
buffers.push(chunk)
|
|
186
|
+
}
|
|
187
|
+
body = Buffer.concat(buffers)
|
|
188
|
+
if (req.url === '/v1/chat/completions') {
|
|
189
|
+
let parsed
|
|
190
|
+
try { parsed = JSON.parse(body.toString('utf8')) }
|
|
191
|
+
catch { fail(400, 'Invalid JSON request.'); return }
|
|
192
|
+
const normalized = boundChatOutputTokens(attachRuntimeModelContext(normalizeZeroArgFunctionTools(parsed)))
|
|
193
|
+
if (normalized !== parsed) body = Buffer.from(JSON.stringify(normalized))
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const upstream = await fetchImpl(endpoint + req.url.slice(3), {
|
|
197
|
+
method,
|
|
198
|
+
headers: {
|
|
199
|
+
authorization: 'Bearer ' + key,
|
|
200
|
+
accept: 'application/json, text/event-stream',
|
|
201
|
+
...(method === 'POST' ? { 'content-type': 'application/json' } : {}),
|
|
202
|
+
},
|
|
203
|
+
body,
|
|
204
|
+
redirect: 'error',
|
|
205
|
+
signal: abort.signal,
|
|
206
|
+
})
|
|
207
|
+
res.writeHead(upstream.status, {
|
|
208
|
+
'content-type': upstream.headers.get('content-type') ?? 'application/json',
|
|
209
|
+
'cache-control': 'no-store',
|
|
210
|
+
})
|
|
211
|
+
if (upstream.body) {
|
|
212
|
+
for await (const chunk of upstream.body) {
|
|
213
|
+
if (res.destroyed) break
|
|
214
|
+
if (!res.write(chunk)) await waitForDrainOrClose(res)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
res.end()
|
|
218
|
+
} catch {
|
|
219
|
+
if (res.headersSent) res.destroy()
|
|
220
|
+
else fail(502, 'Provider connection failed.')
|
|
221
|
+
} finally {
|
|
222
|
+
res.off('close', onClose)
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
await new Promise((resolve, reject) => {
|
|
227
|
+
const onError = (error) => { server.off('listening', onListening); reject(error) }
|
|
228
|
+
const onListening = () => { server.off('error', onError); resolve() }
|
|
229
|
+
server.once('error', onError)
|
|
230
|
+
server.once('listening', onListening)
|
|
231
|
+
server.listen(0, '127.0.0.1')
|
|
232
|
+
})
|
|
233
|
+
const port = server.address()?.port
|
|
234
|
+
return {
|
|
235
|
+
token,
|
|
236
|
+
baseUrl: 'http://127.0.0.1:' + port + '/v1',
|
|
237
|
+
async close() {
|
|
238
|
+
server.closeAllConnections()
|
|
239
|
+
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
}
|