scalix 1.4.1 → 1.5.1
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/CHANGELOG.md +60 -0
- package/README.md +29 -10
- package/dist/agent-replay.js +2 -1
- package/dist/agent.js +319 -26
- package/dist/api-client.js +159 -26
- package/dist/cli.js +671 -252
- package/dist/cloud.js +94 -5
- package/dist/constants.js +96 -10
- package/dist/cost-tracker.js +43 -5
- package/dist/headless.js +44 -20
- package/dist/ipc-server.js +63 -12
- package/dist/keychain.js +46 -22
- package/dist/markdown.js +18 -1
- package/dist/providers/index.js +31 -0
- package/dist/providers/openai.js +149 -0
- package/dist/settings.js +117 -8
- package/dist/slash-commands.js +148 -0
- package/dist/sub-agent.js +1 -0
- package/package.json +13 -3
- package/PARITY-PLAN.md +0 -116
- package/docs/DISTRIBUTION.md +0 -301
package/dist/keychain.js
CHANGED
|
@@ -4,9 +4,30 @@ import fs from 'node:fs/promises';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import { USER_CONFIG_DIR } from './constants.js';
|
|
7
|
-
const
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
8
|
const SERVICE = 'scalix-coder';
|
|
9
9
|
const ACCOUNT = 'api-key';
|
|
10
|
+
/**
|
|
11
|
+
* Ceiling on any single keychain subprocess.
|
|
12
|
+
*
|
|
13
|
+
* `security` normally answers in ~20ms, but it is talking to the OS keychain
|
|
14
|
+
* daemon and that can stall: a locked keychain, or an "allow node to access
|
|
15
|
+
* this item?" dialog nobody is looking at, blocks the call until it is
|
|
16
|
+
* answered. Measured on a normal macOS dev machine, one saveConfig() spent
|
|
17
|
+
* 19.6s in here — and the setup wizard runs it on the final Enter with no
|
|
18
|
+
* spinner, so that reads as a dead keypress. Nothing here is worth waiting on
|
|
19
|
+
* for more than a few seconds; the callers all treat failure as "no keychain"
|
|
20
|
+
* and fall back to the config file.
|
|
21
|
+
*
|
|
22
|
+
* Generous rather than snappy on purpose: giving up early would push a working
|
|
23
|
+
* (just slow) keychain into that plaintext fallback, which is the worse of the
|
|
24
|
+
* two failures. This is a ceiling on a hang, not a latency target.
|
|
25
|
+
*/
|
|
26
|
+
const KEYCHAIN_TIMEOUT_MS = 20_000;
|
|
27
|
+
/** execFile with a hard timeout, so a stalled keychain can't hang the UI. */
|
|
28
|
+
function exec(file, args, opts = {}) {
|
|
29
|
+
return execFileAsync(file, args, { timeout: KEYCHAIN_TIMEOUT_MS, killSignal: 'SIGKILL', ...opts });
|
|
30
|
+
}
|
|
10
31
|
/**
|
|
11
32
|
* SCALIX_NO_KEYCHAIN=1 disables OS keychain integration entirely — the API
|
|
12
33
|
* key stays in the config file. Intended for CI, headless environments, and
|
|
@@ -23,11 +44,14 @@ async function isMacOS() {
|
|
|
23
44
|
return process.platform === 'darwin';
|
|
24
45
|
}
|
|
25
46
|
// ─── Windows DPAPI (user-scoped) via PowerShell ───
|
|
26
|
-
function windowsCredentialPath() {
|
|
47
|
+
function windowsCredentialPath(account = ACCOUNT) {
|
|
27
48
|
// Resolve the home directory the same way the rest of the codebase does
|
|
28
49
|
// (settings/sessions/workspace-trust) so the credential store stays
|
|
29
50
|
// consistent and honors HOME (set by Git Bash and by tests).
|
|
30
|
-
|
|
51
|
+
// The default slot keeps the legacy filename; per-provider slots get a
|
|
52
|
+
// suffixed file (credential-api-key-openai.dat).
|
|
53
|
+
const file = account === ACCOUNT ? 'credential.dat' : `credential-${account}.dat`;
|
|
54
|
+
return path.join(process.env.HOME || process.env.USERPROFILE || os.homedir() || '.', USER_CONFIG_DIR, file);
|
|
31
55
|
}
|
|
32
56
|
async function runPowerShell(script, stdinData) {
|
|
33
57
|
const psExe = process.env.SystemRoot
|
|
@@ -47,9 +71,9 @@ async function runPowerShell(script, stdinData) {
|
|
|
47
71
|
const { stdout } = await child;
|
|
48
72
|
return stdout;
|
|
49
73
|
}
|
|
50
|
-
async function setApiKeyWindows(apiKey) {
|
|
74
|
+
async function setApiKeyWindows(apiKey, account = ACCOUNT) {
|
|
51
75
|
try {
|
|
52
|
-
const credPath = windowsCredentialPath();
|
|
76
|
+
const credPath = windowsCredentialPath(account);
|
|
53
77
|
await fs.mkdir(path.dirname(credPath), { recursive: true });
|
|
54
78
|
// Read the secret from stdin, encrypt it with user-scoped DPAPI, and print
|
|
55
79
|
// the encrypted string. Reading from stdin avoids putting the secret on the
|
|
@@ -67,9 +91,9 @@ async function setApiKeyWindows(apiKey) {
|
|
|
67
91
|
return false;
|
|
68
92
|
}
|
|
69
93
|
}
|
|
70
|
-
async function getApiKeyWindows() {
|
|
94
|
+
async function getApiKeyWindows(account = ACCOUNT) {
|
|
71
95
|
try {
|
|
72
|
-
const credPath = windowsCredentialPath();
|
|
96
|
+
const credPath = windowsCredentialPath(account);
|
|
73
97
|
const encrypted = (await fs.readFile(credPath, 'utf-8')).trim();
|
|
74
98
|
if (!encrypted)
|
|
75
99
|
return null;
|
|
@@ -87,9 +111,9 @@ async function getApiKeyWindows() {
|
|
|
87
111
|
return null;
|
|
88
112
|
}
|
|
89
113
|
}
|
|
90
|
-
async function deleteApiKeyWindows() {
|
|
114
|
+
async function deleteApiKeyWindows(account = ACCOUNT) {
|
|
91
115
|
try {
|
|
92
|
-
await fs.unlink(windowsCredentialPath());
|
|
116
|
+
await fs.unlink(windowsCredentialPath(account));
|
|
93
117
|
return true;
|
|
94
118
|
}
|
|
95
119
|
catch {
|
|
@@ -107,25 +131,25 @@ async function isLinuxWithSecretTool() {
|
|
|
107
131
|
return false;
|
|
108
132
|
}
|
|
109
133
|
}
|
|
110
|
-
export async function getApiKeyFromKeychain() {
|
|
134
|
+
export async function getApiKeyFromKeychain(account = ACCOUNT) {
|
|
111
135
|
if (keychainDisabled())
|
|
112
136
|
return null;
|
|
113
137
|
try {
|
|
114
138
|
if (isWindows()) {
|
|
115
|
-
return await getApiKeyWindows();
|
|
139
|
+
return await getApiKeyWindows(account);
|
|
116
140
|
}
|
|
117
141
|
if (await isMacOS()) {
|
|
118
142
|
const { stdout } = await exec('security', [
|
|
119
143
|
'find-generic-password',
|
|
120
144
|
'-s', SERVICE,
|
|
121
|
-
'-a',
|
|
145
|
+
'-a', account,
|
|
122
146
|
'-w',
|
|
123
147
|
]);
|
|
124
148
|
return stdout.trim() || null;
|
|
125
149
|
}
|
|
126
150
|
if (await isLinuxWithSecretTool()) {
|
|
127
151
|
const { stdout } = await exec('secret-tool', [
|
|
128
|
-
'lookup', 'service', SERVICE, 'account',
|
|
152
|
+
'lookup', 'service', SERVICE, 'account', account,
|
|
129
153
|
]);
|
|
130
154
|
return stdout.trim() || null;
|
|
131
155
|
}
|
|
@@ -135,12 +159,12 @@ export async function getApiKeyFromKeychain() {
|
|
|
135
159
|
}
|
|
136
160
|
return null;
|
|
137
161
|
}
|
|
138
|
-
export async function setApiKeyInKeychain(apiKey) {
|
|
162
|
+
export async function setApiKeyInKeychain(apiKey, account = ACCOUNT) {
|
|
139
163
|
if (keychainDisabled())
|
|
140
164
|
return false;
|
|
141
165
|
try {
|
|
142
166
|
if (isWindows()) {
|
|
143
|
-
return await setApiKeyWindows(apiKey);
|
|
167
|
+
return await setApiKeyWindows(apiKey, account);
|
|
144
168
|
}
|
|
145
169
|
if (await isMacOS()) {
|
|
146
170
|
// Delete existing entry first (ignore errors if it doesn't exist)
|
|
@@ -148,7 +172,7 @@ export async function setApiKeyInKeychain(apiKey) {
|
|
|
148
172
|
await exec('security', [
|
|
149
173
|
'delete-generic-password',
|
|
150
174
|
'-s', SERVICE,
|
|
151
|
-
'-a',
|
|
175
|
+
'-a', account,
|
|
152
176
|
]);
|
|
153
177
|
}
|
|
154
178
|
catch {
|
|
@@ -157,7 +181,7 @@ export async function setApiKeyInKeychain(apiKey) {
|
|
|
157
181
|
await exec('security', [
|
|
158
182
|
'add-generic-password',
|
|
159
183
|
'-s', SERVICE,
|
|
160
|
-
'-a',
|
|
184
|
+
'-a', account,
|
|
161
185
|
'-w', apiKey,
|
|
162
186
|
'-U',
|
|
163
187
|
]);
|
|
@@ -168,7 +192,7 @@ export async function setApiKeyInKeychain(apiKey) {
|
|
|
168
192
|
'store',
|
|
169
193
|
'--label', 'Scalix Coder API Key',
|
|
170
194
|
'service', SERVICE,
|
|
171
|
-
'account',
|
|
195
|
+
'account', account,
|
|
172
196
|
]);
|
|
173
197
|
// secret-tool reads the secret from stdin. If stdin isn't available we
|
|
174
198
|
// cannot deliver the secret — treat that as a failure rather than
|
|
@@ -188,24 +212,24 @@ export async function setApiKeyInKeychain(apiKey) {
|
|
|
188
212
|
}
|
|
189
213
|
return false;
|
|
190
214
|
}
|
|
191
|
-
export async function deleteApiKeyFromKeychain() {
|
|
215
|
+
export async function deleteApiKeyFromKeychain(account = ACCOUNT) {
|
|
192
216
|
if (keychainDisabled())
|
|
193
217
|
return false;
|
|
194
218
|
try {
|
|
195
219
|
if (isWindows()) {
|
|
196
|
-
return await deleteApiKeyWindows();
|
|
220
|
+
return await deleteApiKeyWindows(account);
|
|
197
221
|
}
|
|
198
222
|
if (await isMacOS()) {
|
|
199
223
|
await exec('security', [
|
|
200
224
|
'delete-generic-password',
|
|
201
225
|
'-s', SERVICE,
|
|
202
|
-
'-a',
|
|
226
|
+
'-a', account,
|
|
203
227
|
]);
|
|
204
228
|
return true;
|
|
205
229
|
}
|
|
206
230
|
if (await isLinuxWithSecretTool()) {
|
|
207
231
|
await exec('secret-tool', [
|
|
208
|
-
'clear', 'service', SERVICE, 'account',
|
|
232
|
+
'clear', 'service', SERVICE, 'account', account,
|
|
209
233
|
]);
|
|
210
234
|
return true;
|
|
211
235
|
}
|
package/dist/markdown.js
CHANGED
|
@@ -48,10 +48,27 @@ function createMarked(width) {
|
|
|
48
48
|
blockquote: chalk.gray.italic,
|
|
49
49
|
del: chalk.dim.strikethrough,
|
|
50
50
|
width,
|
|
51
|
-
|
|
51
|
+
// The prefix re-prints the literal '##' in front of every heading, so a
|
|
52
|
+
// reply that opened with "## Result" reached the screen still wearing its
|
|
53
|
+
// markdown. chalk.green.bold already marks a heading as a heading.
|
|
54
|
+
showSectionPrefix: false,
|
|
52
55
|
reflowText: false,
|
|
53
56
|
tab: 2,
|
|
54
57
|
});
|
|
58
|
+
// marked-terminal 7.3 renders a `text` token as token.text — the raw
|
|
59
|
+
// markdown source — and never descends into token.tokens. Under marked v15
|
|
60
|
+
// every tight list item arrives as exactly that token, so "1. a **bold**
|
|
61
|
+
// point" printed its asterisks while the same emphasis in a paragraph
|
|
62
|
+
// styled correctly. Parse the children when there are any; leaf text tokens
|
|
63
|
+
// (the ones parseInline hands back) still go to marked-terminal, so there
|
|
64
|
+
// is no recursion here.
|
|
65
|
+
const renderText = mt.renderer.text;
|
|
66
|
+
mt.renderer.text = function (token) {
|
|
67
|
+
if (token && typeof token === 'object' && Array.isArray(token.tokens) && token.tokens.length > 0) {
|
|
68
|
+
return this.parser.parseInline(token.tokens);
|
|
69
|
+
}
|
|
70
|
+
return renderText.call(this, token);
|
|
71
|
+
};
|
|
55
72
|
m.use(mt);
|
|
56
73
|
return m;
|
|
57
74
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Provider layer — BYOK support per MULTI-PROVIDER-ADAPTER-SPEC.md.
|
|
2
|
+
//
|
|
3
|
+
// Scalix is the zero-config default; every other provider is opt-in with the
|
|
4
|
+
// user's own endpoint + key (no hardcoded third-party defaults anywhere).
|
|
5
|
+
// The agent consumes a client (ScalixClient-shaped) plus a capability set that
|
|
6
|
+
// scopes the Scalix-platform workarounds to the scalix provider only.
|
|
7
|
+
import { ScalixClient } from '../api-client.js';
|
|
8
|
+
import { OpenAICompatClient } from './openai.js';
|
|
9
|
+
export const PROVIDER_IDS = ['scalix', 'openai'];
|
|
10
|
+
export function isProviderId(v) {
|
|
11
|
+
return typeof v === 'string' && PROVIDER_IDS.includes(v);
|
|
12
|
+
}
|
|
13
|
+
const CAPABILITIES = {
|
|
14
|
+
scalix: { streamingToolCalls: false, maxTokensPolicy: 'clamped', platformTools: true },
|
|
15
|
+
openai: { streamingToolCalls: true, maxTokensPolicy: 'explicit-only', platformTools: false },
|
|
16
|
+
};
|
|
17
|
+
export function capabilitiesFor(provider) {
|
|
18
|
+
return CAPABILITIES[provider];
|
|
19
|
+
}
|
|
20
|
+
/** Build the chat client for a provider. baseUrl semantics differ:
|
|
21
|
+
* - scalix: API host root (gateway nests /v1/ai) — DEFAULT_BASE_URL applies upstream;
|
|
22
|
+
* - openai: the endpoint root of any OpenAI-compatible server, with or
|
|
23
|
+
* without its /v1 suffix (api.openai.com/v1, openrouter.ai/api/v1,
|
|
24
|
+
* localhost:11434, ...). Required — there is no default third-party URL. */
|
|
25
|
+
export function createChatClient(provider, baseUrl, apiKey) {
|
|
26
|
+
if (provider === 'openai') {
|
|
27
|
+
return new OpenAICompatClient(baseUrl, apiKey);
|
|
28
|
+
}
|
|
29
|
+
return new ScalixClient(baseUrl, apiKey);
|
|
30
|
+
}
|
|
31
|
+
export { OpenAICompatClient, openaiEndpointRoot } from './openai.js';
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// OpenAI-compatible endpoint adapter (BYOK).
|
|
2
|
+
//
|
|
3
|
+
// Speaks the standard OpenAI wire — the same request/response dialect the
|
|
4
|
+
// agent already produces/consumes — against any server that hosts it:
|
|
5
|
+
// OpenAI, OpenRouter, Ollama, LM Studio, Together, vLLM, LiteLLM, ...
|
|
6
|
+
// The only differences from the Scalix client are URL composition
|
|
7
|
+
// ({root}/chat/completions instead of the gateway's /v1/ai nest) and
|
|
8
|
+
// dropping the Scalix-only `thinking_level` field, which strict servers
|
|
9
|
+
// (OpenAI itself) reject as an unrecognized argument.
|
|
10
|
+
import { ScalixClient } from '../api-client.js';
|
|
11
|
+
import { WEB_DEFAULTS } from '../constants.js';
|
|
12
|
+
/**
|
|
13
|
+
* Normalize a user-supplied OpenAI-compatible base URL to the endpoint root
|
|
14
|
+
* that `/chat/completions` and `/models` append to. Accepts both conventions:
|
|
15
|
+
* https://api.openai.com/v1 → https://api.openai.com/v1
|
|
16
|
+
* https://openrouter.ai/api/v1 → https://openrouter.ai/api/v1
|
|
17
|
+
* http://localhost:11434 → http://localhost:11434/v1
|
|
18
|
+
* http://localhost:1234/ → http://localhost:1234/v1
|
|
19
|
+
* A versioned path segment anywhere (v1, v2, ...) is treated as already-rooted.
|
|
20
|
+
*/
|
|
21
|
+
export function openaiEndpointRoot(baseUrl) {
|
|
22
|
+
const trimmed = baseUrl.replace(/\/+$/, '');
|
|
23
|
+
try {
|
|
24
|
+
const u = new URL(trimmed);
|
|
25
|
+
if (/\/v\d+($|\/)/.test(u.pathname))
|
|
26
|
+
return trimmed;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// Not parseable as a URL — leave composition to fetch to surface the error.
|
|
30
|
+
return trimmed;
|
|
31
|
+
}
|
|
32
|
+
return `${trimmed}/v1`;
|
|
33
|
+
}
|
|
34
|
+
export class OpenAICompatClient extends ScalixClient {
|
|
35
|
+
chatUrl() {
|
|
36
|
+
return `${openaiEndpointRoot(this.baseURL)}/chat/completions`;
|
|
37
|
+
}
|
|
38
|
+
modelsUrl() {
|
|
39
|
+
return openaiModelsUrl(this.baseURL);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The Scalix output ceiling is a property of the Scalix platform, so it must
|
|
43
|
+
* not be imposed on — or inferred from — someone else's endpoint. The agent
|
|
44
|
+
* already sends BYOK a max_tokens derived from the user's own model spec (or
|
|
45
|
+
* none at all), and a third-party server that rejects that value is telling
|
|
46
|
+
* the user their configuration is wrong: surface it instead of quietly
|
|
47
|
+
* retrying at a Scalix-shaped number that means nothing here.
|
|
48
|
+
*/
|
|
49
|
+
canLowerMaxTokens() {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
// Deliberately does NOT delegate to super: the base implementation clamps
|
|
53
|
+
// max_tokens to the Scalix ceiling, which is not this endpoint's business.
|
|
54
|
+
prepareRequest(req) {
|
|
55
|
+
let out = req;
|
|
56
|
+
// `thinking_level` is a Scalix extension; strict OpenAI-compatible servers
|
|
57
|
+
// 400 on unrecognized arguments. Reasoning output isn't consumed by the
|
|
58
|
+
// agent today, so dropping it is lossless (spec §3.8).
|
|
59
|
+
if (out.thinking_level !== undefined) {
|
|
60
|
+
const { thinking_level: _drop, ...rest } = out;
|
|
61
|
+
out = rest;
|
|
62
|
+
}
|
|
63
|
+
// An OpenAI-compatible server sends a usage chunk on a stream only when it
|
|
64
|
+
// is asked for. Without this a whole BYOK session streams with no token
|
|
65
|
+
// figures at all: the status bar, /cost and the context meter all stay on
|
|
66
|
+
// zero, and the context meter is what tells the user compression is near.
|
|
67
|
+
// (The Scalix gateway emits a final usage chunk unconditionally, so its
|
|
68
|
+
// own client has no need of the field.) If the endpoint turns out not to
|
|
69
|
+
// know `stream_options`, the base client drops it after the first 400 and
|
|
70
|
+
// sets this flag.
|
|
71
|
+
if (out.stream && out.stream_options === undefined && !this.streamUsageUnsupported) {
|
|
72
|
+
out = { ...out, stream_options: { include_usage: true } };
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** The /models URL for an OpenAI-compatible endpoint (OpenAI list shape). */
|
|
78
|
+
export function openaiModelsUrl(baseUrl) {
|
|
79
|
+
return `${openaiEndpointRoot(baseUrl)}/models`;
|
|
80
|
+
}
|
|
81
|
+
// Everyone agrees on `id` and nobody agrees on the context window: OpenRouter
|
|
82
|
+
// sends context_length, vLLM max_model_len, LM Studio max_context_length,
|
|
83
|
+
// Scalix context_window, and OpenAI itself sends none at all. Read whichever
|
|
84
|
+
// is there; a model that reports nothing keeps contextWindow undefined rather
|
|
85
|
+
// than inheriting someone else's number.
|
|
86
|
+
const CONTEXT_WINDOW_KEYS = ['context_length', 'max_model_len', 'max_context_length', 'context_window'];
|
|
87
|
+
function reportedContextWindow(model) {
|
|
88
|
+
const top = model.top_provider;
|
|
89
|
+
for (const source of [model, top]) {
|
|
90
|
+
if (!source)
|
|
91
|
+
continue;
|
|
92
|
+
for (const key of CONTEXT_WINDOW_KEYS) {
|
|
93
|
+
const v = source[key];
|
|
94
|
+
if (typeof v === 'number' && Number.isFinite(v) && v > 0)
|
|
95
|
+
return Math.floor(v);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* List the models a BYOK endpoint actually serves.
|
|
102
|
+
*
|
|
103
|
+
* Without this the setup wizard can only tell the user to go read
|
|
104
|
+
* `GET /v1/models` themselves and type the ids back in, and one typo produces a
|
|
105
|
+
* 404 on the first turn with nothing to suggest the id was the problem.
|
|
106
|
+
*
|
|
107
|
+
* Deliberately does not touch the model-spec registry: on the Scalix path a
|
|
108
|
+
* reported spec is the platform's own number, but a third-party endpoint that
|
|
109
|
+
* reports a context window and no output ceiling would have one invented for
|
|
110
|
+
* it here, and inventing a ceiling for someone else's model is how you get
|
|
111
|
+
* rejected requests. The window is returned for the wizard to show and is
|
|
112
|
+
* never combined with a default.
|
|
113
|
+
*
|
|
114
|
+
* Returns [] for anything that isn't a usable answer — this only ever feeds a
|
|
115
|
+
* picker that the user can bypass by typing an id.
|
|
116
|
+
*/
|
|
117
|
+
export async function fetchByokModels(baseUrl, apiKey) {
|
|
118
|
+
if (!baseUrl)
|
|
119
|
+
return [];
|
|
120
|
+
try {
|
|
121
|
+
const headers = {};
|
|
122
|
+
// Ollama and LM Studio serve /models unauthenticated; sending an empty
|
|
123
|
+
// bearer to an endpoint that does check is a 401 for no reason.
|
|
124
|
+
if (apiKey)
|
|
125
|
+
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
126
|
+
const res = await fetch(openaiModelsUrl(baseUrl), {
|
|
127
|
+
headers,
|
|
128
|
+
signal: AbortSignal.timeout(WEB_DEFAULTS.apiFetchTimeoutMs),
|
|
129
|
+
});
|
|
130
|
+
if (!res.ok)
|
|
131
|
+
return [];
|
|
132
|
+
const body = await res.json();
|
|
133
|
+
if (!Array.isArray(body.data))
|
|
134
|
+
return [];
|
|
135
|
+
const out = [];
|
|
136
|
+
const seen = new Set();
|
|
137
|
+
for (const entry of body.data) {
|
|
138
|
+
const m = entry;
|
|
139
|
+
if (typeof m?.id !== 'string' || !m.id || seen.has(m.id))
|
|
140
|
+
continue;
|
|
141
|
+
seen.add(m.id);
|
|
142
|
+
out.push({ id: m.id, contextWindow: reportedContextWindow(m) });
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
}
|
package/dist/settings.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { setSecurityConfig, setScopedPermissionRules } from './sandbox.js';
|
|
4
|
-
import { DEFAULT_BASE_URL, DEFAULT_MODELS, AGENT_DEFAULTS, CONTEXT_DEFAULTS, WEB_DEFAULTS, TOOL_DEFAULTS, SUB_AGENT_DEFAULTS, MULTI_AGENT_DEFAULTS, USER_CONFIG_DIR, registerUserModels, setNetworkSecurityOverrides, } from './constants.js';
|
|
4
|
+
import { DEFAULT_BASE_URL, DEFAULT_MODELS, AGENT_DEFAULTS, CONTEXT_DEFAULTS, WEB_DEFAULTS, TOOL_DEFAULTS, SUB_AGENT_DEFAULTS, MULTI_AGENT_DEFAULTS, USER_CONFIG_DIR, registerUserModels, setNetworkSecurityOverrides, normalizeModelId, } from './constants.js';
|
|
5
5
|
import { getApiKeyFromKeychain, setApiKeyInKeychain, isKeychainAvailable } from './keychain.js';
|
|
6
|
+
import { isProviderId } from './providers/index.js';
|
|
6
7
|
import { isWorkspaceTrusted } from './workspace-trust.js';
|
|
7
8
|
function getConfigDir() {
|
|
8
9
|
return path.join(process.env.HOME || process.env.USERPROFILE || '.', USER_CONFIG_DIR);
|
|
@@ -77,7 +78,7 @@ function validateConfig(parsed) {
|
|
|
77
78
|
if (typeof parsed.baseUrl === 'string' && parsed.baseUrl.length > 0)
|
|
78
79
|
safe.baseUrl = parsed.baseUrl;
|
|
79
80
|
if (Array.isArray(parsed.models) && parsed.models.every((m) => typeof m === 'string')) {
|
|
80
|
-
safe.models = parsed.models;
|
|
81
|
+
safe.models = parsed.models.map(normalizeModelId);
|
|
81
82
|
}
|
|
82
83
|
if (typeof parsed.permissionMode === 'string' && ['plan', 'default', 'auto', 'yolo'].includes(parsed.permissionMode)) {
|
|
83
84
|
safe.permissionMode = parsed.permissionMode;
|
|
@@ -90,6 +91,10 @@ function validateConfig(parsed) {
|
|
|
90
91
|
typeof s.contextWindow === 'number' && s.contextWindow > 0 &&
|
|
91
92
|
typeof s.maxOutputTokens === 'number' && s.maxOutputTokens > 0) {
|
|
92
93
|
validated[id] = { contextWindow: s.contextWindow, maxOutputTokens: s.maxOutputTokens };
|
|
94
|
+
// Keep the vision flag — dropping it made every user-declared vision
|
|
95
|
+
// model report supportsVision === false and silently strip images.
|
|
96
|
+
if (s.supportsVision === true)
|
|
97
|
+
validated[id].supportsVision = true;
|
|
93
98
|
}
|
|
94
99
|
}
|
|
95
100
|
if (Object.keys(validated).length > 0) {
|
|
@@ -160,6 +165,23 @@ function validateConfig(parsed) {
|
|
|
160
165
|
if (typeof parsed.disableUpdateCheck === 'boolean') {
|
|
161
166
|
safe.disableUpdateCheck = parsed.disableUpdateCheck;
|
|
162
167
|
}
|
|
168
|
+
// BYOK (validateConfig drops unknown keys, so these MUST be whitelisted here
|
|
169
|
+
// or they vanish on the next saveConfig — spec §4 migration gotcha #1).
|
|
170
|
+
if (isProviderId(parsed.provider)) {
|
|
171
|
+
safe.provider = parsed.provider;
|
|
172
|
+
}
|
|
173
|
+
if (parsed.providers && typeof parsed.providers === 'object' && !Array.isArray(parsed.providers)) {
|
|
174
|
+
const po = parsed.providers.openai;
|
|
175
|
+
if (po && typeof po === 'object' && !Array.isArray(po)) {
|
|
176
|
+
const creds = {};
|
|
177
|
+
if (typeof po.apiKey === 'string')
|
|
178
|
+
creds.apiKey = po.apiKey;
|
|
179
|
+
if (typeof po.baseUrl === 'string' && po.baseUrl.length > 0)
|
|
180
|
+
creds.baseUrl = po.baseUrl;
|
|
181
|
+
if (Object.keys(creds).length > 0)
|
|
182
|
+
safe.providers = { openai: creds };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
163
185
|
return safe;
|
|
164
186
|
}
|
|
165
187
|
function isStringArray(val) {
|
|
@@ -339,6 +361,12 @@ export async function loadConfig() {
|
|
|
339
361
|
if (keychainKey)
|
|
340
362
|
config.apiKey = keychainKey;
|
|
341
363
|
}
|
|
364
|
+
// Per-provider keychain slot (BYOK): openai key lives under its own account.
|
|
365
|
+
if (config.providers?.openai && !config.providers.openai.apiKey) {
|
|
366
|
+
const k = await getApiKeyFromKeychain(OPENAI_KEYCHAIN_ACCOUNT).catch(() => null);
|
|
367
|
+
if (k)
|
|
368
|
+
config.providers.openai.apiKey = k;
|
|
369
|
+
}
|
|
342
370
|
// Apply local overrides (project-level, gitignored)
|
|
343
371
|
config = await loadLocalOverrides(config);
|
|
344
372
|
// Apply managed/organization settings (enterprise policy)
|
|
@@ -363,18 +391,99 @@ export async function loadConfig() {
|
|
|
363
391
|
return config;
|
|
364
392
|
}
|
|
365
393
|
}
|
|
394
|
+
/**
|
|
395
|
+
* The GLOBAL config file only — no project-local settings.local.json, no
|
|
396
|
+
* managed-policy merge, no keychain resolution. This is the ONLY correct base
|
|
397
|
+
* for a read-modify-write persistence cycle: round-tripping loadConfig()
|
|
398
|
+
* (which merges overrides) through saveConfig() laundered project-local
|
|
399
|
+
* models/limits/customModels into ~/.scalix-coder/config.json permanently.
|
|
400
|
+
*/
|
|
401
|
+
export async function loadGlobalConfig() {
|
|
402
|
+
try {
|
|
403
|
+
const raw = await fs.readFile(getConfigFile(), 'utf-8');
|
|
404
|
+
return { ...DEFAULTS, ...validateConfig(JSON.parse(raw)) };
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
return { ...DEFAULTS };
|
|
408
|
+
}
|
|
409
|
+
}
|
|
366
410
|
export async function saveConfig(config) {
|
|
367
411
|
await ensureDir();
|
|
368
|
-
// Store API
|
|
412
|
+
// Store API keys in keychain if available, strip from config file. The
|
|
413
|
+
// per-provider secret gets the same blanking treatment as the top-level key
|
|
414
|
+
// (spec §4: new creds must never persist plaintext when a keychain exists).
|
|
369
415
|
const configToWrite = { ...config };
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
if (
|
|
373
|
-
|
|
416
|
+
let plaintextSecret = false;
|
|
417
|
+
if (await isKeychainAvailable()) {
|
|
418
|
+
if (config.apiKey) {
|
|
419
|
+
const stored = await setApiKeyInKeychain(config.apiKey).catch(() => false);
|
|
420
|
+
if (stored)
|
|
421
|
+
configToWrite.apiKey = '';
|
|
422
|
+
else
|
|
423
|
+
plaintextSecret = true;
|
|
424
|
+
}
|
|
425
|
+
if (config.providers?.openai?.apiKey) {
|
|
426
|
+
const stored = await setApiKeyInKeychain(config.providers.openai.apiKey, OPENAI_KEYCHAIN_ACCOUNT).catch(() => false);
|
|
427
|
+
if (stored) {
|
|
428
|
+
configToWrite.providers = {
|
|
429
|
+
...configToWrite.providers,
|
|
430
|
+
openai: { ...configToWrite.providers.openai, apiKey: '' },
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
plaintextSecret = true;
|
|
435
|
+
}
|
|
374
436
|
}
|
|
375
437
|
}
|
|
376
438
|
await fs.writeFile(getConfigFile(), JSON.stringify(configToWrite, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
439
|
+
return { plaintextSecret };
|
|
377
440
|
}
|
|
378
441
|
export function isConfigured(config) {
|
|
379
|
-
|
|
442
|
+
const active = resolveActiveProvider(config);
|
|
443
|
+
if (active.provider === 'scalix') {
|
|
444
|
+
return config.apiKey.length > 0 && config.models.length > 0;
|
|
445
|
+
}
|
|
446
|
+
return active.apiKey.length > 0 && active.baseUrl.length > 0 && config.models.length > 0;
|
|
447
|
+
}
|
|
448
|
+
// ─── BYOK provider resolution ───
|
|
449
|
+
export const OPENAI_KEYCHAIN_ACCOUNT = 'api-key-openai';
|
|
450
|
+
/**
|
|
451
|
+
* Resolve which provider + credentials this session runs on.
|
|
452
|
+
*
|
|
453
|
+
* Precedence (mirrors the rest of config): env → config. `SCALIX_PROVIDER`
|
|
454
|
+
* selects the provider; for `openai`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`
|
|
455
|
+
* override `providers.openai`. Scalix stays the zero-config default and its
|
|
456
|
+
* path is byte-identical to the pre-BYOK behavior. There is NO default
|
|
457
|
+
* third-party URL — an openai selection without a baseUrl resolves with an
|
|
458
|
+
* empty one and the caller surfaces the configuration error.
|
|
459
|
+
*
|
|
460
|
+
* Scalix Cloud credentials resolve separately so BYOK sessions keep the
|
|
461
|
+
* platform tools: under `openai` they come from `SCALIX_CLOUD_API_KEY` →
|
|
462
|
+
* `SCALIX_API_KEY` → the top-level `config.apiKey`, and are simply empty when
|
|
463
|
+
* the user never added one.
|
|
464
|
+
*/
|
|
465
|
+
export function resolveActiveProvider(config, env = process.env) {
|
|
466
|
+
const envProvider = env.SCALIX_PROVIDER?.trim().toLowerCase();
|
|
467
|
+
const provider = isProviderId(envProvider)
|
|
468
|
+
? envProvider
|
|
469
|
+
: (config.provider ?? 'scalix');
|
|
470
|
+
const scalixBaseUrl = env.SCALIX_BASE_URL?.trim() || config.baseUrl;
|
|
471
|
+
const scalixApiKey = env.SCALIX_API_KEY?.trim() || config.apiKey;
|
|
472
|
+
if (provider === 'openai') {
|
|
473
|
+
const creds = config.providers?.openai ?? {};
|
|
474
|
+
return {
|
|
475
|
+
provider,
|
|
476
|
+
baseUrl: env.OPENAI_BASE_URL?.trim() || creds.baseUrl || '',
|
|
477
|
+
apiKey: env.OPENAI_API_KEY?.trim() || creds.apiKey || '',
|
|
478
|
+
cloudBaseUrl: env.SCALIX_CLOUD_BASE_URL?.trim() || scalixBaseUrl,
|
|
479
|
+
cloudApiKey: env.SCALIX_CLOUD_API_KEY?.trim() || scalixApiKey || '',
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
provider: 'scalix',
|
|
484
|
+
baseUrl: scalixBaseUrl,
|
|
485
|
+
apiKey: scalixApiKey,
|
|
486
|
+
cloudBaseUrl: scalixBaseUrl,
|
|
487
|
+
cloudApiKey: scalixApiKey,
|
|
488
|
+
};
|
|
380
489
|
}
|