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/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 exec = promisify(execFile);
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
- return path.join(process.env.HOME || process.env.USERPROFILE || os.homedir() || '.', USER_CONFIG_DIR, 'credential.dat');
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', ACCOUNT,
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', 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', ACCOUNT,
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', ACCOUNT,
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', 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', ACCOUNT,
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', 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
- showSectionPrefix: true,
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 key in keychain if available, strip from config file
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
- if (config.apiKey && await isKeychainAvailable()) {
371
- const stored = await setApiKeyInKeychain(config.apiKey).catch(() => false);
372
- if (stored) {
373
- configToWrite.apiKey = '';
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
- return config.apiKey.length > 0 && config.models.length > 0;
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
  }