@rubixkube/rubix 0.0.2 → 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 +29 -0
- package/LICENSE +33 -0
- package/README.md +29 -12
- package/dist/cli.js +7 -0
- package/dist/commands/login.js +23 -2
- package/dist/commands/model.js +84 -0
- package/dist/core/device-auth.js +39 -1
- package/dist/core/rubix-api.js +175 -20
- package/dist/core/session-store.js +36 -0
- package/dist/core/settings.js +25 -0
- package/dist/core/update-check.js +51 -0
- package/dist/core/whats-new.js +56 -0
- package/dist/ui/App.js +305 -39
- package/dist/ui/components/BrandPanel.js +1 -1
- package/dist/ui/components/ChatTranscript.js +61 -8
- package/dist/ui/components/Composer.js +66 -7
- package/dist/ui/components/DashboardPanel.js +1 -1
- package/dist/ui/components/SplashScreen.js +2 -8
- package/dist/ui/hooks/useBracketedPaste.js +27 -0
- package/package.json +6 -4
- package/patches/ink-multiline-input+0.1.0.patch +246 -16
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
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@rubixkube/rubix)
|
|
4
|
+
[](https://www.npmjs.com/package/@rubixkube/rubix)
|
|
5
|
+
[](https://rubixkube.ai)
|
|
4
6
|
|
|
5
|
-
|
|
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
|
+

|
|
6
12
|
|
|
7
13
|
## Install
|
|
8
14
|
|
|
@@ -18,24 +24,35 @@ npx @rubixkube/rubix
|
|
|
18
24
|
|
|
19
25
|
## Quick Start
|
|
20
26
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
##
|
|
41
|
+
## What You Can Do
|
|
26
42
|
|
|
27
|
-
- **
|
|
28
|
-
- **
|
|
29
|
-
- **
|
|
30
|
-
- **
|
|
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)
|
|
52
|
+
- [RubixKube account](https://console.rubixkube.ai) — free to sign up
|
|
36
53
|
|
|
37
54
|
## Links
|
|
38
55
|
|
|
39
|
-
- [
|
|
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) => {
|
package/dist/commands/login.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/core/device-auth.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/core/rubix-api.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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(
|
|
228
|
-
const response = await
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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,
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
457
|
-
callbacks.onWorkflow?.(normalizeWorkflowEvent("function_call",
|
|
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
|
}));
|