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/cloud.js CHANGED
@@ -18,8 +18,12 @@ import { apiHostRoot, USER_AGENT, WEB_DEFAULTS } from './constants.js';
18
18
  export const CONSOLE_KEYS_URL = 'https://console.scalix.world/org/keys';
19
19
  export const CLOUD_DEFAULTS = {
20
20
  fetchTimeoutMs: WEB_DEFAULTS.apiFetchTimeoutMs,
21
- // Platform operations (deploys, migrations) can legitimately take a while.
22
- callTimeoutMs: 120_000,
21
+ // The platform's own transport kills a tool call at ~30-35s (gateway →
22
+ // mcp-server hop, then the inner REST callback). 45s covers that ceiling
23
+ // plus jitter; anything longer is waiting on a call the server already
24
+ // abandoned. Long operations go through async pairs (build_create →
25
+ // build_status) — see buildCloudToolsPromptSection.
26
+ callTimeoutMs: 45_000,
23
27
  };
24
28
  export class CloudError extends Error {
25
29
  status;
@@ -51,7 +55,7 @@ export function errorFromEnvelope(status, statusText, bodyText) {
51
55
  }
52
56
  catch { }
53
57
  if (status === 401) {
54
- return new CloudError(`API key rejected — create one at ${CONSOLE_KEYS_URL}`, {
58
+ return new CloudError(`API key rejected — create a project-scoped key (all scopes is simplest) at ${CONSOLE_KEYS_URL}`, {
55
59
  status,
56
60
  code: typeof envelope?.code === 'string' ? envelope.code : undefined,
57
61
  docsUrl: typeof envelope?.docs_url === 'string' ? envelope.docs_url : undefined,
@@ -182,8 +186,11 @@ export async function fetchMe(baseUrl, apiKey) {
182
186
  return parseMeResponse(await res.json());
183
187
  }
184
188
  /**
185
- * Fetch the account's MCP tool catalog from `/api/v1/mcp`. The catalog is
186
- * scope-filtered server-side for the caller's key. Throws CloudError on failure.
189
+ * Fetch the account's MCP tool catalog from `/api/v1/mcp`. The gateway
190
+ * advertises the full platform tool set to every authenticated key — only the
191
+ * gateway-native DB tools are scope-filtered server-side — so callers that
192
+ * know the key's scopes should narrow the result with filterCatalogByScopes()
193
+ * before registering. Throws CloudError on failure.
187
194
  */
188
195
  export async function fetchCloudTools(baseUrl, apiKey, projectId) {
189
196
  const url = `${apiHostRoot(baseUrl)}/api/v1/mcp`;
@@ -219,6 +226,68 @@ export async function callCloudTool(baseUrl, apiKey, name, args, projectId) {
219
226
  }
220
227
  return parseToolCallResult(await res.json());
221
228
  }
229
+ /**
230
+ * One-line, user-actionable fix for the two key-shape failures the platform
231
+ * returns on the MCP routes: 400 = org-wide key (platform tools require a
232
+ * project-scoped key), 403 = missing the database scope (database:query is
233
+ * the one that unlocks tools; the default all-scopes project key covers it).
234
+ * Anything else gets no remedy — the server's own error text stands alone.
235
+ */
236
+ export function cloudKeyRemedy(err) {
237
+ if (err?.status === 400) {
238
+ return `This looks like an org-wide key — platform tools need a project-scoped key (all scopes, or at least database:query). Create one at ${CONSOLE_KEYS_URL}`;
239
+ }
240
+ if (err?.status === 403) {
241
+ return `This key is missing the database scope — create a project key with database:query (or all scopes) at ${CONSOLE_KEYS_URL}`;
242
+ }
243
+ return undefined;
244
+ }
245
+ // Tool-name prefix → API-key scope family (the part before ':'). Mirrors the
246
+ // gateway's route enforcement, NOT /v1/me's per-tool list: that list drifts
247
+ // from enforcement in places (run_scale/run_rollback, compute_delete), so a
248
+ // per-tool intersection would hide tools a key can actually call. Family
249
+ // granularity only drops whole areas the key holds no scope for at all.
250
+ const TOOL_SCOPE_FAMILIES = [
251
+ [/^scalix_db_/, 'database'],
252
+ [/^scalix_run_/, 'services'],
253
+ [/^scalix_(compute|domain|build)_/, 'compute'],
254
+ [/^scalix_storage_/, 'storage'],
255
+ [/^scalix_kv_/, 'kv'],
256
+ [/^scalix_fn_/, 'functions'],
257
+ [/^scalix_events_/, 'events'],
258
+ [/^scalix_cron_/, 'cron'],
259
+ [/^scalix_(computer_|sandbox_run$)/, 'sandbox'],
260
+ [/^scalix_ai_/, 'ai'],
261
+ [/^scalix_search$/, 'search'],
262
+ [/^scalix_usage$/, 'metering'],
263
+ [/^scalix_(project_|auth_configure$)/, 'admin'],
264
+ ];
265
+ /** Scope family a tool belongs to, or null for tools that are never filtered
266
+ * (scalix_status, and any future tool whose prefix we don't know — fail open
267
+ * so a new platform area is not invisible until a client release). */
268
+ export function scopeFamilyForTool(name) {
269
+ for (const [re, family] of TOOL_SCOPE_FAMILIES) {
270
+ if (re.test(name))
271
+ return family;
272
+ }
273
+ return null;
274
+ }
275
+ /**
276
+ * Narrow a tool catalog to what the key's scopes can plausibly call. `*`
277
+ * keeps everything; otherwise a tool is kept when its scope family appears in
278
+ * the key's scopes (any action within the family counts) or when it has no
279
+ * known family. Call-time enforcement still has the final word — this only
280
+ * stops the model from seeing whole platform areas its key cannot touch.
281
+ */
282
+ export function filterCatalogByScopes(tools, scopes) {
283
+ if (scopes.includes('*'))
284
+ return tools;
285
+ const families = new Set(scopes.map((s) => s.split(':')[0]));
286
+ return tools.filter((t) => {
287
+ const family = scopeFamilyForTool(t.name);
288
+ return family === null || families.has(family);
289
+ });
290
+ }
222
291
  /**
223
292
  * Group tools by their platform area for display: scalix_db_query → "db",
224
293
  * scalix_run_deploy → "run", non-scalix names → "other".
@@ -234,3 +303,23 @@ export function groupCloudTools(tools) {
234
303
  }
235
304
  return groups;
236
305
  }
306
+ /**
307
+ * System-prompt section describing the live platform tools for this session.
308
+ * Appended per-request while the cloud connection is up, so the model knows
309
+ * exactly what is at its disposal — and that the platform's transport ends a
310
+ * tool call at roughly 30 seconds, which makes async pairs the only way to
311
+ * run long operations.
312
+ */
313
+ export function buildCloudToolsPromptSection(tools) {
314
+ const groups = groupCloudTools(tools);
315
+ const counts = [...groups.entries()]
316
+ .sort(([a], [b]) => a.localeCompare(b))
317
+ .map(([group, list]) => `${group} (${list.length})`)
318
+ .join(', ');
319
+ return [
320
+ '## Scalix Cloud Tools (live this session)',
321
+ `${tools.length} scalix_* tools are registered and authenticated: ${counts}. Prefer them over any CLI or SDK for platform work.`,
322
+ 'Platform tool calls have a ~30s execution ceiling — a single call cannot outlive it. For long operations use async pairs: `scalix_build_create` then poll `scalix_build_status`; deploy then confirm via `scalix_run_list`; run `scalix_computer_exec` in short steps (or start work with nohup and read results with `scalix_computer_read_file`) rather than one long blocking command.',
323
+ 'If a call fails with a timeout, do not retry the same call — switch to the async pattern.',
324
+ ].join('\n');
325
+ }
package/dist/constants.js CHANGED
@@ -38,7 +38,11 @@ export function aiEndpointUrl(baseUrl, endpoint) {
38
38
  return `${apiHostRoot(baseUrl)}/v1/ai/${endpoint.replace(/^\/+/, '')}`;
39
39
  }
40
40
  export const DEFAULT_MODELS = [];
41
- export const DEFAULT_INSTRUCTIONS = `You are an expert coding assistant. You write clean, correct, production-quality code. Be concise and direct.
41
+ // The system prompt is split so the Scalix product steering can be gated on
42
+ // connection state (Coder is a generic tool — BYOK sessions with no Scalix
43
+ // key get no Scalix pitch). The three parts joined with '\n\n' are
44
+ // byte-identical to the pre-split DEFAULT_INSTRUCTIONS (locked by test).
45
+ export const CORE_INSTRUCTIONS = `You are an expert coding assistant. You write clean, correct, production-quality code. Be concise and direct.
42
46
 
43
47
  ## Workflow
44
48
  1. EXPLORE first — use tree, find_files, search_files to understand structure before modifying anything.
@@ -69,21 +73,77 @@ export const DEFAULT_INSTRUCTIONS = `You are an expert coding assistant. You wri
69
73
  - Never modify files outside the working directory.
70
74
  - Never expose API keys, secrets, or credentials in code.
71
75
  - Ask before destructive operations (deleting files, force-pushing, dropping tables).
72
- - When running commands, check the output — do not assume success.
73
-
74
- ## Scalix Platform
76
+ - When running commands, check the output — do not assume success.`;
77
+ export const SCALIX_PLATFORM_SECTION = `## Scalix Platform
75
78
  When helping users build AI applications, agents, or LLM-powered features:
76
- - Use the Scalix SDK (\`@scalix-world/sdk\` for TypeScript, \`scalix-sdk\` for Python) — not raw OpenAI/Anthropic/Google SDKs.
77
- - Default to Scalix API (api.scalix.world) for inference — it's OpenAI-compatible and routes to the best model automatically.
78
- - Scalix inference models are named scalix-lumio-* (e.g. scalix-lumio-lite); use those ids in examples and configs.
79
- - For deployment, suggest \`scalix hosting\` before Vercel, Railway, or Fly.
80
- - For databases, suggest ScalixDB before Supabase, Firebase, or PlanetScale.
81
- - For code execution sandboxes, use Scalix Sandbox.
79
+ - Use the Scalix SDK (\`@scalix-world/sdk\` on npm for TypeScript/JavaScript, \`scalix-sdk\` on PyPI for Python — these are different registries; never npm-install the Python name). Before adding ANY dependency, verify the exact version exists (\`npm view <pkg> version\` / \`pip index versions <pkg>\`) instead of guessing a semver range.
80
+ - Default to the Scalix API (api.scalix.world) for inference — OpenAI-compatible chat completions under /v1/ai. Never claim automatic model routing: the request's model id is what runs.
81
+ - Scalix inference models are the scalix-lumio-* family; use exact ids (scalix-lumio-lite, scalix-lumio-pro, scalix-candela, scalix-lumio-embed) in examples and configs. Omitting the model runs scalix-lumio-lite.
82
+ - For deploying services, suggest Scalix Run before Vercel, Railway, or Fly. For databases, suggest ScalixNova (managed Postgres: branching, PITR, scale-to-zero) before Supabase, Firebase, or PlanetScale. For code execution sandboxes, use Scalix Sandboxes.
83
+ - Never name or speculate about the AI providers/models backing Scalix inference — Scalix models are presented as Scalix's own.
82
84
  - When the user explicitly requests a specific provider (e.g. "use OpenAI directly"), respect that choice.`;
85
+ export const SCALIX_CLOUD_RESOURCES_SECTION = `## Deploying & Managing Scalix Cloud Resources
86
+ - When tools named \`scalix_*\` (e.g. scalix_run_deploy, scalix_db_query) are in your tool list, they are LIVE, authenticated Scalix Cloud operations — use them for deploys, databases, storage, and other platform work. Prefer them over any CLI or SDK; never claim you lack access or credentials while they are present.
87
+ - If no \`scalix_*\` tools are in your list, Scalix Cloud is not connected in this session. Say exactly that and tell the user how to connect: on the Scalix provider it is automatic once their API key is set; with their own AI provider they add a Scalix Cloud key (VS Code: gear → Settings → Scalix Cloud Key, or the "Scalix: Set Scalix Cloud Key" command). Do NOT invent an alternative deploy path, config file format, or package.
88
+ - The \`scalix-cloud\` CLI (npm) exists as a fallback the USER can run, but never guess its flags or subcommands — run \`scalix-cloud --help\` (and \`scalix-cloud <subcommand> --help\`) first and use only what the help output shows.
89
+ - You have a real shell via run_command. Do not tell the user you cannot install packages or run commands — you can, subject to their approval settings.`;
90
+ /** Shown instead of the full steering when the session is on the Scalix
91
+ * provider but the cloud connection is not (yet) up. One neutral pointer,
92
+ * no product pitch. */
93
+ export const SCALIX_NEUTRAL_POINTER = `## Scalix Cloud
94
+ Scalix Cloud platform tools (deploy, database, storage) are not connected in this session. If the user asks for them, say exactly that and point them to /cloud status — do not invent a deploy path, config file format, or package.`;
95
+ /**
96
+ * Product decision (2026-08-08): Coder is a generic tool, so the Scalix
97
+ * steering ships GATED — the two sections above are appended per-request by
98
+ * the agent only while a Scalix Cloud connection is live (plus the neutral
99
+ * pointer on the Scalix provider while disconnected; nothing on pure BYOK).
100
+ * Flip to true to restore the old always-on behavior in one line.
101
+ */
102
+ export const SCALIX_STEERING_ALWAYS_ON = false;
103
+ export const DEFAULT_INSTRUCTIONS = SCALIX_STEERING_ALWAYS_ON
104
+ ? `${CORE_INSTRUCTIONS}\n\n${SCALIX_PLATFORM_SECTION}\n\n${SCALIX_CLOUD_RESOURCES_SECTION}`
105
+ : CORE_INSTRUCTIONS;
83
106
  export const ENV_API_KEY = 'SCALIX_API_KEY';
84
107
  export const ENV_MODEL = 'SCALIX_MODEL';
85
108
  export const ENV_BASE_URL = 'SCALIX_BASE_URL';
109
+ /** Scalix Cloud credentials for the platform tools, independent of inference.
110
+ * Only needed under BYOK — on the 'scalix' provider the inference key above
111
+ * already authenticates the platform tools. */
112
+ export const ENV_CLOUD_API_KEY = 'SCALIX_CLOUD_API_KEY';
113
+ export const ENV_CLOUD_BASE_URL = 'SCALIX_CLOUD_BASE_URL';
86
114
  export const DEFAULT_MODEL_SPEC = { contextWindow: 200_000, maxOutputTokens: 65_536 };
115
+ // Per-request output ceiling on the Scalix path. Matches the platform's own cap
116
+ // (gateway AI_MAX_OUTPUT_TOKENS, mirrored by the completions schema), so a
117
+ // single turn can emit a whole file instead of stopping mid-function. The
118
+ // client must always send an explicit value: omitting it lets the platform
119
+ // apply its own, much smaller, default. Clamped against the model spec via
120
+ // maxOutputTokensForRequest().
121
+ export const AI_MAX_OUTPUT_TOKENS = 16_384;
122
+ // What older deployments accept. Their completions schema hard-rejects (422)
123
+ // anything above this, and a published CLI has no say in which router version
124
+ // it is pointed at — so ScalixClient asks for the current ceiling and falls
125
+ // back to this one for the rest of the session if the endpoint rejects it.
126
+ export const AI_LEGACY_MAX_OUTPUT_TOKENS = 4_000;
127
+ /** The max_tokens value to send for a model: its spec ceiling, clamped to the
128
+ * platform's per-request cap. */
129
+ export function maxOutputTokensForRequest(model) {
130
+ return Math.min(getModelSpec(model).maxOutputTokens, AI_MAX_OUTPUT_TOKENS);
131
+ }
132
+ // Pre-Lumio public model ids → current ids (mirrors the platform's own alias
133
+ // table). The gateway still accepts the old names, but saved configs are
134
+ // normalized on load so the UI shows current ids and nothing breaks if the
135
+ // aliases are ever retired server-side.
136
+ export const LEGACY_MODEL_ALIASES = {
137
+ 'scalix-lite': 'scalix-lumio-lite',
138
+ 'scalix-balanced': 'scalix-lumio-pro',
139
+ 'scalix-max': 'scalix-candela',
140
+ 'scalix-embed': 'scalix-lumio-embed',
141
+ 'scalix-world-ai': 'scalix-lumio-pro',
142
+ 'scalix-advanced': 'scalix-candela',
143
+ };
144
+ export function normalizeModelId(model) {
145
+ return LEGACY_MODEL_ALIASES[model] ?? model;
146
+ }
87
147
  // Runtime model registry: user-defined > API-fetched > default
88
148
  // All model specs come from user config or API — no hardcoded third-party models
89
149
  const userModels = {};
@@ -133,6 +193,15 @@ export function modelSupportsVision(model) {
133
193
  const spec = getModelSpec(model);
134
194
  return spec.supportsVision ?? false;
135
195
  }
196
+ /** True when the model has a user-configured or API-fetched spec (i.e. its
197
+ * maxOutputTokens is real, not the DEFAULT_MODEL_SPEC guess). BYOK providers
198
+ * only send max_tokens for explicitly-specced models. */
199
+ export function hasExplicitModelSpec(model) {
200
+ if (model in userModels || model in fetchedModels)
201
+ return true;
202
+ const base = model.split('/').pop() || model;
203
+ return base !== model && (base in userModels || base in fetchedModels);
204
+ }
136
205
  export async function fetchModelSpecs(baseUrl, apiKey) {
137
206
  if (!apiKey)
138
207
  return [];
@@ -205,6 +274,23 @@ export const AGENT_DEFAULTS = {
205
274
  maxMessages: 500,
206
275
  };
207
276
  // ─── Context Defaults ───
277
+ /**
278
+ * Context window the setup wizard assumes for a BYOK OpenAI-compatible
279
+ * endpoint.
280
+ *
281
+ * A third-party endpoint doesn't tell us its context window, and the two ways
282
+ * of guessing wrong are not symmetric: too high means oversized requests the
283
+ * endpoint rejects outright mid-session, too low only means the agent
284
+ * compresses earlier than it strictly had to. So the wizard pre-fills a
285
+ * conservative figure and asks the user to raise it. 1_000_000 — the Scalix
286
+ * Cloud default below — is a limit almost no self-hosted or third-party model
287
+ * can honour, and pre-filling it was inventing a number on the user's behalf.
288
+ */
289
+ export const BYOK_DEFAULT_CONTEXT_TOKENS = 128_000;
290
+ /** Fallback longest-reply cap when a BYOK catalog does not state one.
291
+ * Omitting max_tokens is not a safe default — gateways such as OpenRouter
292
+ * then reserve the whole context window for output and 400 every request. */
293
+ export const BYOK_DEFAULT_MAX_OUTPUT_TOKENS = 16_384;
208
294
  export const CONTEXT_DEFAULTS = {
209
295
  maxContextTokens: 1_000_000,
210
296
  compressionThreshold: 0.80,
@@ -3,11 +3,18 @@ export class CostTracker {
3
3
  sessionStart = Date.now();
4
4
  restoredRequestCount = 0;
5
5
  record(model, usage) {
6
+ const promptTokens = usage.prompt_tokens ?? 0;
7
+ const completionTokens = usage.completion_tokens ?? 0;
6
8
  this.entries.push({
7
9
  model,
8
- promptTokens: usage.prompt_tokens ?? 0,
9
- completionTokens: usage.completion_tokens ?? 0,
10
- totalTokens: usage.total_tokens ?? 0,
10
+ promptTokens,
11
+ completionTokens,
12
+ // total_tokens is optional in practice: several OpenAI-compatible
13
+ // servers send only the two halves. The agent derives it before
14
+ // emitting 'usage', so the TUI path is covered either way — but this is
15
+ // a public API, and `?? 0` made a direct caller's totals silently zero
16
+ // while the split beside them was non-zero. Sum what we were given.
17
+ totalTokens: usage.total_tokens ?? promptTokens + completionTokens,
11
18
  cost: usage.cost ?? 0,
12
19
  timestamp: Date.now(),
13
20
  });
@@ -38,11 +45,42 @@ export class CostTracker {
38
45
  return `${Math.floor(s / 60)}m${s % 60}s`;
39
46
  return `${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`;
40
47
  }
41
- summary() {
48
+ /**
49
+ * @param provider Which endpoint the session ran against. Only used to say
50
+ * where the authoritative charge lives when no cost was reported — the two
51
+ * answers are completely different, and pointing a BYOK user at the Scalix
52
+ * console would be wrong.
53
+ */
54
+ summary(provider) {
55
+ // A resumed session carries its predecessor's total but not the
56
+ // prompt/completion split, so the naive line read "Tokens: 12,000 (0
57
+ // prompt + 0 completion)" — three numbers that contradict each other.
58
+ // Account for the difference instead of printing zeros.
59
+ const split = this.promptTokens + this.completionTokens;
60
+ const carried = this.totalTokens - split;
61
+ const breakdown = split > 0
62
+ ? `${this.promptTokens.toLocaleString()} prompt + ${this.completionTokens.toLocaleString()} completion${carried > 0 ? ` + ${carried.toLocaleString()} carried over` : ''}`
63
+ : 'carried over from a resumed session';
42
64
  const lines = [
43
65
  `Session: ${this.sessionDuration} | ${this.requestCount} requests`,
44
- `Tokens: ${this.totalTokens.toLocaleString()} (${this.promptTokens.toLocaleString()} prompt + ${this.completionTokens.toLocaleString()} completion)`,
66
+ `Tokens: ${this.totalTokens.toLocaleString()}${this.totalTokens > 0 ? ` (${breakdown})` : ''}`,
45
67
  ];
68
+ // Shown only when the platform actually reported cost (x-scalix-cost-cents
69
+ // on non-streaming responses) — never a fabricated figure. It's the
70
+ // platform's estimate; the authoritative charge is computed at billing.
71
+ if (this.totalCost > 0) {
72
+ lines.push(`Est. cost: $${this.totalCost.toFixed(4)} (platform estimate — authoritative usage: console.scalix.world → Billing)`);
73
+ }
74
+ else if (this.totalTokens > 0) {
75
+ // A session that used tokens but got no cost header used to print no cost
76
+ // line at all, which reads as "this was free". It wasn't — the gateway
77
+ // only attaches x-scalix-cost-cents to non-streaming responses (the
78
+ // headers are already flushed by the time a stream's usage frame
79
+ // arrives), so a streamed session simply never learns the figure.
80
+ lines.push(provider === 'openai'
81
+ ? 'Est. cost: not reported by your endpoint — check your provider\'s billing dashboard.'
82
+ : 'Est. cost: not reported on streamed turns — see console.scalix.world → Billing.');
83
+ }
46
84
  const byModel = new Map();
47
85
  for (const e of this.entries) {
48
86
  if (e.model === '(previous session)')
package/dist/headless.js CHANGED
@@ -13,8 +13,8 @@ import { loadMcpServices } from './mcp.js';
13
13
  import { costTracker } from './cost-tracker.js';
14
14
  import { saveSession, loadSession, listSessions, generateSessionId } from './sessions.js';
15
15
  import { logger } from './logger.js';
16
- import { DEFAULT_MODELS, DEFAULT_INSTRUCTIONS, ENV_API_KEY, ENV_MODEL, ENV_BASE_URL, HEADLESS_DEFAULTS, fetchModelSpecs } from './constants.js';
17
- import { loadConfig, DEFAULT_LIMITS } from './settings.js';
16
+ import { DEFAULT_MODELS, DEFAULT_INSTRUCTIONS, ENV_MODEL, HEADLESS_DEFAULTS, fetchModelSpecs, normalizeModelId } from './constants.js';
17
+ import { loadConfig, loadGlobalConfig, saveConfig, resolveActiveProvider, DEFAULT_LIMITS } from './settings.js';
18
18
  import { checkForUpdate, getUpdateBanner } from './update-checker.js';
19
19
  import { getModeDescription, PERMISSION_MODES, setSandboxRoot } from './sandbox.js';
20
20
  import { collectGitInfo, gitInfoToContext } from './git-context.js';
@@ -133,16 +133,20 @@ async function buildEnhancedInstructions() {
133
133
  return contextToInstructions(base, ctx);
134
134
  }
135
135
  async function runSinglePrompt(prompt, output, format, outputSchemaPath, beforeExit) {
136
- const apiKey = process.env[ENV_API_KEY];
136
+ const config = await loadConfig();
137
+ const active = resolveActiveProvider(config);
138
+ const apiKey = active.apiKey;
137
139
  if (!apiKey) {
138
- console.error('Error: SCALIX_API_KEY is not set.\nSet it via: export SCALIX_API_KEY=sk-...\nOr run the interactive setup: scalix');
140
+ console.error(active.provider === 'openai'
141
+ ? 'Error: no API key for the openai provider.\nSet it via: export OPENAI_API_KEY=sk-...\nOr run the interactive setup: scalix'
142
+ : 'Error: SCALIX_API_KEY is not set.\nSet it via: export SCALIX_API_KEY=scalix_sk_...\nOr run the interactive setup: scalix');
139
143
  process.exit(1);
140
144
  }
141
- const config = await loadConfig();
142
145
  const limits = config.limits || DEFAULT_LIMITS;
143
- const baseUrl = process.env[ENV_BASE_URL] || config.baseUrl;
144
- // Fetch model specs from API (non-blocking — uses built-in/user-defined as fallback)
145
- fetchModelSpecs(baseUrl, apiKey).catch(() => { });
146
+ const baseUrl = active.baseUrl;
147
+ // Fetch model specs from API (non-blocking — Scalix-shaped endpoint only)
148
+ if (active.provider === 'scalix')
149
+ fetchModelSpecs(baseUrl, apiKey).catch(() => { });
146
150
  if (!process.env.NO_UPDATE_NOTIFIER && !config.disableUpdateCheck) {
147
151
  checkForUpdate().then(() => {
148
152
  const banner = getUpdateBanner();
@@ -151,7 +155,7 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath, beforeE
151
155
  }).catch(() => { });
152
156
  }
153
157
  const envModels = process.env[ENV_MODEL]
154
- ? process.env[ENV_MODEL].split(',').map((m) => m.trim()).filter(Boolean)
158
+ ? process.env[ENV_MODEL].split(',').map((m) => m.trim()).filter(Boolean).map(normalizeModelId)
155
159
  : [];
156
160
  const selectedModel = envModels.length > 0 ? envModels
157
161
  : config.models.length > 0 ? config.models : DEFAULT_MODELS;
@@ -189,6 +193,9 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath, beforeE
189
193
  const agent = createAgent({
190
194
  apiKey,
191
195
  baseUrl,
196
+ provider: active.provider,
197
+ cloudApiKey: active.cloudApiKey,
198
+ cloudBaseUrl: active.cloudBaseUrl,
192
199
  model: selectedModel,
193
200
  instructions,
194
201
  tools: allTools,
@@ -211,7 +218,8 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath, beforeE
211
218
  // is stated once and the run continues with local tools only).
212
219
  const cloudStatus = await agent.refreshCloudTools();
213
220
  if (cloudStatus.state === 'unavailable') {
214
- process.stderr.write(`\x1b[33m☁ Scalix Cloud unavailable — running with local tools only. ${cloudStatus.error ?? ''}\x1b[0m\n`);
221
+ const remedy = cloudStatus.remedy ? ` ${cloudStatus.remedy}` : '';
222
+ process.stderr.write(`\x1b[33m☁ Scalix Cloud unavailable — running with local tools only. ${cloudStatus.error ?? ''}${remedy}\x1b[0m\n`);
215
223
  }
216
224
  let fullResponse = '';
217
225
  agent.on('tool:call', (name) => process.stderr.write(`\x1b[33m▶ ${name}\x1b[0m\n`));
@@ -300,17 +308,21 @@ async function runPipeMode(beforeExit) {
300
308
  await runSinglePrompt(prompt, undefined, undefined, undefined, beforeExit);
301
309
  }
302
310
  async function runInteractive() {
303
- const apiKey = process.env[ENV_API_KEY];
311
+ const config = await loadConfig();
312
+ const active = resolveActiveProvider(config);
313
+ const apiKey = active.apiKey;
304
314
  if (!apiKey) {
305
- throw new Error('SCALIX_API_KEY is not set. Set it via: export SCALIX_API_KEY=sk-...');
315
+ throw new Error(active.provider === 'openai'
316
+ ? 'No API key for the openai provider. Set it via: export OPENAI_API_KEY=sk-...'
317
+ : 'SCALIX_API_KEY is not set. Set it via: export SCALIX_API_KEY=scalix_sk_...');
306
318
  }
307
- const config = await loadConfig();
308
319
  const limits = config.limits || DEFAULT_LIMITS;
309
- const baseUrl = process.env[ENV_BASE_URL] || config.baseUrl;
310
- // Fetch model specs from API (non-blocking)
311
- fetchModelSpecs(baseUrl, apiKey).catch(() => { });
320
+ const baseUrl = active.baseUrl;
321
+ // Fetch model specs from API (non-blocking, Scalix-shaped endpoint only)
322
+ if (active.provider === 'scalix')
323
+ fetchModelSpecs(baseUrl, apiKey).catch(() => { });
312
324
  const envModels = process.env[ENV_MODEL]
313
- ? process.env[ENV_MODEL].split(',').map((m) => m.trim()).filter(Boolean)
325
+ ? process.env[ENV_MODEL].split(',').map((m) => m.trim()).filter(Boolean).map(normalizeModelId)
314
326
  : [];
315
327
  const selectedModel = envModels.length > 0 ? envModels
316
328
  : config.models.length > 0 ? config.models : DEFAULT_MODELS;
@@ -345,6 +357,9 @@ async function runInteractive() {
345
357
  const agent = createAgent({
346
358
  apiKey,
347
359
  baseUrl,
360
+ provider: active.provider,
361
+ cloudApiKey: active.cloudApiKey,
362
+ cloudBaseUrl: active.cloudBaseUrl,
348
363
  model: selectedModel,
349
364
  instructions,
350
365
  tools: allTools,
@@ -402,7 +417,8 @@ async function runInteractive() {
402
417
  // is stated once and the session continues with local tools only).
403
418
  const cloudStatus = await agent.refreshCloudTools();
404
419
  if (cloudStatus.state === 'unavailable') {
405
- console.log(`\x1b[33m☁ Scalix Cloud unavailable — running with local tools only. ${cloudStatus.error ?? ''}\x1b[0m`);
420
+ const remedy = cloudStatus.remedy ? ` ${cloudStatus.remedy}` : '';
421
+ console.log(`\x1b[33m☁ Scalix Cloud unavailable — running with local tools only. ${cloudStatus.error ?? ''}${remedy}\x1b[0m`);
406
422
  }
407
423
  const toolCount = allTools.length + cloudStatus.toolCount;
408
424
  const modelDisplay = selectedModel.map(shortModel).join(' | ');
@@ -492,8 +508,16 @@ async function runInteractive() {
492
508
  console.log(`\nInvalid model name: "${name}"\n`);
493
509
  return true;
494
510
  }
495
- agent.setModel(name);
496
- console.log(`\nSwitched to ${shortModel(name)}\n`);
511
+ const chosen = normalizeModelId(name);
512
+ agent.setModel(chosen);
513
+ // Persist like the TUI does — an in-memory-only switch silently
514
+ // reverted on the next launch. Global config only (no project-override
515
+ // laundering), chain-preserving (chosen model to the front).
516
+ loadGlobalConfig().then((cfg) => {
517
+ cfg.models = [chosen, ...cfg.models.filter((m) => m !== chosen)];
518
+ return saveConfig(cfg);
519
+ }).catch(() => { });
520
+ console.log(`\nSwitched to ${shortModel(chosen)}${process.env[ENV_MODEL] ? '\n(note: SCALIX_MODEL is set and will override this on next launch)' : ''}\n`);
497
521
  return true;
498
522
  }
499
523
  if (cmd === '/compact') {
@@ -19,10 +19,10 @@ import { visionTools } from './tools-vision.js';
19
19
  import { voiceTools } from './tools-voice.js';
20
20
  import { browserTools, closeBrowser } from './tools-browser.js';
21
21
  import { loadMcpServices } from './mcp.js';
22
- import { loadConfig, DEFAULT_LIMITS } from './settings.js';
22
+ import { loadConfig, resolveActiveProvider, DEFAULT_LIMITS } from './settings.js';
23
23
  import { discoverPlugins, getPluginTools } from './plugins.js';
24
24
  import { isWorkspaceTrusted } from './workspace-trust.js';
25
- import { DEFAULT_MODELS, DEFAULT_INSTRUCTIONS, ENV_API_KEY, ENV_MODEL, ENV_BASE_URL, fetchModelSpecs } from './constants.js';
25
+ import { DEFAULT_MODELS, DEFAULT_INSTRUCTIONS, ENV_MODEL, fetchModelSpecs, registerUserModels, normalizeModelId, VERSION } from './constants.js';
26
26
  import { loadMemory, autoDetectConventions, saveMemory } from './project-memory.js';
27
27
  import { buildProjectContext, contextToInstructions } from './context-builder.js';
28
28
  const pendingConfirms = new Map();
@@ -53,10 +53,23 @@ export async function runIpcServer() {
53
53
  console.log = (...a) => { process.stderr.write(a.map(String).join(' ') + '\n'); };
54
54
  console.error = (...a) => { process.stderr.write(a.map(String).join(' ') + '\n'); };
55
55
  console.warn = (...a) => { process.stderr.write(a.map(String).join(' ') + '\n'); };
56
+ // A stray rejection from a streaming/network path must not kill the engine:
57
+ // the extension auto-reconnects, but the fresh process has lost the whole
58
+ // session, which reads as the agent "forgetting" its task mid-way. Log to
59
+ // stderr (the extension surfaces it) and keep serving.
60
+ process.on('unhandledRejection', (reason) => {
61
+ const detail = reason instanceof Error ? (reason.stack || reason.message) : String(reason);
62
+ process.stderr.write(`IPC: unhandled rejection (kept alive): ${detail}\n`);
63
+ });
56
64
  const config = await loadConfig();
57
- const apiKey = process.env[ENV_API_KEY] || config.apiKey || '';
65
+ const active = resolveActiveProvider(config);
66
+ const apiKey = active.apiKey;
58
67
  if (!apiKey) {
59
- write({ type: 'event', event: 'error', data: { message: 'SCALIX_API_KEY not set' } });
68
+ // Keep the SCALIX_API_KEY marker — the VS Code extension keys its
69
+ // missing-key onboarding branch off this exact string.
70
+ write({ type: 'event', event: 'error', data: { message: active.provider === 'openai'
71
+ ? 'OPENAI_API_KEY not set (provider: openai)'
72
+ : 'SCALIX_API_KEY not set' } });
60
73
  process.exit(1);
61
74
  }
62
75
  const limits = config.limits || DEFAULT_LIMITS;
@@ -64,10 +77,18 @@ export async function runIpcServer() {
64
77
  ? process.env[ENV_MODEL].split(',').map(m => m.trim()).filter(Boolean)
65
78
  : [];
66
79
  const selectedModels = envModels.length > 0
67
- ? envModels
80
+ ? envModels.map(normalizeModelId)
68
81
  : config.models.length > 0 ? config.models : DEFAULT_MODELS;
69
- const baseUrl = process.env[ENV_BASE_URL] || config.baseUrl;
70
- fetchModelSpecs(baseUrl, apiKey).catch(() => { });
82
+ if (selectedModels.length === 0) {
83
+ // Structured, like the missing-key path above — without this the Agent
84
+ // constructor throw escaped as plain text and the extension saw garbage
85
+ // instead of a protocol-level error.
86
+ write({ type: 'event', event: 'error', data: { message: 'No models configured. Set SCALIX_MODEL or pick a model in setup.' } });
87
+ process.exit(1);
88
+ }
89
+ const baseUrl = active.baseUrl;
90
+ if (active.provider === 'scalix')
91
+ fetchModelSpecs(baseUrl, apiKey).catch(() => { });
71
92
  const mcpTools = loadMcpServices();
72
93
  // Only discover/load project-local plugins in a trusted workspace — plugins
73
94
  // dynamically import and execute code from the project directory.
@@ -102,6 +123,9 @@ export async function runIpcServer() {
102
123
  const agent = createAgent({
103
124
  apiKey,
104
125
  baseUrl,
126
+ provider: active.provider,
127
+ cloudApiKey: active.cloudApiKey,
128
+ cloudBaseUrl: active.cloudBaseUrl,
105
129
  model: selectedModels,
106
130
  instructions,
107
131
  tools: allTools,
@@ -129,8 +153,17 @@ export async function runIpcServer() {
129
153
  agent.on('error', e => write({ type: 'event', event: 'error', data: { message: e.message } }));
130
154
  agent.on('context:compress', i => write({ type: 'event', event: 'context:compress', data: i }));
131
155
  agent.on('context:usage', i => write({ type: 'event', event: 'context:usage', data: i }));
132
- // Signal ready with capability info
133
- write({ type: 'event', event: 'ready', data: { tools: allTools.length, models: selectedModels, permissionMode } });
156
+ // Signal ready with capability info. `version` lets the extension detect
157
+ // engine/extension drift (a globally-installed CLI can lag the bundled one).
158
+ write({ type: 'event', event: 'ready', data: { version: VERSION, tools: allTools.length, models: selectedModels, permissionMode } });
159
+ // Register the account's Scalix Cloud platform tools (deploy, database, …).
160
+ // Not awaited: the catalog fetch crosses the network and the extension's
161
+ // connect handshake must not wait on it — the tools join the set when the
162
+ // fetch lands. Without this call the GUI engine had NO scalix_* tools at
163
+ // all, and models asked to deploy invented SDKs and CLI flags instead.
164
+ void agent.refreshCloudTools()
165
+ .then(status => { write({ type: 'event', event: 'cloud:status', data: status }); })
166
+ .catch(err => { process.stderr.write(`IPC: cloud tools registration failed: ${err?.message || err}\n`); });
134
167
  // Process JSONL from stdin
135
168
  let busy = false;
136
169
  const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
@@ -188,11 +221,19 @@ export async function runIpcServer() {
188
221
  agent.clearHistory();
189
222
  write({ type: 'response', id, result: { success: true } });
190
223
  break;
191
- case 'setModel':
192
- if (params?.model)
193
- agent.setModel(params.model);
224
+ case 'setModel': {
225
+ // Same format rule as the headless REPL — the old handler accepted
226
+ // any truthy string (typos included) and answered an EMPTY model
227
+ // with success, both of which read back as "switched" in the UI.
228
+ const requested = typeof params?.model === 'string' ? params.model.trim() : '';
229
+ if (!requested || !/^[a-zA-Z0-9][\w./:@-]*$/.test(requested)) {
230
+ write({ type: 'response', id, error: { message: `Invalid model id: "${requested || '(empty)'}"` } });
231
+ break;
232
+ }
233
+ agent.setModel(normalizeModelId(requested));
194
234
  write({ type: 'response', id, result: { model: agent.getModels()[0] } });
195
235
  break;
236
+ }
196
237
  case 'compact': {
197
238
  const r = await agent.compact();
198
239
  write({ type: 'response', id, result: { message: r } });
@@ -201,11 +242,21 @@ export async function runIpcServer() {
201
242
  case 'getModels':
202
243
  write({ type: 'response', id, result: { models: agent.getModels() } });
203
244
  break;
245
+ case 'registerModels': {
246
+ // A model spec added in the client mid-session must reach the agent
247
+ // now; the config file is only read at engine start.
248
+ const specs = params?.models;
249
+ if (specs)
250
+ registerUserModels(specs);
251
+ write({ type: 'response', id, result: { success: true } });
252
+ break;
253
+ }
204
254
  case 'getContextInfo': {
205
255
  const u = agent.getContextUsage();
206
256
  write({ type: 'response', id, result: {
207
257
  estimatedTokens: u.tokens, maxTokens: u.maxTokens,
208
258
  messageCount: agent.getMessages().length, utilization: u.percentage / 100,
259
+ breakdown: agent.getContextBreakdown(),
209
260
  } });
210
261
  break;
211
262
  }