@rubixkube/rubix 0.0.1 → 0.0.3

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 ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com),
6
+ and this project adheres to [Semantic Versioning](https://semver.org).
7
+
8
+ ## [0.0.3]
9
+
10
+ ### Added
11
+
12
+ - /models to switch between models
13
+ - Persistent user settings
14
+ - Bugfixes and performance improvements
15
+
16
+
17
+ ## [0.0.2]
18
+
19
+ ### Added
20
+
21
+ - Slash commands
22
+ - Session history and auto-resume
23
+ - Streaming workflow event tracing
24
+
25
+ ## [0.0.1]
26
+
27
+ ### Added
28
+
29
+ - Initial release
package/LICENSE ADDED
@@ -0,0 +1,33 @@
1
+ Copyright (c) 2026 Rubixkube Intelligence Pvt. Ltd. All rights reserved.
2
+
3
+ RUBIXKUBE PROPRIETARY SOFTWARE LICENSE AGREEMENT
4
+
5
+ IMPORTANT: PLEASE READ THIS LICENSE AGREEMENT CAREFULLY. BY INSTALLING OR USING THE RUBIX CLI ("SOFTWARE"), YOU AGREE TO BE BOUND BY THE TERMS OF THIS AGREEMENT.
6
+
7
+ RubixKube and Site Reliability Intelligence are trademarks of Rubixkube Intelligence Pvt. Ltd.
8
+
9
+ 1. GRANT OF LICENSE
10
+ Rubixkube Intelligence Pvt. Ltd. ("Company") grants you a non-exclusive, non-transferable, limited license to use the Software solely for your internal business purposes, provided that you have a valid and active account on the RubixKube platform (https://console.rubixkube.ai).
11
+
12
+ 2. RESTRICTIONS
13
+ You shall not, and shall not permit any third party to:
14
+ (a) copy, modify, or create derivative works of the Software;
15
+ (b) redistribute, sell, lease, sublicense, or otherwise transfer the Software to any third party;
16
+ (c) reverse engineer, decompile, disassemble, or attempt to derive the source code of the Software;
17
+ (d) remove or alter any copyright, trademark, or other proprietary notices;
18
+ (e) use the Software in any manner that violates applicable laws or regulations.
19
+
20
+ 3. INTELLECTUAL PROPERTY
21
+ The Software is licensed, not sold. All title, ownership rights, and intellectual property rights in and to the Software (including but not limited to any images, animations, video, audio, music, and text incorporated into the Software) are owned by Rubixkube Intelligence Pvt. Ltd. This Agreement does not grant you any rights to trademarks or service marks of the Company.
22
+
23
+ 4. TERMINATION
24
+ This license is effective until terminated. Your rights under this license will terminate automatically without notice from the Company if you fail to comply with any term(s) of this Agreement or if your RubixKube account is suspended or terminated. Upon termination, you must cease all use of the Software and destroy all copies.
25
+
26
+ 5. DISCLAIMER OF WARRANTIES
27
+ THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE COMPANY DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.
28
+
29
+ 6. LIMITATION OF LIABILITY
30
+ IN NO EVENT SHALL THE COMPANY BE LIABLE FOR ANY SPECIAL, INCIDENTAL, INDIRECT, OR CONSEQUENTIAL DAMAGES WHATSOEVER (INCLUDING, WITHOUT LIMITATION, DAMAGES FOR LOSS OF BUSINESS PROFITS, BUSINESS INTERRUPTION, OR LOSS OF BUSINESS INFORMATION) ARISING OUT OF THE USE OF OR INABILITY TO USE THE SOFTWARE.
31
+
32
+ 7. CONTACT
33
+ For any questions regarding this license, please contact: connect@rubixkube.ai.
package/README.md CHANGED
@@ -1,8 +1,14 @@
1
1
  # Rubix CLI
2
2
 
3
- Chat with your infrastructure from the terminal. **RubixKube** CLI for Site Reliability Intelligence—predict, prevent, and fix failures with AI.
3
+ [![npm version](https://img.shields.io/npm/v/@rubixkube/rubix.svg)](https://www.npmjs.com/package/@rubixkube/rubix)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@rubixkube/rubix.svg)](https://www.npmjs.com/package/@rubixkube/rubix)
5
+ [![License: Proprietary](https://img.shields.io/badge/License-Proprietary-red.svg)](https://rubixkube.ai)
4
6
 
5
- ![Rubix CLI](https://raw.githubusercontent.com/rubixkube-io/rubixkube-ai/main/public/assets/rubix-cli.png)
7
+ Built for SREs and platform engineers who own production reliability. **Rubix** brings RubixKube's Site Reliability Intelligence directly into your terminal — investigate incidents, understand blast radius, and get AI-driven RCA without leaving the command line.
8
+
9
+ Rubix connects to the RubixKube cloud platform — no local setup beyond a free account.
10
+
11
+ ![Rubix CLI](https://rubixkube.ai/assets/rubix-cli.png)
6
12
 
7
13
  ## Install
8
14
 
@@ -18,24 +24,35 @@ npx @rubixkube/rubix
18
24
 
19
25
  ## Quick Start
20
26
 
21
- 1. Run `rubix` to start the chat UI
22
- 2. On first run, choose **Login** and complete the Auth0 device flow
23
- 3. Start asking questions about your infrastructure
27
+ ```bash
28
+ rubix
29
+ ```
30
+
31
+ On first run, you'll be prompted to log in via a browser link — takes 30 seconds. After that, you're in.
32
+
33
+ Start asking:
34
+
35
+ ```
36
+ > What's causing the latency spike in production?
37
+ > Show me recent incidents for the payments service
38
+ > What changed before this alert fired?
39
+ ```
24
40
 
25
- ## Features
41
+ ## What You Can Do
26
42
 
27
- - **Auth** — Auth0 device code flow; credentials stored in `~/.rubix/auth.json`
28
- - **Chat** — Real-time streaming to RubixKube; session resume; cluster-aware
29
- - **Slash commands** — `/login`, `/logout`, `/status`, `/sessions`, `/cluster`, `/new`, `/clear`, `/console`, `/help`, `/exit`
30
- - **Shortcuts** — `?` help, `Tab` autocomplete, `Ctrl+X` then `H/C/Q` for help/clear/quit
43
+ - **Investigate incidents** — ask natural language questions about your live infrastructure
44
+ - **Get RCA** — AI-generated root cause analysis, right in the terminal
45
+ - **Track multiple clusters** — switch context without leaving the chat
46
+ - **Resume sessions** — pick up where you left off across terminal sessions
47
+ - **Slash commands** — `/new`, `/sessions`, `/status`, `/clear`, `/help`, `/exit`
31
48
 
32
49
  ## Requirements
33
50
 
34
51
  - Node.js 18+
35
- - [RubixKube account](https://console.rubixkube.ai) (free signup)
52
+ - [RubixKube account](https://console.rubixkube.ai) free to sign up
36
53
 
37
54
  ## Links
38
55
 
39
- - [RubixKube](https://rubixkube.ai) — Site Reliability Intelligence platform
56
+ - [rubixkube.ai](https://rubixkube.ai) — Site Reliability Intelligence platform
40
57
  - [Documentation](https://docs.rubixkube.ai)
41
58
  - [Console](https://console.rubixkube.ai)
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ import { VERSION } from "./version.js";
5
5
  import { runLoginCommand } from "./commands/login.js";
6
6
  import { runLogoutCommand } from "./commands/logout.js";
7
7
  import { runSessionsCommand } from "./commands/sessions.js";
8
+ import { runModelCommand } from "./commands/model.js";
8
9
  async function main() {
9
10
  if (process.argv.length <= 2) {
10
11
  await runChatCommand({});
@@ -41,6 +42,12 @@ async function main() {
41
42
  .action(async () => {
42
43
  await runSessionsCommand();
43
44
  });
45
+ program
46
+ .command("model [subcommand] [modelId]")
47
+ .description("Manage AI models")
48
+ .action(async (subcommand, modelId) => {
49
+ await runModelCommand({ subcommand, modelId });
50
+ });
44
51
  await program.parseAsync(process.argv);
45
52
  }
46
53
  main().catch((error) => {
@@ -1,6 +1,27 @@
1
- import { saveAuthConfig } from "../core/auth-store.js";
2
- import { authenticateWithDeviceFlow } from "../core/device-auth.js";
1
+ import { loadAuthConfig, saveAuthConfig } from "../core/auth-store.js";
2
+ import { authenticateWithDeviceFlow, isTokenNearExpiry } from "../core/device-auth.js";
3
+ import { refreshAndUpdateAuth } from "../core/rubix-api.js";
3
4
  export async function runLoginCommand() {
5
+ let existing = await loadAuthConfig();
6
+ if (existing?.isAuthenticated && (existing.idToken || existing.authToken)) {
7
+ let tokenValid = !isTokenNearExpiry(existing.idToken ?? existing.authToken);
8
+ if (!tokenValid && existing.refreshToken) {
9
+ try {
10
+ console.log("Session token expired, attempting to refresh...");
11
+ existing = await refreshAndUpdateAuth(existing);
12
+ await saveAuthConfig(existing);
13
+ tokenValid = true;
14
+ }
15
+ catch (error) {
16
+ console.log("Failed to refresh token. Need to log in again.");
17
+ }
18
+ }
19
+ if (tokenValid) {
20
+ console.log(`Already authenticated as ${existing?.userName ?? existing?.userEmail ?? "user"}.`);
21
+ console.log("Run `rubix logout` first if you want to switch accounts.");
22
+ return;
23
+ }
24
+ }
4
25
  console.log("Starting Rubix device authentication...");
5
26
  try {
6
27
  const authConfig = await authenticateWithDeviceFlow((message) => {
@@ -0,0 +1,84 @@
1
+ import { loadAuthConfig } from "../core/auth-store.js";
2
+ import { listModels, setSessionModel, listSessions } from "../core/rubix-api.js";
3
+ export async function runModelCommand(args) {
4
+ try {
5
+ const auth = await loadAuthConfig();
6
+ if (!auth?.isAuthenticated) {
7
+ console.error("Not authenticated. Run `rubix login` first.");
8
+ process.exitCode = 1;
9
+ return;
10
+ }
11
+ const subcommand = args.subcommand?.toLowerCase() ?? "list";
12
+ switch (subcommand) {
13
+ case "list": {
14
+ const models = await listModels(auth);
15
+ if (models.length === 0) {
16
+ console.log("No models available.");
17
+ return;
18
+ }
19
+ console.log("\nAvailable models:\n");
20
+ for (const model of models) {
21
+ const marker = model.default ? "●" : " ";
22
+ const experimental = model.experimental ? " (experimental)" : "";
23
+ const thinking = model.thinking_supported ? " · thinking" : "";
24
+ console.log(`${marker} ${model.id.padEnd(12)} ${model.display_name}`);
25
+ if (model.description) {
26
+ console.log(` ${model.description}`);
27
+ }
28
+ if (experimental || thinking) {
29
+ console.log(` ${experimental}${thinking}`.trim());
30
+ }
31
+ console.log();
32
+ }
33
+ break;
34
+ }
35
+ case "set": {
36
+ if (!args.modelId) {
37
+ console.error("Usage: rubix model set <model_id>");
38
+ console.error("Example: rubix model set expert");
39
+ process.exitCode = 1;
40
+ return;
41
+ }
42
+ const sessions = await listSessions(auth, 1, 0);
43
+ if (sessions.length === 0) {
44
+ console.error("No active session. Use `rubix chat` to create one.");
45
+ process.exitCode = 1;
46
+ return;
47
+ }
48
+ const sessionId = sessions[0].id;
49
+ const result = await setSessionModel(auth, sessionId, args.modelId);
50
+ console.log(`Switched to: ${result.displayName}`);
51
+ if (result.thinkingSupported) {
52
+ console.log("(This model supports extended thinking)");
53
+ }
54
+ break;
55
+ }
56
+ case "current": {
57
+ const sessions = await listSessions(auth, 1, 0);
58
+ if (sessions.length === 0) {
59
+ console.log("No active session.");
60
+ process.exitCode = 1;
61
+ return;
62
+ }
63
+ const models = await listModels(auth);
64
+ const defaultModel = models.find((m) => m.default);
65
+ if (defaultModel) {
66
+ console.log(`Current model: ${defaultModel.display_name} (${defaultModel.id})`);
67
+ }
68
+ else {
69
+ console.log("No model information available.");
70
+ }
71
+ break;
72
+ }
73
+ default:
74
+ console.error(`Unknown subcommand: ${subcommand}`);
75
+ console.error("Usage: rubix model [list|set|current]");
76
+ process.exitCode = 1;
77
+ }
78
+ }
79
+ catch (error) {
80
+ const message = error instanceof Error ? error.message : String(error);
81
+ console.error(`Failed: ${message}`);
82
+ process.exitCode = 1;
83
+ }
84
+ }
@@ -19,6 +19,19 @@ function decodeJwtClaims(token) {
19
19
  const decoded = Buffer.from(padded, "base64").toString("utf8");
20
20
  return JSON.parse(decoded);
21
21
  }
22
+ export function isTokenNearExpiry(token, thresholdMs = 5 * 60 * 1000) {
23
+ if (!token)
24
+ return false;
25
+ try {
26
+ const claims = decodeJwtClaims(token);
27
+ if (!claims.exp)
28
+ return false;
29
+ return Date.now() + thresholdMs > claims.exp * 1000;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
22
35
  export async function startDeviceAuth() {
23
36
  const missing = getMissingEnv(AUTH_REQUIRED_ENV);
24
37
  if (missing.length > 0) {
@@ -27,7 +40,7 @@ export async function startDeviceAuth() {
27
40
  const { auth0ClientId, auth0Audience } = getConfig();
28
41
  const body = new URLSearchParams();
29
42
  body.set("client_id", auth0ClientId);
30
- body.set("scope", "openid profile email");
43
+ body.set("scope", "openid profile email offline_access");
31
44
  if (auth0Audience) {
32
45
  body.set("audience", auth0Audience);
33
46
  }
@@ -153,3 +166,28 @@ export async function authenticateWithDeviceFlow(log) {
153
166
  timestamp: Date.now(),
154
167
  };
155
168
  }
169
+ export async function refreshAccessToken(auth) {
170
+ if (!auth.refreshToken) {
171
+ throw new Error("No refresh token available. Please login again.");
172
+ }
173
+ const body = new URLSearchParams();
174
+ body.set("grant_type", "refresh_token");
175
+ body.set("refresh_token", auth.refreshToken);
176
+ body.set("client_id", getConfig().auth0ClientId);
177
+ const response = await fetch(`${auth0BaseUrl()}/oauth/token`, {
178
+ method: "POST",
179
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
180
+ body,
181
+ });
182
+ const raw = (await response.json());
183
+ if (!response.ok || !raw.id_token || !raw.access_token) {
184
+ throw new Error(`Token refresh failed: ${raw.error ?? response.statusText}`);
185
+ }
186
+ return {
187
+ ...auth,
188
+ authToken: raw.access_token,
189
+ idToken: raw.id_token,
190
+ refreshToken: raw.refresh_token ?? auth.refreshToken,
191
+ timestamp: Date.now(),
192
+ };
193
+ }
@@ -1,4 +1,6 @@
1
1
  import { getConfig } from "../config/env.js";
2
+ import { refreshAccessToken } from "./device-auth.js";
3
+ import { saveAuthConfig } from "./auth-store.js";
2
4
  const DEFAULT_APP_NAME = "SRI Agent";
3
5
  export class StreamError extends Error {
4
6
  reason;
@@ -21,7 +23,7 @@ function opelBase() {
21
23
  }
22
24
  function ensureAuth(auth) {
23
25
  const token = auth.idToken ?? auth.authToken;
24
- const userId = auth.userId ?? auth.userEmail;
26
+ const userId = auth.userEmail ?? auth.userId;
25
27
  if (!token) {
26
28
  throw new Error("Missing auth token. Run /login.");
27
29
  }
@@ -48,6 +50,38 @@ function headers(auth, includeTenant = true) {
48
50
  }
49
51
  return out;
50
52
  }
53
+ export async function refreshAndUpdateAuth(auth) {
54
+ try {
55
+ const refreshed = await refreshAccessToken(auth);
56
+ await saveAuthConfig(refreshed);
57
+ return refreshed;
58
+ }
59
+ catch (error) {
60
+ const msg = error instanceof Error ? error.message : String(error);
61
+ throw new StreamError(`Token refresh failed: ${msg}. Please run /login again.`, {
62
+ reason: "http_error",
63
+ status: 401,
64
+ });
65
+ }
66
+ }
67
+ async function fetchWithAutoRefresh(auth, url, options) {
68
+ let response = await fetch(url, options);
69
+ if (response.status === 401) {
70
+ try {
71
+ auth = await refreshAndUpdateAuth(auth);
72
+ const newHeaders = { ...options.headers };
73
+ const token = auth.idToken ?? auth.authToken;
74
+ if (token) {
75
+ newHeaders.Authorization = `Bearer ${token}`;
76
+ }
77
+ response = await fetch(url, { ...options, headers: newHeaders });
78
+ }
79
+ catch {
80
+ return response;
81
+ }
82
+ }
83
+ return response;
84
+ }
51
85
  async function parseJsonResponse(response) {
52
86
  if (!response.ok) {
53
87
  const text = await response.text();
@@ -159,6 +193,19 @@ function parseToolNameFromText(value) {
159
193
  return callMatch[1];
160
194
  return "";
161
195
  }
196
+ export async function listApps(auth) {
197
+ const url = `${opelBase()}/apps`;
198
+ const response = await fetchWithAutoRefresh(auth, url, {
199
+ method: "GET",
200
+ headers: headers(auth),
201
+ });
202
+ if (!response.ok) {
203
+ const text = await response.text();
204
+ throw new Error(`Failed to load agents (${response.status}): ${text}`);
205
+ }
206
+ const payload = await parseJsonResponse(response);
207
+ return payload.apps ?? [DEFAULT_APP_NAME];
208
+ }
162
209
  function normalizeWorkflowEvent(type, content, details) {
163
210
  return {
164
211
  id: `${type}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
@@ -171,7 +218,7 @@ function normalizeWorkflowEvent(type, content, details) {
171
218
  export async function listSessions(auth, limit = 20, offset = 0) {
172
219
  const { userId } = ensureAuth(auth);
173
220
  const url = `${opelBase()}/sessions/?user_id=${encodeURIComponent(userId)}&limit=${limit}&offset=${offset}`;
174
- const response = await fetch(url, {
221
+ const response = await fetchWithAutoRefresh(auth, url, {
175
222
  method: "GET",
176
223
  headers: headers(auth),
177
224
  });
@@ -190,7 +237,7 @@ export async function listSessions(auth, limit = 20, offset = 0) {
190
237
  }))
191
238
  .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
192
239
  }
193
- export async function createSession(auth, appName = DEFAULT_APP_NAME, clusterId) {
240
+ export async function createSession(auth, appName = DEFAULT_APP_NAME, clusterId, modelId) {
194
241
  const { userId, tenantId } = ensureAuth(auth);
195
242
  if (!tenantId) {
196
243
  throw new Error("Missing tenant ID. Run /login.");
@@ -208,11 +255,12 @@ export async function createSession(auth, appName = DEFAULT_APP_NAME, clusterId)
208
255
  session_created_at: new Date().toISOString(),
209
256
  client_type: "rubix-cli",
210
257
  ...(clusterId ? { cluster_id: clusterId, default_namespace: "default" } : {}),
258
+ ...(modelId ? { model_id: modelId } : {}),
211
259
  ...(auth.userRole ? { user_role: auth.userRole } : {}),
212
260
  ...(auth.tenantPlan ? { tenant_plan: auth.tenantPlan } : {}),
213
261
  },
214
262
  };
215
- const response = await fetch(`${opelBase()}/sessions/`, {
263
+ const response = await fetchWithAutoRefresh(auth, `${opelBase()}/sessions/`, {
216
264
  method: "POST",
217
265
  headers: headers(auth),
218
266
  body: JSON.stringify(payload),
@@ -223,9 +271,9 @@ export async function createSession(auth, appName = DEFAULT_APP_NAME, clusterId)
223
271
  }
224
272
  return parsed.id;
225
273
  }
226
- export async function updateSessionState(auth, sessionId, state) {
227
- const url = `${opelBase()}/sessions/${encodeURIComponent(sessionId)}?app_name=${encodeURIComponent(DEFAULT_APP_NAME)}`;
228
- const response = await fetch(url, {
274
+ export async function updateSessionState(auth, sessionId, state, appName = DEFAULT_APP_NAME) {
275
+ const url = `${opelBase()}/sessions/${encodeURIComponent(sessionId)}?app_name=${encodeURIComponent(appName)}`;
276
+ const response = await fetchWithAutoRefresh(auth, url, {
229
277
  method: "PUT",
230
278
  headers: headers(auth),
231
279
  body: JSON.stringify({ state }),
@@ -235,17 +283,43 @@ export async function updateSessionState(auth, sessionId, state) {
235
283
  throw new Error(`Failed to update session (${response.status}): ${text}`);
236
284
  }
237
285
  }
238
- export async function getOrCreateSession(auth, preferredId, clusterId) {
286
+ export async function hasSessionMessages(auth, sessionId) {
287
+ try {
288
+ const { userId } = ensureAuth(auth);
289
+ // Fetch just 1 history event — cheapest possible check.
290
+ const url = `${opelBase()}/sessions/${encodeURIComponent(sessionId)}/chat-history?user_id=${encodeURIComponent(userId)}&limit=1&offset=0&format=detailed&order_desc=false`;
291
+ const response = await fetchWithAutoRefresh(auth, url, {
292
+ method: "GET",
293
+ headers: headers(auth),
294
+ });
295
+ if (!response.ok)
296
+ return false;
297
+ const payload = (await response.json());
298
+ return (payload.chat_history?.length ?? 0) > 0;
299
+ }
300
+ catch {
301
+ // If we can't determine, assume no messages so we reuse rather than spam new sessions.
302
+ return false;
303
+ }
304
+ }
305
+ export async function getOrCreateSession(auth, preferredId, clusterId, appName = DEFAULT_APP_NAME) {
239
306
  if (preferredId)
240
307
  return preferredId;
241
308
  const sessions = await listSessions(auth, 50, 0);
242
309
  if (sessions.length > 0) {
243
- const recent = sessions[0];
310
+ // Attempt to find the most recent empty session for the same app
311
+ // Note: since list_sessions merges across apps now, we iterate
312
+ // until we find one that matches our appName.
313
+ const recent = sessions.find(s => s.appName === appName) ?? sessions[0];
244
314
  // Reuse the session only if it has matching cluster context (or no cluster was requested)
245
- if (!clusterId || recent.clusterId === clusterId)
246
- return recent.id;
315
+ // AND it has no messages — otherwise start fresh.
316
+ if (!clusterId || recent.clusterId === clusterId) {
317
+ const isEmpty = !(await hasSessionMessages(auth, recent.id));
318
+ if (isEmpty)
319
+ return recent.id;
320
+ }
247
321
  }
248
- return createSession(auth, undefined, clusterId);
322
+ return createSession(auth, appName, clusterId);
249
323
  }
250
324
  const HEALTHY_STATUSES = new Set(["connected", "healthy", "active"]);
251
325
  export async function listClusters(auth) {
@@ -257,7 +331,7 @@ export async function listClusters(auth) {
257
331
  throw new Error("Missing tenant ID. Run /login.");
258
332
  }
259
333
  const url = `${authBase.replace(/\/+$/, "")}/clusters/?page=1&page_size=50`;
260
- const response = await fetch(url, {
334
+ const response = await fetchWithAutoRefresh(auth, url, {
261
335
  method: "GET",
262
336
  headers: {
263
337
  Authorization: `Bearer ${token}`,
@@ -284,6 +358,75 @@ export async function listClusters(auth) {
284
358
  export function firstHealthyCluster(clusters) {
285
359
  return clusters.find((c) => HEALTHY_STATUSES.has(c.status)) ?? clusters[0] ?? null;
286
360
  }
361
+ export async function listModels(auth) {
362
+ const url = `${opelBase()}/models`;
363
+ const response = await fetchWithAutoRefresh(auth, url, {
364
+ method: "GET",
365
+ headers: headers(auth, false),
366
+ });
367
+ if (!response.ok) {
368
+ const text = await response.text();
369
+ throw new StreamError(`Failed to load models (${response.status}): ${text}`, {
370
+ status: response.status,
371
+ reason: "http_error",
372
+ });
373
+ }
374
+ const payload = await parseJsonResponse(response);
375
+ return (payload.models ?? [])
376
+ .filter((m) => !!m?.id)
377
+ .map((m) => ({
378
+ id: m.id ?? "",
379
+ model: m.model ?? "",
380
+ display_name: m.display_name ?? "",
381
+ description: m.description,
382
+ thinking_supported: m.thinking_supported ?? false,
383
+ agent: m.agent ?? "sre_agent",
384
+ default: m.default ?? false,
385
+ experimental: m.experimental,
386
+ }));
387
+ }
388
+ export async function setSessionModel(auth, sessionId, modelId, appName = DEFAULT_APP_NAME) {
389
+ const { userId } = ensureAuth(auth);
390
+ const url = `${opelBase()}/sessions/${encodeURIComponent(sessionId)}/model`;
391
+ const response = await fetchWithAutoRefresh(auth, url, {
392
+ method: "POST",
393
+ headers: headers(auth, true),
394
+ body: JSON.stringify({
395
+ user_id: userId,
396
+ model_id: modelId,
397
+ app_name: appName,
398
+ }),
399
+ });
400
+ if (!response.ok) {
401
+ const text = await response.text();
402
+ let errorMsg = `HTTP ${response.status}: ${text || response.statusText}`;
403
+ try {
404
+ const errorPayload = JSON.parse(text);
405
+ const error = errorPayload.detail?.error ?? "Unknown error";
406
+ const available = errorPayload.detail?.available ?? [];
407
+ if (available.length > 0) {
408
+ errorMsg = `${error}. Available: ${available.join(", ")}`;
409
+ }
410
+ else {
411
+ errorMsg = error;
412
+ }
413
+ }
414
+ catch {
415
+ // Fall back to generic error message
416
+ }
417
+ throw new StreamError(errorMsg, {
418
+ status: response.status,
419
+ reason: "http_error",
420
+ });
421
+ }
422
+ const parsed = await parseJsonResponse(response);
423
+ return {
424
+ modelId: parsed.model_id ?? modelId,
425
+ model: parsed.model ?? "",
426
+ displayName: parsed.display_name ?? "",
427
+ thinkingSupported: parsed.thinking_supported ?? false,
428
+ };
429
+ }
287
430
  function parseParts(content) {
288
431
  if (!content)
289
432
  return [];
@@ -301,7 +444,7 @@ function parseParts(content) {
301
444
  export async function fetchChatHistory(auth, sessionId, limit = 50) {
302
445
  const { userId } = ensureAuth(auth);
303
446
  const url = `${opelBase()}/sessions/${encodeURIComponent(sessionId)}/chat-history?user_id=${encodeURIComponent(userId)}&limit=${limit}&offset=0&format=detailed&order_desc=false`;
304
- const response = await fetch(url, {
447
+ const response = await fetchWithAutoRefresh(auth, url, {
305
448
  method: "GET",
306
449
  headers: headers(auth),
307
450
  });
@@ -318,15 +461,25 @@ export async function fetchChatHistory(auth, sessionId, limit = 50) {
318
461
  let text = "";
319
462
  const workflow = [];
320
463
  for (const part of parts) {
321
- if (part.thought === true)
464
+ if (part.thought === true) {
465
+ if (typeof part.text === "string" && part.text.trim()) {
466
+ workflow.push({
467
+ id: `hist-th-${idx}-${workflow.length}`,
468
+ type: "thought",
469
+ content: part.text.trim(),
470
+ ts,
471
+ });
472
+ }
322
473
  continue;
474
+ }
323
475
  const fc = part.functionCall ?? part.function_call;
324
476
  if (fc) {
325
477
  const name = typeof fc.name === "string" ? fc.name : "tool";
478
+ const argsStr = fc.args && Object.keys(fc.args).length > 0 ? JSON.stringify(fc.args) : "";
326
479
  workflow.push({
327
480
  id: `hist-fc-${idx}-${name}`,
328
481
  type: "function_call",
329
- content: name,
482
+ content: argsStr,
330
483
  ts,
331
484
  details: { name, id: fc.id },
332
485
  });
@@ -338,7 +491,7 @@ export async function fetchChatHistory(auth, sessionId, limit = 50) {
338
491
  workflow.push({
339
492
  id: `hist-fr-${idx}-${name}`,
340
493
  type: "function_response",
341
- content: typeof fr.response === "string" ? fr.response : `[${name}]`,
494
+ content: typeof fr.response === "string" ? fr.response : (fr.response ? JSON.stringify(fr.response) : `[${name}]`),
342
495
  ts,
343
496
  details: { name, id: fr.id },
344
497
  });
@@ -362,7 +515,7 @@ export async function fetchChatHistory(auth, sessionId, limit = 50) {
362
515
  }
363
516
  export async function streamChat(input, callbacks = {}) {
364
517
  const { userId } = ensureAuth(input.auth);
365
- const response = await fetch(`${opelBase()}/chat/${encodeURIComponent(userId)}/session/${encodeURIComponent(input.sessionId)}`, {
518
+ const response = await fetchWithAutoRefresh(input.auth, `${opelBase()}/chat/${encodeURIComponent(userId)}/session/${encodeURIComponent(input.sessionId)}`, {
366
519
  method: "POST",
367
520
  headers: headers(input.auth, true),
368
521
  signal: input.signal,
@@ -376,6 +529,8 @@ export async function streamChat(input, callbacks = {}) {
376
529
  streaming: true,
377
530
  minify: true,
378
531
  maxTextLen: -1,
532
+ ...(input.stateDelta ? { stateDelta: input.stateDelta } : {}),
533
+ ...(input.modelOverride ? { modelOverride: input.modelOverride } : {}),
379
534
  }),
380
535
  });
381
536
  if (!response.ok) {
@@ -453,8 +608,8 @@ export async function streamChat(input, callbacks = {}) {
453
608
  hasWorkflowEvents = true;
454
609
  const name = asText(functionCall.name) || "tool";
455
610
  const args = functionCall.args ?? {};
456
- const prettyArgs = Object.keys(args).length > 0 ? `: ${JSON.stringify(args)}` : "";
457
- callbacks.onWorkflow?.(normalizeWorkflowEvent("function_call", `${name}${prettyArgs}`, {
611
+ const argsStr = Object.keys(args).length > 0 ? JSON.stringify(args) : "";
612
+ callbacks.onWorkflow?.(normalizeWorkflowEvent("function_call", argsStr, {
458
613
  name,
459
614
  id: functionCall.id,
460
615
  }));