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.
Files changed (50) hide show
  1. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/PKG-INFO +1 -1
  2. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/_version.py +1 -1
  3. lyceum_cli-1.2.4/lyceum/external/auth/callback_page.py +308 -0
  4. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/auth/login.py +22 -77
  5. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/compute/inference/chat.py +4 -4
  6. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/compute/inference/claude_code.py +1 -1
  7. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/compute/inference/infer.py +54 -11
  8. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/orgs/management.py +1 -1
  9. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/shared/config.py +1 -1
  10. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum_cli.egg-info/PKG-INFO +1 -1
  11. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum_cli.egg-info/SOURCES.txt +1 -0
  12. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/pyproject.toml +5 -0
  13. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/tests/test_dedicated_deploy.py +26 -2
  14. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/README.md +0 -0
  15. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/__main__.py +0 -0
  16. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/__init__.py +0 -0
  17. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/auth/__init__.py +0 -0
  18. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/auth/environment.py +0 -0
  19. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/auth/keys.py +0 -0
  20. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/compute/__init__.py +0 -0
  21. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/compute/execution/__init__.py +0 -0
  22. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/compute/execution/config.py +0 -0
  23. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/compute/execution/docker.py +0 -0
  24. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/compute/execution/docker_compose.py +0 -0
  25. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/compute/execution/gpu_selection.py +0 -0
  26. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/compute/execution/jobs.py +0 -0
  27. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/compute/execution/notebook.py +0 -0
  28. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/compute/execution/python.py +0 -0
  29. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/compute/inference/__init__.py +0 -0
  30. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/orgs/__init__.py +0 -0
  31. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/storage/__init__.py +0 -0
  32. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/storage/block.py +0 -0
  33. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/storage/files.py +0 -0
  34. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/vms/__init__.py +0 -0
  35. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/external/vms/management.py +0 -0
  36. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/main.py +0 -0
  37. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/shared/__init__.py +0 -0
  38. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/shared/http.py +0 -0
  39. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/shared/imports.py +0 -0
  40. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/shared/orgs.py +0 -0
  41. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/shared/run_options.py +0 -0
  42. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/shared/streaming.py +0 -0
  43. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum/shared/ui.py +0 -0
  44. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum_cli.egg-info/dependency_links.txt +0 -0
  45. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum_cli.egg-info/entry_points.txt +0 -0
  46. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum_cli.egg-info/requires.txt +0 -0
  47. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/lyceum_cli.egg-info/top_level.txt +0 -0
  48. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/setup.cfg +0 -0
  49. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/tests/test_block_storage.py +0 -0
  50. {lyceum_cli-1.2.2 → lyceum_cli-1.2.4}/tests/test_orgs.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lyceum-cli
3
- Version: 1.2.2
3
+ Version: 1.2.4
4
4
  Summary: Command-line interface for Lyceum Cloud
5
5
  Author-email: Lyceum <support@lyceum.technology>
6
6
  License: MIT
@@ -7,4 +7,4 @@ __version__ lives in this module. A future refactor could move these modules
7
7
  under lyceum/cli/, claiming one subpackage the way the other two do.
8
8
  """
9
9
 
10
- __version__ = "1.2.2"
10
+ __version__ = "1.2.4"
@@ -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
- # (svg path, tailwind color) per page kind
30
- _PAGE_ICONS = {
31
- "success": ("M5 13l4 4L19 7", "green"),
32
- "error": ("M6 18L18 6M6 6l12 12", "red"),
33
- "warning": (
34
- "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L3.268 16c-.77.833.192 2.5 1.732 2.5z", # noqa: E501
35
- "yellow",
36
- ),
37
- }
38
-
39
-
40
- def _render_callback_page(kind: str, title: str, heading: str, sub_html: str, auto_close: bool = False) -> str:
41
- """Render the browser page shown after the OAuth callback."""
42
- icon_path, color = _PAGE_ICONS[kind]
43
- script = "<script>setTimeout(() => window.close(), 10000);</script>" if auto_close else ""
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
- _render_callback_page(
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
- _render_callback_page(
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
- _render_callback_page(
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
- _render_callback_page(
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
- _render_callback_page(
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 gateway's /serverless endpoints;
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: gateway routes by model name and bills per token.
86
+ # Serverless: routed by model name and billed per token.
87
87
  payload["model"] = model
88
- url = f"{config.base_url}/api/v2/external/serverless/chat/completions"
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
- "/api/v2/external/serverless/embeddings",
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", "/api/v2/external/inference/models", timeout=20.0).get("models", [])
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", "/api/v2/external/serverless/models", timeout=20.0)
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, "--gpu-count", help="GPUs per replica, defaults per model (also sets --tensor-parallel-size)"
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((m for m in models if model_id.lower() in (m["serving_name"].lower(), m["hf_model_id"].lower())), None)
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("No HF token detected. Provide one (required for gated models)?", default=False):
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", "/api/v2/external/inference/create", json=payload)
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", "/api/v2/external/inference/stop", json={"deployment_id": deployment_id})
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
- "/api/v2/external/inference/get",
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
- "/api/v2/external/inference/list",
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"/api/v2/external/inference/replica/{replica_id}/logs", timeout=15.0)
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}/api/v2/external/inference/get",
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
- """Add a user by email. If the email isn't a Lyceum user yet, an invite is sent."""
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://iris.lyceum.technology"
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."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lyceum-cli
3
- Version: 1.2.2
3
+ Version: 1.2.4
4
4
  Summary: Command-line interface for Lyceum Cloud
5
5
  Author-email: Lyceum <support@lyceum.technology>
6
6
  License: MIT
@@ -5,6 +5,7 @@ lyceum/_version.py
5
5
  lyceum/main.py
6
6
  lyceum/external/__init__.py
7
7
  lyceum/external/auth/__init__.py
8
+ lyceum/external/auth/callback_page.py
8
9
  lyceum/external/auth/environment.py
9
10
  lyceum/external/auth/keys.py
10
11
  lyceum/external/auth/login.py
@@ -40,6 +40,11 @@ Dashboard = "https://dashboard.lyceum.technology"
40
40
  [project.scripts]
41
41
  lyceum = "lyceum.main:app"
42
42
 
43
+ [dependency-groups]
44
+ test = [
45
+ "pytest==9.0.2",
46
+ ]
47
+
43
48
  [tool.setuptools.dynamic]
44
49
  version = { attr = "lyceum._version.__version__" }
45
50
 
@@ -29,11 +29,14 @@ def gateway(monkeypatch):
29
29
  creates = []
30
30
 
31
31
  def api_request(method, path, **kw):
32
- if path == "/api/v2/external/inference/models":
32
+ if path == "/external/inference/models":
33
33
  return {"models": [GLM52, FLASH]}
34
- if path == "/api/v2/external/inference/create":
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