lyceum-cli 1.2.2__tar.gz → 1.2.4__tar.gz
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.
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/PKG-INFO +1 -1
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/_version.py +1 -1
- lyceum_cli-1.2.4/lyceum/external/auth/callback_page.py +308 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/auth/login.py +22 -77
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/compute/inference/chat.py +4 -4
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/compute/inference/claude_code.py +1 -1
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/compute/inference/infer.py +54 -11
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/orgs/management.py +1 -1
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/shared/config.py +1 -1
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum_cli.egg-info/PKG-INFO +1 -1
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum_cli.egg-info/SOURCES.txt +1 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/pyproject.toml +5 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/tests/test_dedicated_deploy.py +26 -2
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/README.md +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/__main__.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/__init__.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/auth/__init__.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/auth/environment.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/auth/keys.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/compute/__init__.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/compute/execution/__init__.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/compute/execution/config.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/compute/execution/docker.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/compute/execution/docker_compose.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/compute/execution/gpu_selection.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/compute/execution/jobs.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/compute/execution/notebook.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/compute/execution/python.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/compute/inference/__init__.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/orgs/__init__.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/storage/__init__.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/storage/block.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/storage/files.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/vms/__init__.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/vms/management.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/main.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/shared/__init__.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/shared/http.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/shared/imports.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/shared/orgs.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/shared/run_options.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/shared/streaming.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/shared/ui.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum_cli.egg-info/dependency_links.txt +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum_cli.egg-info/entry_points.txt +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum_cli.egg-info/requires.txt +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum_cli.egg-info/top_level.txt +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/setup.cfg +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/tests/test_block_storage.py +0 -0
- {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/tests/test_orgs.py +0 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Browser page shown by `lyceum auth login` after the dashboard hands back the session.
|
|
2
|
+
|
|
3
|
+
The same frame as the dashboard's sign-in (`SignInFrame`): a white pane with one centred column on the left, and on
|
|
4
|
+
wide screens the token grid on the right, a board of GPU tiles with tokens gliding along the rows. Everything is
|
|
5
|
+
inline, so the page works offline and loads nothing from a CDN.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import html
|
|
9
|
+
|
|
10
|
+
_WORDMARK = (
|
|
11
|
+
'<svg class="wordmark" viewBox="0 0 544 82" role="img" aria-label="Lyceum" '
|
|
12
|
+
'xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path d="M407.25 47.5198C407.25 58.0085 399.555 63.9212 388.731 63.9212C377.907 63.9212 370.322 58.0085 370.322 47.5198V1.89453H346V46.6286C346 68.9427 362.401 81.7698 388.281 81.7698C414.16 81.7698 430.897 68.9381 430.897 46.7388V1.89453H407.246V47.5244L407.25 47.5198Z"/><path d="M259.235 79.9873H334.87V63.811H283.442V48.1906H322.153V32.3497H283.442V18.4016H334.87V1.89453H259.235V79.9873Z"/><path d="M248.108 33.8043C244.864 12.7536 226.639 0 202.248 0C174.692 0 156.062 15.5101 156.062 40.9438C156.062 66.3774 174.692 81.8876 202.133 81.8876C226.602 81.8876 244.612 69.0743 248.021 48.0832H223.708C221.324 58.7097 213.031 64.4846 202.584 64.4846C189.642 64.4846 181.051 56.339 181.051 40.9438C181.051 25.5485 189.642 17.5178 202.694 17.5178C213.017 17.5178 221.314 23.2009 223.795 33.8043H248.108Z"/><path d="M24.207 1.89453H0V79.9873H74.9687V63.3607H24.207V1.89453Z"/><path d="M133.818 1.89453L109.455 49.5964H108.844L84.977 1.95426L84.9448 1.89453H59.0977L86.0842 52.4357L86.1163 52.4954H108.022L94.0506 79.7943L93.9679 79.9597H117.183L159.67 2.05992L159.762 1.89453H133.818Z"/><path d="M510.752 1.89453L501.83 27.6636L493.431 50.9977H492.375L484.202 27.6636L475.165 1.89453H442.031V79.9873H463.229L463.119 41.2717L463.008 24.9851L468.471 41.2717L481.973 79.9873H481.983H503.272H503.281L516.779 41.2717L522.246 24.9851L522.132 41.2717L521.911 79.9873H544V1.89453H510.752Z"/></svg>'
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# Seconds before a successful sign-in closes its tab.
|
|
16
|
+
CLOSE_AFTER = 10
|
|
17
|
+
|
|
18
|
+
_STYLE = """
|
|
19
|
+
:root {
|
|
20
|
+
--paper: #ffffff; --ink: #000114; --ink-2: #3d3e4f; --ink-3: #6b6c7d; --rule: #e7e7e7; --frame: #d3d3dc;
|
|
21
|
+
--accent: #2f0ac2; --wash: #f7f7fa; --bad: #c23934;
|
|
22
|
+
color-scheme: light;
|
|
23
|
+
}
|
|
24
|
+
@media (prefers-color-scheme: dark) {
|
|
25
|
+
:root {
|
|
26
|
+
--paper: #0b0c1a; --ink: #f2f2f5; --ink-2: #c4c5d4; --ink-3: #8e8fa6; --rule: #24263d; --frame: #34375a;
|
|
27
|
+
--accent: #6244f2; --wash: #10111f; --bad: #ef6b62;
|
|
28
|
+
color-scheme: dark;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
* { box-sizing: border-box; }
|
|
32
|
+
html, body { height: 100%; margin: 0; }
|
|
33
|
+
body {
|
|
34
|
+
background: var(--paper); color: var(--ink); overflow: hidden;
|
|
35
|
+
font: 14px/20px Inter, -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Segoe UI", sans-serif;
|
|
36
|
+
-webkit-font-smoothing: antialiased;
|
|
37
|
+
}
|
|
38
|
+
canvas { position: fixed; inset: 0; display: block; width: 100%; height: 100%; }
|
|
39
|
+
.stage {
|
|
40
|
+
position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; padding: 16px;
|
|
41
|
+
pointer-events: none;
|
|
42
|
+
}
|
|
43
|
+
.card {
|
|
44
|
+
position: relative; width: 100%; max-width: 520px; pointer-events: auto; text-align: center;
|
|
45
|
+
background: var(--paper); border: 1px solid var(--frame); box-shadow: 0 0 0 20px var(--paper);
|
|
46
|
+
animation: enter 240ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
|
47
|
+
}
|
|
48
|
+
@keyframes enter { from { opacity: 0; transform: scale(0.96); } }
|
|
49
|
+
.body { padding: 48px 40px 44px; }
|
|
50
|
+
.wordmark { display: block; height: 18px; width: auto; margin: 0 auto 32px; }
|
|
51
|
+
h1 { margin: 0; font-size: 44px; line-height: 48px; font-weight: 400; letter-spacing: -0.025em; text-wrap: balance; }
|
|
52
|
+
.sub { margin: 16px 0 0; font-size: 17px; line-height: 24px; color: var(--ink-2); text-wrap: pretty; }
|
|
53
|
+
.sub strong { color: var(--ink); font-weight: 500; }
|
|
54
|
+
.details { margin: 28px 0 0; border: 1px solid var(--rule); text-align: left; font-size: 15px; line-height: 22px; }
|
|
55
|
+
.details div { display: flex; justify-content: space-between; gap: 16px; padding: 10px 14px; }
|
|
56
|
+
.details div + div { border-top: 1px solid var(--rule); }
|
|
57
|
+
.details dt { color: var(--ink-3); flex: none; }
|
|
58
|
+
.details dd { margin: 0; min-width: 0; overflow-wrap: anywhere; text-align: right; color: var(--ink-2); }
|
|
59
|
+
.details strong { color: var(--ink); font-weight: 500; }
|
|
60
|
+
.note { margin: 8px 0 0; font-size: 14px; line-height: 20px; color: var(--ink-3); }
|
|
61
|
+
.count { font-variant-numeric: tabular-nums; }
|
|
62
|
+
.bar { height: 2px; margin: 32px auto 0; max-width: 240px; background: var(--rule); overflow: hidden; }
|
|
63
|
+
.bar i { display: block; height: 100%; background: var(--accent); transform-origin: left; animation: drain __CLOSE__s linear forwards; }
|
|
64
|
+
@keyframes drain { from { transform: scaleX(1); } to { transform: scaleX(0); } }
|
|
65
|
+
pre {
|
|
66
|
+
margin: 20px 0 0; padding: 12px 14px; text-align: left; white-space: pre-wrap; word-break: break-word;
|
|
67
|
+
border: 1px solid var(--rule); background: var(--wash); color: var(--bad);
|
|
68
|
+
font: 12px/18px "JetBrains Mono", ui-monospace, Menlo, monospace;
|
|
69
|
+
}
|
|
70
|
+
code { font: 12px/18px "JetBrains Mono", ui-monospace, Menlo, monospace; color: var(--ink); }
|
|
71
|
+
a { color: inherit; text-decoration: underline; text-decoration-color: var(--frame); text-underline-offset: 3px; }
|
|
72
|
+
a:hover { text-decoration-color: currentColor; }
|
|
73
|
+
@media (max-width: 480px) { .details div { flex-direction: column; gap: 2px; } .details dd { text-align: left; } h1 { font-size: 34px; line-height: 40px; } .body { padding: 36px 20px 32px; } .card { box-shadow: 0 0 0 12px var(--paper); } }
|
|
74
|
+
@media (prefers-reduced-motion: reduce) { .bar i, .card { animation: none; } }
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
# The dashboard's token grid (TokenGrid.tsx), in plain JavaScript: square GPU tiles on a bus of hairlines, tokens
|
|
78
|
+
# gliding along the rows and warming each tile they pass. The pointer warms the tiles round it and hurries its row; a
|
|
79
|
+
# click sends a ripple. Still with reduced motion, and no frames while the tab is hidden.
|
|
80
|
+
_GRID = """
|
|
81
|
+
(function () {
|
|
82
|
+
var canvas = document.querySelector('canvas');
|
|
83
|
+
if (!canvas) return;
|
|
84
|
+
var ctx = canvas.getContext('2d');
|
|
85
|
+
var TILE = 28, GAP = 28, PITCH = TILE + GAP, COOL = 0.9, REACH = 1.8, HURRY = 2.6, RIPPLE = 9;
|
|
86
|
+
var w = 0, h = 0, sim = null, pointer = null, raf = 0, last = 0, ink = {};
|
|
87
|
+
var still = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
88
|
+
function seeded(s) { return function () { s = (s + 0x6d2b79f5) >>> 0; var x = Math.imul(s ^ (s >>> 15), 1 | s);
|
|
89
|
+
x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x; return ((x ^ (x >>> 14)) >>> 0) / 4294967296; }; }
|
|
90
|
+
function readInk() { var css = getComputedStyle(document.documentElement);
|
|
91
|
+
ink = { paper: css.getPropertyValue('--paper').trim(), rule: css.getPropertyValue('--rule').trim(),
|
|
92
|
+
accent: css.getPropertyValue('--accent').trim() }; }
|
|
93
|
+
function build() {
|
|
94
|
+
var cols = Math.ceil((w + GAP) / PITCH) + 1;
|
|
95
|
+
var rows = Math.ceil((h + GAP) / PITCH) + 1;
|
|
96
|
+
var rand = seeded(20261016), tokens = [], n = Math.max(4, Math.round(rows * 1.6));
|
|
97
|
+
for (var i = 0; i < n; i++) tokens.push({ lane: Math.floor(rand() * rows), x: rand() * w, v: 22 + rand() * 34 });
|
|
98
|
+
sim = { cols: cols, rows: rows, ox: Math.round((w - (cols * PITCH - GAP)) / 2),
|
|
99
|
+
oy: Math.round((h - (rows * PITCH - GAP)) / 2), tokens: tokens, heat: new Float32Array(cols * rows),
|
|
100
|
+
lanes: new Float32Array(rows), ripples: [] };
|
|
101
|
+
for (var k = 0; k < 150; k++) step(1 / 30);
|
|
102
|
+
}
|
|
103
|
+
function at(x, y) { return { c: (x - sim.ox - TILE / 2) / PITCH, r: (y - sim.oy - TILE / 2) / PITCH }; }
|
|
104
|
+
function step(dt) {
|
|
105
|
+
var decay = Math.exp(-dt / COOL), i, r, c;
|
|
106
|
+
for (i = 0; i < sim.heat.length; i++) sim.heat[i] *= decay;
|
|
107
|
+
var hov = -1;
|
|
108
|
+
if (pointer) { var p = at(pointer.x, pointer.y), pr = Math.round(p.r); if (pr >= 0 && pr < sim.rows) hov = pr; }
|
|
109
|
+
var toward = Math.min(1, dt / 0.18);
|
|
110
|
+
for (r = 0; r < sim.rows; r++) sim.lanes[r] += ((r === hov ? 1 : 0) - sim.lanes[r]) * toward;
|
|
111
|
+
sim.tokens = sim.tokens.filter(function (t) {
|
|
112
|
+
t.x += t.v * dt * (t.lane === hov ? HURRY : 1);
|
|
113
|
+
if (t.x > w + 40) { if (t.once) return false; t.x = -40 - ((t.v * 7) % 120); }
|
|
114
|
+
var col = Math.floor((t.x - sim.ox) / PITCH);
|
|
115
|
+
if (col >= 0 && col < sim.cols && t.x - sim.ox - col * PITCH <= TILE) sim.heat[t.lane * sim.cols + col] = 1;
|
|
116
|
+
return true;
|
|
117
|
+
});
|
|
118
|
+
if (pointer) {
|
|
119
|
+
var a = at(pointer.x, pointer.y);
|
|
120
|
+
for (r = Math.max(0, Math.floor(a.r - REACH)); r <= Math.min(sim.rows - 1, Math.ceil(a.r + REACH)); r++)
|
|
121
|
+
for (c = Math.max(0, Math.floor(a.c - REACH)); c <= Math.min(sim.cols - 1, Math.ceil(a.c + REACH)); c++) {
|
|
122
|
+
var near = 1 - Math.hypot(c - a.c, r - a.r) / REACH;
|
|
123
|
+
if (near > 0) sim.heat[r * sim.cols + c] = Math.max(sim.heat[r * sim.cols + c], 0.8 * near * near);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
sim.ripples = sim.ripples.filter(function (rp) {
|
|
127
|
+
var before = rp.age * RIPPLE; rp.age += dt; var front = rp.age * RIPPLE;
|
|
128
|
+
for (var r2 = 0; r2 < sim.rows; r2++) for (var c2 = 0; c2 < sim.cols; c2++) {
|
|
129
|
+
var ring = Math.max(Math.abs(c2 - rp.c), Math.abs(r2 - rp.r));
|
|
130
|
+
if (ring <= front && ring <= rp.reach && ring > before - 1e-6)
|
|
131
|
+
sim.heat[r2 * sim.cols + c2] = Math.max(sim.heat[r2 * sim.cols + c2], 1 - ring / (rp.reach + 1));
|
|
132
|
+
}
|
|
133
|
+
return front <= rp.reach;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
function paint() {
|
|
137
|
+
var r, c, x, y;
|
|
138
|
+
ctx.globalAlpha = 1; ctx.fillStyle = ink.paper; ctx.fillRect(0, 0, w, h);
|
|
139
|
+
for (r = 0; r < sim.rows; r++) {
|
|
140
|
+
y = sim.oy + r * PITCH + TILE / 2;
|
|
141
|
+
[[ink.rule, 1], [ink.accent, sim.lanes[r] * 0.55]].forEach(function (pair) {
|
|
142
|
+
if (pair[1] < 0.01) return;
|
|
143
|
+
ctx.globalAlpha = pair[1]; ctx.fillStyle = pair[0]; ctx.fillRect(0, y, sim.ox, 1);
|
|
144
|
+
for (c = 0; c < sim.cols; c++) { x = sim.ox + c * PITCH + TILE; ctx.fillRect(x, y, c === sim.cols - 1 ? w - x : GAP, 1); }
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
var core = 8;
|
|
148
|
+
for (r = 0; r < sim.rows; r++) for (c = 0; c < sim.cols; c++) {
|
|
149
|
+
x = sim.ox + c * PITCH; y = sim.oy + r * PITCH;
|
|
150
|
+
ctx.globalAlpha = 1; ctx.strokeStyle = ink.rule; ctx.lineWidth = 1; ctx.strokeRect(x + 0.5, y + 0.5, TILE - 1, TILE - 1);
|
|
151
|
+
var heat = sim.heat[r * sim.cols + c];
|
|
152
|
+
if (heat > 0.03) {
|
|
153
|
+
ctx.globalAlpha = heat * 0.9; ctx.fillStyle = ink.accent;
|
|
154
|
+
ctx.fillRect(x + 5, y + 5, core, core); ctx.fillRect(x + TILE - 5 - core, y + 5, core, core);
|
|
155
|
+
ctx.fillRect(x + 5, y + TILE - 5 - core, core, core); ctx.fillRect(x + TILE - 5 - core, y + TILE - 5 - core, core, core);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (pointer) {
|
|
159
|
+
var a = at(pointer.x, pointer.y), pc = Math.round(a.c), pr = Math.round(a.r);
|
|
160
|
+
if (pc >= 0 && pc < sim.cols && pr >= 0 && pr < sim.rows && Math.hypot(pc - a.c, pr - a.r) < 0.6) {
|
|
161
|
+
ctx.globalAlpha = 1; ctx.strokeStyle = ink.accent;
|
|
162
|
+
ctx.strokeRect(sim.ox + pc * PITCH + 0.5, sim.oy + pr * PITCH + 0.5, TILE - 1, TILE - 1);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
ctx.fillStyle = ink.accent;
|
|
166
|
+
sim.tokens.forEach(function (t) {
|
|
167
|
+
var ty = sim.oy + t.lane * PITCH + TILE / 2 - 2;
|
|
168
|
+
ctx.globalAlpha = 1; ctx.fillRect(t.x, ty, 4, 4);
|
|
169
|
+
ctx.globalAlpha = 0.4; ctx.fillRect(t.x - 8, ty, 4, 4);
|
|
170
|
+
ctx.globalAlpha = 0.16; ctx.fillRect(t.x - 16, ty, 4, 4);
|
|
171
|
+
});
|
|
172
|
+
ctx.globalAlpha = 1;
|
|
173
|
+
}
|
|
174
|
+
function size() {
|
|
175
|
+
var rect = canvas.getBoundingClientRect(), dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
176
|
+
w = Math.round(rect.width); h = Math.round(rect.height);
|
|
177
|
+
canvas.width = Math.max(1, Math.round(w * dpr)); canvas.height = Math.max(1, Math.round(h * dpr));
|
|
178
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
179
|
+
if (w && h) { build(); paint(); }
|
|
180
|
+
}
|
|
181
|
+
function running() { return w > 0 && h > 0 && !document.hidden && !still.matches; }
|
|
182
|
+
function tick(now) {
|
|
183
|
+
raf = 0; if (!running()) return;
|
|
184
|
+
if (!last) last = now;
|
|
185
|
+
var elapsed = now - last;
|
|
186
|
+
if (elapsed >= 1000 / 30 - 1) { step(Math.min(elapsed, 100) / 1000); last = now; paint(); }
|
|
187
|
+
raf = requestAnimationFrame(tick);
|
|
188
|
+
}
|
|
189
|
+
function start() { if (raf) cancelAnimationFrame(raf); raf = 0; last = 0; if (running()) raf = requestAnimationFrame(tick); }
|
|
190
|
+
function local(e) { var b = canvas.getBoundingClientRect(); return { x: e.clientX - b.left, y: e.clientY - b.top }; }
|
|
191
|
+
canvas.addEventListener('pointermove', function (e) { if (!still.matches) pointer = local(e); });
|
|
192
|
+
canvas.addEventListener('pointerleave', function () { pointer = null; });
|
|
193
|
+
canvas.addEventListener('pointerdown', function (e) {
|
|
194
|
+
if (!sim || still.matches) return;
|
|
195
|
+
var p = local(e), a = at(p.x, p.y);
|
|
196
|
+
pulse(5, Math.max(0, Math.min(sim.cols - 1, Math.round(a.c))), Math.max(0, Math.min(sim.rows - 1, Math.round(a.r))));
|
|
197
|
+
});
|
|
198
|
+
function pulse(reach, c, r) {
|
|
199
|
+
if (!sim || still.matches || document.hidden) return;
|
|
200
|
+
sim.ripples.push({ c: c, r: r, age: 0, reach: reach });
|
|
201
|
+
var from = sim.ox + c * PITCH + TILE;
|
|
202
|
+
sim.tokens.push({ lane: r, x: from, v: 150, once: true }, { lane: r, x: from - 20, v: 120, once: true });
|
|
203
|
+
}
|
|
204
|
+
readInk(); size(); start();
|
|
205
|
+
setTimeout(function () { if (sim) pulse(7, Math.floor(sim.cols / 2), Math.floor(sim.rows / 2)); }, 250);
|
|
206
|
+
var rnd = seeded(7);
|
|
207
|
+
setInterval(function () { if (sim) pulse(3, Math.floor(rnd() * sim.cols), Math.floor(rnd() * sim.rows)); }, 2600);
|
|
208
|
+
new ResizeObserver(function () { size(); start(); }).observe(canvas);
|
|
209
|
+
document.addEventListener('visibilitychange', start);
|
|
210
|
+
still.addEventListener('change', start);
|
|
211
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () { readInk(); if (sim) paint(); });
|
|
212
|
+
})();
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
# The session token arrived in the query string: keep it out of the address bar and the history.
|
|
216
|
+
_SCRUB = "if (location.search) history.replaceState(null, '', location.pathname);"
|
|
217
|
+
|
|
218
|
+
# Count down, then close the tab. Browsers only let a page close a tab a script opened, so if the tab is still here a
|
|
219
|
+
# moment later, say so instead of pretending.
|
|
220
|
+
_COUNTDOWN = """
|
|
221
|
+
(function () {
|
|
222
|
+
var left = __CLOSE__, count = document.querySelector('.count'), sub = document.querySelector('.closing');
|
|
223
|
+
var timer = setInterval(function () {
|
|
224
|
+
left -= 1;
|
|
225
|
+
if (left > 0) { count.textContent = left + (left === 1 ? ' second' : ' seconds'); return; }
|
|
226
|
+
clearInterval(timer);
|
|
227
|
+
window.close();
|
|
228
|
+
setTimeout(function () {
|
|
229
|
+
sub.textContent = 'You can close this tab now.';
|
|
230
|
+
var bar = document.querySelector('.bar'); if (bar) bar.style.visibility = 'hidden';
|
|
231
|
+
}, 300);
|
|
232
|
+
}, 1000);
|
|
233
|
+
})();
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
_STYLE_CLOSING = _STYLE.replace("__CLOSE__", str(CLOSE_AFTER))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _page(title: str, body_html: str, scripts: str) -> str:
|
|
241
|
+
return f"""<!DOCTYPE html>
|
|
242
|
+
<html lang="en">
|
|
243
|
+
<head>
|
|
244
|
+
<meta charset="utf-8">
|
|
245
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
246
|
+
<meta name="referrer" content="no-referrer">
|
|
247
|
+
<title>{html.escape(title)} | Lyceum CLI</title>
|
|
248
|
+
<style>{_STYLE_CLOSING}</style>
|
|
249
|
+
</head>
|
|
250
|
+
<body>
|
|
251
|
+
<canvas aria-hidden="true"></canvas>
|
|
252
|
+
<div class="stage">
|
|
253
|
+
<main class="card">
|
|
254
|
+
<div class="body">{_WORDMARK}{body_html}</div>
|
|
255
|
+
</main>
|
|
256
|
+
</div>
|
|
257
|
+
<script>{_SCRUB}{_GRID}{scripts}</script>
|
|
258
|
+
</body>
|
|
259
|
+
</html>"""
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def success_page(
|
|
263
|
+
user: str | None, org: str | None = None, choose_org: bool = False
|
|
264
|
+
) -> str:
|
|
265
|
+
"""`org` is the organization the CLI uses; `choose_org` means it asks in the terminal. Either only for people in
|
|
266
|
+
more than one organization."""
|
|
267
|
+
rows = []
|
|
268
|
+
if user:
|
|
269
|
+
rows.append(("Signed in as", f"<strong>{html.escape(user)}</strong>"))
|
|
270
|
+
if org:
|
|
271
|
+
rows.append(("Organization", f"<strong>{html.escape(org)}</strong>"))
|
|
272
|
+
elif choose_org:
|
|
273
|
+
rows.append(("Organization", "Pick one in your terminal"))
|
|
274
|
+
details = (
|
|
275
|
+
'<dl class="details">'
|
|
276
|
+
+ "".join(f"<div><dt>{k}</dt><dd>{v}</dd></div>" for k, v in rows)
|
|
277
|
+
+ "</dl>"
|
|
278
|
+
if rows
|
|
279
|
+
else ""
|
|
280
|
+
)
|
|
281
|
+
return _page(
|
|
282
|
+
"Signed in",
|
|
283
|
+
"<h1>You're signed in</h1>"
|
|
284
|
+
f"{details}"
|
|
285
|
+
'<p class="sub">Head back to your terminal.</p>'
|
|
286
|
+
f'<p class="note closing">This tab closes in <span class="count">{CLOSE_AFTER} seconds</span>.</p>'
|
|
287
|
+
'<div class="bar" aria-hidden="true"><i></i></div>',
|
|
288
|
+
_COUNTDOWN.replace("__CLOSE__", str(CLOSE_AFTER)),
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def error_page(message: str, status: str = "Sign-in failed") -> str:
|
|
293
|
+
return _page(
|
|
294
|
+
status,
|
|
295
|
+
"<h1>Sign-in didn't work</h1>"
|
|
296
|
+
'<p class="sub">Run <code>lyceum auth login</code> in your terminal to try again.</p>'
|
|
297
|
+
f"<pre>{html.escape(message)}</pre>",
|
|
298
|
+
"",
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def warning_page(heading: str, message: str) -> str:
|
|
303
|
+
return _page(
|
|
304
|
+
heading,
|
|
305
|
+
f"<h1>{html.escape(heading)}</h1>"
|
|
306
|
+
f'<p class="sub">{html.escape(message)} Run <code>lyceum auth login</code> to start again.</p>',
|
|
307
|
+
"",
|
|
308
|
+
)
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"""Authentication commands: login, logout, status"""
|
|
2
2
|
|
|
3
|
-
import html
|
|
4
3
|
import socket
|
|
5
4
|
import sys
|
|
6
5
|
import threading
|
|
@@ -16,6 +15,7 @@ from rich.console import Console
|
|
|
16
15
|
|
|
17
16
|
from ...shared.config import config
|
|
18
17
|
from ...shared.orgs import explain, resolve_org_context
|
|
18
|
+
from .callback_page import error_page, success_page, warning_page
|
|
19
19
|
from .keys import keys_app
|
|
20
20
|
|
|
21
21
|
console = Console()
|
|
@@ -26,48 +26,21 @@ auth_app.add_typer(keys_app, name="keys")
|
|
|
26
26
|
# Global state for the OAuth callback server
|
|
27
27
|
callback_result: dict[str, Any] = {"token": None, "error": None, "received": False}
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return f"""<!DOCTYPE html>
|
|
45
|
-
<html lang="en">
|
|
46
|
-
<head>
|
|
47
|
-
<meta charset="UTF-8">
|
|
48
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
49
|
-
<title>Lyceum CLI - {title}</title>
|
|
50
|
-
<script src="https://cdn.tailwindcss.com"></script>
|
|
51
|
-
</head>
|
|
52
|
-
<body class="min-h-screen bg-gray-50 flex items-center justify-center py-12 px-4">
|
|
53
|
-
<div class="max-w-md w-full bg-white rounded-lg shadow-md p-8 text-center">
|
|
54
|
-
<div class="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-{color}-100 mb-6">
|
|
55
|
-
<svg class="h-8 w-8 text-{color}-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
56
|
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{icon_path}"></path>
|
|
57
|
-
</svg>
|
|
58
|
-
</div>
|
|
59
|
-
<h1 class="text-2xl font-bold text-gray-900 mb-2">{heading}</h1>
|
|
60
|
-
<div class="space-y-3 mb-8 text-gray-600">{sub_html}</div>
|
|
61
|
-
<div class="w-full py-3 px-4 bg-gray-50 text-gray-700 rounded-md border border-gray-200">
|
|
62
|
-
You can close this window now
|
|
63
|
-
</div>
|
|
64
|
-
<div class="mt-8 pt-6 border-t border-gray-200">
|
|
65
|
-
<p class="text-xs text-gray-400">Powered by Lyceum Technology</p>
|
|
66
|
-
</div>
|
|
67
|
-
</div>
|
|
68
|
-
{script}
|
|
69
|
-
</body>
|
|
70
|
-
</html>"""
|
|
29
|
+
|
|
30
|
+
def _org_for_page(token: str) -> dict:
|
|
31
|
+
"""Which organization the sign-in page names, for people in more than one: the pinned one if the new account
|
|
32
|
+
belongs to it, otherwise a note that the terminal asks. Best effort: the page names none on any error."""
|
|
33
|
+
try:
|
|
34
|
+
resp = httpx.get(f"{config.base_url}/api/v2/external/orgs", headers={"Authorization": f"Bearer {token}"}, timeout=4.0)
|
|
35
|
+
orgs = resp.json() if resp.status_code == 200 else []
|
|
36
|
+
except (httpx.HTTPError, ValueError):
|
|
37
|
+
return {}
|
|
38
|
+
if not isinstance(orgs, list) or len(orgs) < 2:
|
|
39
|
+
return {}
|
|
40
|
+
pinned = next((o for o in orgs if o.get("slug") == config.active_org_slug), None)
|
|
41
|
+
if pinned:
|
|
42
|
+
return {"org": pinned.get("name") or pinned["slug"]}
|
|
43
|
+
return {"choose_org": sys.stdin.isatty()}
|
|
71
44
|
|
|
72
45
|
|
|
73
46
|
class CallbackHandler(BaseHTTPRequestHandler):
|
|
@@ -79,7 +52,7 @@ class CallbackHandler(BaseHTTPRequestHandler):
|
|
|
79
52
|
|
|
80
53
|
def _send_page(self, status_code: int, html: str):
|
|
81
54
|
self.send_response(status_code)
|
|
82
|
-
self.send_header("Content-type", "text/html")
|
|
55
|
+
self.send_header("Content-type", "text/html; charset=utf-8")
|
|
83
56
|
self.end_headers()
|
|
84
57
|
try:
|
|
85
58
|
self.wfile.write(html.encode())
|
|
@@ -95,12 +68,7 @@ class CallbackHandler(BaseHTTPRequestHandler):
|
|
|
95
68
|
if parsed_url.path != "/callback":
|
|
96
69
|
self._send_page(
|
|
97
70
|
404,
|
|
98
|
-
|
|
99
|
-
"warning",
|
|
100
|
-
"Page Not Found",
|
|
101
|
-
"Page Not Found",
|
|
102
|
-
"<p>The requested page could not be found.</p>",
|
|
103
|
-
),
|
|
71
|
+
warning_page("Page not found", "This address only handles the sign-in callback."),
|
|
104
72
|
)
|
|
105
73
|
return
|
|
106
74
|
|
|
@@ -114,14 +82,7 @@ class CallbackHandler(BaseHTTPRequestHandler):
|
|
|
114
82
|
|
|
115
83
|
self._send_page(
|
|
116
84
|
200,
|
|
117
|
-
|
|
118
|
-
"success",
|
|
119
|
-
"Authentication Success",
|
|
120
|
-
"Authentication Successful!",
|
|
121
|
-
"<p class='text-lg'>Welcome to Lyceum</p>"
|
|
122
|
-
"<p>You can now close this browser tab and return to the CLI.</p>",
|
|
123
|
-
auto_close=True,
|
|
124
|
-
),
|
|
85
|
+
success_page(callback_result.get("user"), **_org_for_page(callback_result["token"])),
|
|
125
86
|
)
|
|
126
87
|
elif "error" in query_params:
|
|
127
88
|
error = query_params["error"][0]
|
|
@@ -130,13 +91,7 @@ class CallbackHandler(BaseHTTPRequestHandler):
|
|
|
130
91
|
|
|
131
92
|
self._send_page(
|
|
132
93
|
400,
|
|
133
|
-
|
|
134
|
-
"error",
|
|
135
|
-
"Authentication Error",
|
|
136
|
-
"Authentication Failed",
|
|
137
|
-
f"<div class='bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md'><p class='text-sm'>{html.escape(error)}</p></div>" # noqa: E501
|
|
138
|
-
"<p>Please try again or contact support if the issue persists.</p>",
|
|
139
|
-
),
|
|
94
|
+
error_page(error),
|
|
140
95
|
)
|
|
141
96
|
else:
|
|
142
97
|
callback_result["error"] = "Missing token or error parameter"
|
|
@@ -144,12 +99,7 @@ class CallbackHandler(BaseHTTPRequestHandler):
|
|
|
144
99
|
|
|
145
100
|
self._send_page(
|
|
146
101
|
400,
|
|
147
|
-
|
|
148
|
-
"warning",
|
|
149
|
-
"Invalid Parameters",
|
|
150
|
-
"Invalid Parameters",
|
|
151
|
-
"<p>The authentication callback received invalid parameters.</p>",
|
|
152
|
-
),
|
|
102
|
+
warning_page("Sign-in link incomplete", "The dashboard sent back neither a session nor an error."),
|
|
153
103
|
)
|
|
154
104
|
|
|
155
105
|
except Exception as e:
|
|
@@ -157,12 +107,7 @@ class CallbackHandler(BaseHTTPRequestHandler):
|
|
|
157
107
|
callback_result["received"] = True
|
|
158
108
|
self._send_page(
|
|
159
109
|
500,
|
|
160
|
-
|
|
161
|
-
"error",
|
|
162
|
-
"Server Error",
|
|
163
|
-
"Server Error",
|
|
164
|
-
f"<div class='bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md'><p class='text-sm'>{html.escape(str(e))}</p></div>", # noqa: E501,
|
|
165
|
-
),
|
|
110
|
+
error_page(str(e), status="CLI error"),
|
|
166
111
|
)
|
|
167
112
|
|
|
168
113
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Inference chat and embeddings commands.
|
|
2
2
|
|
|
3
|
-
Serverless models (--model) go through the
|
|
3
|
+
Serverless models (--model) go through /openai/v1 on the API host;
|
|
4
4
|
dedicated deployments (--deployment) go directly to the Iris proxy.
|
|
5
5
|
"""
|
|
6
6
|
|
|
@@ -83,9 +83,9 @@ def chat_cmd(
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
if model:
|
|
86
|
-
# Serverless:
|
|
86
|
+
# Serverless: routed by model name and billed per token.
|
|
87
87
|
payload["model"] = model
|
|
88
|
-
url = f"{config.base_url}/
|
|
88
|
+
url = f"{config.base_url}/openai/v1/chat/completions"
|
|
89
89
|
headers = config.auth_headers()
|
|
90
90
|
console.print(f"[dim]Sending to {model}...[/dim]")
|
|
91
91
|
else:
|
|
@@ -129,7 +129,7 @@ def embeddings_cmd(
|
|
|
129
129
|
|
|
130
130
|
data = api_request(
|
|
131
131
|
"POST",
|
|
132
|
-
"/
|
|
132
|
+
"/openai/v1/embeddings",
|
|
133
133
|
json={"model": model, "input": text},
|
|
134
134
|
timeout=60.0,
|
|
135
135
|
)
|
|
@@ -19,7 +19,7 @@ console = Console()
|
|
|
19
19
|
# or ~/.claude settings on this machine.
|
|
20
20
|
CLAUDE_PROFILE_DIR = Path.home() / ".lyceum" / "claude"
|
|
21
21
|
|
|
22
|
-
DEFAULT_MODEL = "moonshotai/kimi-k3"
|
|
22
|
+
DEFAULT_MODEL = "moonshotai/kimi-k3.1-pro"
|
|
23
23
|
DEFAULT_FAST_MODEL = "z-ai/glm-5.3-flash"
|
|
24
24
|
|
|
25
25
|
# Use explicit Lyceum IDs so the backend never has to guess a substitute.
|
|
@@ -74,7 +74,7 @@ def _is_dedicated_inference_hidden() -> bool:
|
|
|
74
74
|
|
|
75
75
|
def _fetch_dedicated_models() -> list[dict]:
|
|
76
76
|
"""Serverless models a dedicated deployment may run, on their default GPU shape."""
|
|
77
|
-
return api_request("GET", "/
|
|
77
|
+
return api_request("GET", "/external/inference/models", timeout=20.0).get("models", [])
|
|
78
78
|
|
|
79
79
|
|
|
80
80
|
def _format_gpus(gpu_type: str, gpu_count: int) -> str:
|
|
@@ -136,7 +136,7 @@ def serverless_models(
|
|
|
136
136
|
"[dim]Note: 'infer models' now lists serverless models. "
|
|
137
137
|
"For dedicated deployments use 'lyceum infer deployments'.[/dim]"
|
|
138
138
|
)
|
|
139
|
-
data = api_request("GET", "/
|
|
139
|
+
data = api_request("GET", "/openai/v1/models", timeout=20.0)
|
|
140
140
|
models = data.get("data", []) if isinstance(data, dict) else data
|
|
141
141
|
|
|
142
142
|
if as_json:
|
|
@@ -181,14 +181,27 @@ def deploy_model(
|
|
|
181
181
|
target_latency: float = typer.Option(None, "--target-latency", hidden=True),
|
|
182
182
|
stabilisation: int = typer.Option(None, "--stabilisation", hidden=True),
|
|
183
183
|
gpu_count: int = typer.Option(
|
|
184
|
-
None,
|
|
184
|
+
None,
|
|
185
|
+
"--gpu-count",
|
|
186
|
+
help="GPUs per replica, defaults per model (also sets --tensor-parallel-size)",
|
|
187
|
+
),
|
|
188
|
+
offload: bool = typer.Option(
|
|
189
|
+
None,
|
|
190
|
+
"--offload/--no-offload",
|
|
191
|
+
help=(
|
|
192
|
+
"Serve requests the deployment cannot from Lyceum's serverless capacity (default off). That may include a "
|
|
193
|
+
"third-party provider under Lyceum's data terms unless your org is restricted to Lyceum's own"
|
|
194
|
+
),
|
|
185
195
|
),
|
|
186
196
|
yes: bool = typer.Option(False, "--yes", "-y", help="Do not ask before creating"),
|
|
187
197
|
wait: bool = typer.Option(False, "--wait", "-w", help="Wait until deployment has healthy replicas"),
|
|
188
198
|
):
|
|
189
199
|
"""Deploy a serverless model on dedicated GPUs."""
|
|
190
200
|
models = _fetch_dedicated_models()
|
|
191
|
-
model = next(
|
|
201
|
+
model = next(
|
|
202
|
+
(m for m in models if model_id.lower() in (m["serving_name"].lower(), m["hf_model_id"].lower())),
|
|
203
|
+
None,
|
|
204
|
+
)
|
|
192
205
|
if model is None:
|
|
193
206
|
console.print(f"[red]{model_id} cannot be deployed on dedicated GPUs.[/red]")
|
|
194
207
|
console.print("[dim]See the models that can: lyceum infer models --dedicated[/dim]")
|
|
@@ -222,7 +235,10 @@ def deploy_model(
|
|
|
222
235
|
raise typer.Exit(0)
|
|
223
236
|
|
|
224
237
|
if hf_token is None and not yes and not (os.getenv("HF_TOKEN") or os.getenv("HUGGING_FACE_HUB_TOKEN")):
|
|
225
|
-
if typer.confirm(
|
|
238
|
+
if typer.confirm(
|
|
239
|
+
"No HF token detected. Provide one (required for gated models)?",
|
|
240
|
+
default=False,
|
|
241
|
+
):
|
|
226
242
|
hf_token = typer.prompt("HuggingFace Token", hide_input=True)
|
|
227
243
|
|
|
228
244
|
payload = {
|
|
@@ -233,6 +249,7 @@ def deploy_model(
|
|
|
233
249
|
overrides = {
|
|
234
250
|
"gpu_count": gpu_count,
|
|
235
251
|
"gpu_type": gpu_type,
|
|
252
|
+
"offload": offload,
|
|
236
253
|
"stabilisation_window": stabilisation,
|
|
237
254
|
"target_latency_p95_ms": target_latency,
|
|
238
255
|
"target_rps": target_rps,
|
|
@@ -241,7 +258,7 @@ def deploy_model(
|
|
|
241
258
|
if hf_token:
|
|
242
259
|
payload["hf_token"] = hf_token
|
|
243
260
|
|
|
244
|
-
data = api_request("POST", "/
|
|
261
|
+
data = api_request("POST", "/external/inference/create", json=payload)
|
|
245
262
|
deployment_id = data.get("deployment_id", "?")
|
|
246
263
|
|
|
247
264
|
console.print("\n[green]Deployment created![/green]")
|
|
@@ -261,6 +278,30 @@ def deploy_model(
|
|
|
261
278
|
_wait_for_healthy(deployment_id, timeout=model["ready_timeout_s"])
|
|
262
279
|
|
|
263
280
|
|
|
281
|
+
def _offload_label(enabled: bool | None) -> str:
|
|
282
|
+
return "enabled" if enabled else "disabled"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@infer_app.command("offload", hidden=_is_dedicated_inference_hidden())
|
|
286
|
+
def set_offload(
|
|
287
|
+
deployment_id: str = typer.Argument(..., help="Deployment ID"),
|
|
288
|
+
enabled: bool = typer.Option(..., "--enable/--disable", help="Turn offloading on or off"),
|
|
289
|
+
):
|
|
290
|
+
"""Turn offloading on or off for a deployment.
|
|
291
|
+
|
|
292
|
+
Enabled, requests the deployment cannot serve go to Lyceum's serverless capacity,
|
|
293
|
+
which may include a third-party provider under Lyceum's data terms unless your
|
|
294
|
+
org is restricted to Lyceum's own.
|
|
295
|
+
"""
|
|
296
|
+
data = api_request(
|
|
297
|
+
"POST",
|
|
298
|
+
"/external/inference/offload",
|
|
299
|
+
json={"deployment_id": deployment_id, "offload": enabled},
|
|
300
|
+
)
|
|
301
|
+
console.print(f" Deployment ID: [cyan]{data.get('deployment_id')}[/cyan]")
|
|
302
|
+
console.print(f" Offload: [yellow]{_offload_label(data.get('offload'))}[/yellow]")
|
|
303
|
+
|
|
304
|
+
|
|
264
305
|
@infer_app.command("stop")
|
|
265
306
|
def stop_model(
|
|
266
307
|
deployment_id: str = typer.Argument(..., help="Deployment ID to stop"),
|
|
@@ -268,7 +309,7 @@ def stop_model(
|
|
|
268
309
|
"""Stop a deployed model and all its replicas."""
|
|
269
310
|
console.print(f"[dim]Stopping deployment {deployment_id[:12]}...[/dim]")
|
|
270
311
|
|
|
271
|
-
data = api_request("DELETE", "/
|
|
312
|
+
data = api_request("DELETE", "/external/inference/stop", json={"deployment_id": deployment_id})
|
|
272
313
|
console.print("[green]Deployment stopped![/green]")
|
|
273
314
|
console.print(f" Deployment ID: [cyan]{data.get('deployment_id')}[/cyan]")
|
|
274
315
|
console.print(f" Status: [yellow]{data.get('status')}[/yellow]")
|
|
@@ -283,7 +324,7 @@ def deployment_status(
|
|
|
283
324
|
"""Show detailed status of a deployment."""
|
|
284
325
|
dep = api_request(
|
|
285
326
|
"GET",
|
|
286
|
-
"/
|
|
327
|
+
"/external/inference/get",
|
|
287
328
|
params={"deployment_id": deployment_id, "include_terminated": str(include_terminated).lower()},
|
|
288
329
|
timeout=15.0,
|
|
289
330
|
)
|
|
@@ -307,6 +348,8 @@ def deployment_status(
|
|
|
307
348
|
console.print(f" Target p95: {dep['target_latency_p95_ms']} ms")
|
|
308
349
|
if dep.get("stabilisation_window") is not None:
|
|
309
350
|
console.print(f" Stabilisation: {dep['stabilisation_window']} s")
|
|
351
|
+
if dep.get("offload") is not None:
|
|
352
|
+
console.print(f" Offload: {_offload_label(dep['offload'])}")
|
|
310
353
|
|
|
311
354
|
replicas = dep.get("replicas", [])
|
|
312
355
|
if replicas:
|
|
@@ -359,7 +402,7 @@ def list_deployments(
|
|
|
359
402
|
"""List your dedicated deployments."""
|
|
360
403
|
data = api_request(
|
|
361
404
|
"GET",
|
|
362
|
-
"/
|
|
405
|
+
"/external/inference/list",
|
|
363
406
|
params={"include_terminated": str(include_terminated).lower()},
|
|
364
407
|
timeout=15.0,
|
|
365
408
|
)
|
|
@@ -425,7 +468,7 @@ def replica_logs(
|
|
|
425
468
|
replica_id: str = typer.Argument(..., help="Replica ID"),
|
|
426
469
|
):
|
|
427
470
|
"""Show last container logs for a failed replica."""
|
|
428
|
-
data = api_request("GET", f"/
|
|
471
|
+
data = api_request("GET", f"/external/inference/replica/{replica_id}/logs", timeout=15.0)
|
|
429
472
|
error_logs = data.get("error_logs")
|
|
430
473
|
if not error_logs:
|
|
431
474
|
console.print(f"[yellow]No logs available for replica {replica_id}[/yellow]")
|
|
@@ -445,7 +488,7 @@ def _wait_for_healthy(deployment_id: str, timeout: int = 600, poll_interval: int
|
|
|
445
488
|
while time.time() < deadline:
|
|
446
489
|
try:
|
|
447
490
|
response = httpx.get(
|
|
448
|
-
f"{config.base_url}/
|
|
491
|
+
f"{config.base_url}/external/inference/get",
|
|
449
492
|
headers=headers,
|
|
450
493
|
params={"deployment_id": deployment_id},
|
|
451
494
|
timeout=15.0,
|
|
@@ -325,7 +325,7 @@ def add_member(
|
|
|
325
325
|
slug: str | None = typer.Option(None, "--org", help="Org slug (defaults to active)"),
|
|
326
326
|
role: str = typer.Option("member", "--role", help="owner | admin | member"),
|
|
327
327
|
):
|
|
328
|
-
"""
|
|
328
|
+
"""Invite a user by email. They join the org when they accept the invite."""
|
|
329
329
|
slug = _resolve_slug(slug)
|
|
330
330
|
res = _request(
|
|
331
331
|
"POST",
|
|
@@ -147,7 +147,7 @@ class _Config:
|
|
|
147
147
|
|
|
148
148
|
def get_iris_url(self) -> str:
|
|
149
149
|
"""Get the Iris proxy URL. Resolution: IRIS_PROXY_URL env > iris_url config > prod default."""
|
|
150
|
-
return os.getenv("IRIS_PROXY_URL") or self.iris_url or "https://
|
|
150
|
+
return os.getenv("IRIS_PROXY_URL") or self.iris_url or "https://api.lyceum.technology"
|
|
151
151
|
|
|
152
152
|
def get_active_org_slug(self) -> str | None:
|
|
153
153
|
"""Resolution: --org flag > LYCEUM_ORG env > config file. Returns None if unset."""
|
|
@@ -29,11 +29,14 @@ def gateway(monkeypatch):
|
|
|
29
29
|
creates = []
|
|
30
30
|
|
|
31
31
|
def api_request(method, path, **kw):
|
|
32
|
-
if path == "/
|
|
32
|
+
if path == "/external/inference/models":
|
|
33
33
|
return {"models": [GLM52, FLASH]}
|
|
34
|
-
if path == "/
|
|
34
|
+
if path == "/external/inference/create":
|
|
35
35
|
creates.append(kw["json"])
|
|
36
36
|
return {"deployment_id": "d1", "status": "pending"}
|
|
37
|
+
if path == "/external/inference/offload":
|
|
38
|
+
creates.append(kw["json"])
|
|
39
|
+
return kw["json"]
|
|
37
40
|
raise AssertionError(path)
|
|
38
41
|
|
|
39
42
|
monkeypatch.setattr(infer, "api_request", api_request)
|
|
@@ -103,3 +106,24 @@ def test_wait_uses_the_model_ready_timeout(gateway, monkeypatch):
|
|
|
103
106
|
monkeypatch.setattr(infer, "_wait_for_healthy", lambda d, timeout: waited.append((d, timeout)))
|
|
104
107
|
assert _run("deploy", "deepseek/deepseek-v4-flash-0731", "--wait").exit_code == 0
|
|
105
108
|
assert waited == [("d1", 4800)]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_deploy_offload_is_sent_only_when_given(gateway):
|
|
112
|
+
assert _run("deploy", "deepseek/deepseek-v4-flash-0731").exit_code == 0
|
|
113
|
+
assert "offload" not in gateway[0]
|
|
114
|
+
assert _run("deploy", "deepseek/deepseek-v4-flash-0731", "--offload").exit_code == 0
|
|
115
|
+
assert gateway[1]["offload"] is True
|
|
116
|
+
assert _run("deploy", "deepseek/deepseek-v4-flash-0731", "--no-offload").exit_code == 0
|
|
117
|
+
assert gateway[2]["offload"] is False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_offload_command_posts_the_switch(gateway):
|
|
121
|
+
result = _run("offload", "d1", "--enable")
|
|
122
|
+
assert result.exit_code == 0
|
|
123
|
+
assert "enabled" in result.output
|
|
124
|
+
assert _run("offload", "d1", "--disable").exit_code == 0
|
|
125
|
+
assert gateway == [
|
|
126
|
+
{"deployment_id": "d1", "offload": True},
|
|
127
|
+
{"deployment_id": "d1", "offload": False},
|
|
128
|
+
]
|
|
129
|
+
assert _run("offload", "d1").exit_code == 2
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|