div0-space commited on
Commit
c4e64ca
Β·
verified Β·
1 Parent(s): 5f1cb76

Initial import: API batch tester

Browse files
Files changed (4) hide show
  1. README.md +31 -12
  2. api-tester.html +1307 -0
  3. app.py +65 -0
  4. requirements.txt +1 -0
README.md CHANGED
@@ -1,12 +1,31 @@
1
- ---
2
- title: Api Batch Tester
3
- emoji: πŸ“‰
4
- colorFrom: purple
5
- colorTo: blue
6
- sdk: gradio
7
- sdk_version: 6.5.1
8
- app_file: app.py
9
- pinned: false
10
- ---
11
-
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LibraxisAI / api-batch-tester
2
+
3
+ Public Gradio Space that serves the multi-lane API tester (standalone HTML) so you can compare any `responses`-compatible endpoints side-by-side.
4
+
5
+ ## How it works
6
+ - The HTML tester is bundled as `api-tester.html` and rendered inside a minimal Gradio app (`app.py`).
7
+ - No backend calls are proxied; the page talks directly to whichever endpoints you enter.
8
+ - Optional basic auth protects the Space UI (recommended for private keys).
9
+
10
+ ## Running locally
11
+ ```bash
12
+ pip install -r requirements.txt
13
+ python app.py # opens on http://0.0.0.0:7860
14
+ ```
15
+
16
+ ## Environment variables
17
+ - `GRADIO_USERNAME`, `GRADIO_PASSWORD` – optional single-user basic auth.
18
+ - `GRADIO_AUTH` – optional comma-separated list of `user:pass` pairs (overrides/extends the above).
19
+ - `API_TESTER_HTML` – optional path to a custom HTML tester file (defaults to bundled `api-tester.html`).
20
+ - `PORT` or `GRADIO_PORT` – port to bind (default 7860).
21
+
22
+ ## Deploying to Hugging Face Spaces
23
+ 1. Create Space: **Gradio** type, repository name `LibraxisAI/api-batch-tester`, hardware **CPU Basic**.
24
+ 2. Add secrets/variables in the Space settings:
25
+ - `GRADIO_USERNAME`, `GRADIO_PASSWORD` (or `GRADIO_AUTH`) for UI protection.
26
+ - Leave others empty unless you override the HTML path.
27
+ 3. Push these files to the Space repo. HF will auto-build and serve `app.py`.
28
+
29
+ ## Safety notes
30
+ - The tester form includes an API key field; when the Space is public, advise users not to paste production secrets unless behind auth.
31
+ - The app itself stores nothing; all calls go from the browser to the user-provided endpoints.
api-tester.html ADDED
@@ -0,0 +1,1307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>LibraxisAI API Tester</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0a0a0f;
10
+ --surface: #12121a;
11
+ --surface-hover: #1a1a24;
12
+ --border: #2a2a3a;
13
+ --accent: #6366f1;
14
+ --accent-hover: #818cf8;
15
+ --text: #e4e4e7;
16
+ --text-dim: #71717a;
17
+ --success: #22c55e;
18
+ --error: #ef4444;
19
+ --warning: #f59e0b;
20
+ --gradient: linear-gradient(135deg, #6366f1 0%, #a855f7 50%, #ec4899 100%);
21
+ }
22
+
23
+ * { box-sizing: border-box; margin: 0; padding: 0; }
24
+
25
+ body {
26
+ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, sans-serif;
27
+ background: var(--bg);
28
+ color: var(--text);
29
+ min-height: 100vh;
30
+ padding: 1rem;
31
+ }
32
+
33
+ /* Header */
34
+ .header {
35
+ text-align: center;
36
+ padding: 1.5rem 0;
37
+ border-bottom: 1px solid var(--border);
38
+ margin-bottom: 1.5rem;
39
+ }
40
+
41
+ .header h1 {
42
+ font-size: 1.75rem;
43
+ font-weight: 700;
44
+ background: var(--gradient);
45
+ -webkit-background-clip: text;
46
+ -webkit-text-fill-color: transparent;
47
+ background-clip: text;
48
+ }
49
+
50
+ .header p {
51
+ color: var(--text-dim);
52
+ font-size: 0.875rem;
53
+ margin-top: 0.25rem;
54
+ }
55
+
56
+ /* Global Config */
57
+ .global-config {
58
+ display: flex;
59
+ gap: 1rem;
60
+ align-items: center;
61
+ flex-wrap: wrap;
62
+ padding: 1rem;
63
+ background: var(--surface);
64
+ border: 1px solid var(--border);
65
+ border-radius: 12px;
66
+ margin-bottom: 1rem;
67
+ }
68
+
69
+ .global-config label {
70
+ font-size: 0.75rem;
71
+ color: var(--text-dim);
72
+ text-transform: uppercase;
73
+ letter-spacing: 0.05em;
74
+ }
75
+
76
+ .global-config input[type="password"] {
77
+ flex: 1;
78
+ min-width: 200px;
79
+ padding: 0.5rem 0.75rem;
80
+ background: var(--bg);
81
+ border: 1px solid var(--border);
82
+ border-radius: 6px;
83
+ color: var(--text);
84
+ font-size: 0.875rem;
85
+ }
86
+
87
+ .mode-toggle {
88
+ display: flex;
89
+ gap: 0.5rem;
90
+ }
91
+
92
+ .mode-toggle label {
93
+ display: flex;
94
+ align-items: center;
95
+ gap: 0.25rem;
96
+ cursor: pointer;
97
+ padding: 0.5rem 0.75rem;
98
+ border-radius: 6px;
99
+ border: 1px solid var(--border);
100
+ font-size: 0.875rem;
101
+ text-transform: none;
102
+ }
103
+
104
+ .mode-toggle input:checked + span {
105
+ color: var(--accent);
106
+ }
107
+
108
+ .mode-toggle input {
109
+ display: none;
110
+ }
111
+
112
+ /* Toolbar */
113
+ .toolbar {
114
+ display: flex;
115
+ gap: 0.75rem;
116
+ margin-bottom: 1rem;
117
+ flex-wrap: wrap;
118
+ }
119
+
120
+ .btn {
121
+ padding: 0.625rem 1rem;
122
+ border: none;
123
+ border-radius: 8px;
124
+ font-size: 0.875rem;
125
+ font-weight: 500;
126
+ cursor: pointer;
127
+ transition: all 0.2s;
128
+ display: flex;
129
+ align-items: center;
130
+ gap: 0.5rem;
131
+ }
132
+
133
+ .btn-secondary {
134
+ background: var(--surface);
135
+ color: var(--text);
136
+ border: 1px solid var(--border);
137
+ }
138
+
139
+ .btn-secondary:hover {
140
+ background: var(--surface-hover);
141
+ border-color: var(--accent);
142
+ }
143
+
144
+ .btn-primary {
145
+ background: var(--gradient);
146
+ color: white;
147
+ font-weight: 700;
148
+ padding: 0.75rem 2rem;
149
+ font-size: 1rem;
150
+ box-shadow: 0 4px 20px rgba(99, 102, 241, 0.3);
151
+ }
152
+
153
+ .btn-primary:hover {
154
+ transform: translateY(-1px);
155
+ box-shadow: 0 6px 24px rgba(99, 102, 241, 0.4);
156
+ }
157
+
158
+ .btn-primary:disabled {
159
+ opacity: 0.5;
160
+ cursor: not-allowed;
161
+ transform: none;
162
+ }
163
+
164
+ /* Lanes Container */
165
+ .lanes-container {
166
+ display: flex;
167
+ gap: 1rem;
168
+ overflow-x: auto;
169
+ padding-bottom: 1rem;
170
+ }
171
+
172
+ /* Lane */
173
+ .lane {
174
+ flex: 0 0 360px;
175
+ background: var(--surface);
176
+ border: 1px solid var(--border);
177
+ border-radius: 12px;
178
+ display: flex;
179
+ flex-direction: column;
180
+ }
181
+
182
+ .lane-header {
183
+ display: flex;
184
+ justify-content: space-between;
185
+ align-items: center;
186
+ padding: 0.75rem 1rem;
187
+ border-bottom: 1px solid var(--border);
188
+ background: rgba(99, 102, 241, 0.05);
189
+ }
190
+
191
+ .lane-title {
192
+ font-weight: 600;
193
+ color: var(--accent);
194
+ display: flex;
195
+ align-items: center;
196
+ gap: 0.5rem;
197
+ }
198
+
199
+ .lane-actions {
200
+ display: flex;
201
+ gap: 0.25rem;
202
+ }
203
+
204
+ .lane-actions button {
205
+ background: transparent;
206
+ border: none;
207
+ color: var(--text-dim);
208
+ cursor: pointer;
209
+ padding: 0.25rem;
210
+ border-radius: 4px;
211
+ font-size: 1rem;
212
+ }
213
+
214
+ .lane-actions button:hover {
215
+ background: var(--surface-hover);
216
+ color: var(--text);
217
+ }
218
+
219
+ .lane-config {
220
+ padding: 1rem;
221
+ display: flex;
222
+ flex-direction: column;
223
+ gap: 0.75rem;
224
+ border-bottom: 1px solid var(--border);
225
+ }
226
+
227
+ .field {
228
+ display: flex;
229
+ flex-direction: column;
230
+ gap: 0.25rem;
231
+ }
232
+
233
+ .field label {
234
+ font-size: 0.75rem;
235
+ color: var(--text-dim);
236
+ text-transform: uppercase;
237
+ letter-spacing: 0.05em;
238
+ }
239
+
240
+ .field input, .field select, .field textarea {
241
+ padding: 0.5rem 0.75rem;
242
+ background: var(--bg);
243
+ border: 1px solid var(--border);
244
+ border-radius: 6px;
245
+ color: var(--text);
246
+ font-size: 0.875rem;
247
+ font-family: inherit;
248
+ }
249
+
250
+ .field input:focus, .field select:focus, .field textarea:focus {
251
+ outline: none;
252
+ border-color: var(--accent);
253
+ }
254
+
255
+ .field textarea {
256
+ min-height: 60px;
257
+ resize: vertical;
258
+ }
259
+
260
+ .field-row {
261
+ display: flex;
262
+ gap: 0.5rem;
263
+ }
264
+
265
+ .field-row .field {
266
+ flex: 1;
267
+ }
268
+
269
+ /* Prompts Section */
270
+ .prompts-section {
271
+ padding: 1rem;
272
+ border-bottom: 1px solid var(--border);
273
+ max-height: 300px;
274
+ overflow-y: auto;
275
+ }
276
+
277
+ .prompts-section h4 {
278
+ font-size: 0.75rem;
279
+ color: var(--text-dim);
280
+ text-transform: uppercase;
281
+ letter-spacing: 0.05em;
282
+ margin-bottom: 0.75rem;
283
+ }
284
+
285
+ .prompt-item {
286
+ margin-bottom: 0.75rem;
287
+ }
288
+
289
+ .prompt-item label {
290
+ font-size: 0.75rem;
291
+ color: var(--accent);
292
+ margin-bottom: 0.25rem;
293
+ display: block;
294
+ }
295
+
296
+ .prompt-item textarea {
297
+ width: 100%;
298
+ padding: 0.5rem;
299
+ background: var(--bg);
300
+ border: 1px solid var(--border);
301
+ border-radius: 6px;
302
+ color: var(--text);
303
+ font-size: 0.8125rem;
304
+ font-family: inherit;
305
+ min-height: 50px;
306
+ resize: vertical;
307
+ }
308
+
309
+ /* Output Section */
310
+ .output-section {
311
+ flex: 1;
312
+ padding: 1rem;
313
+ display: flex;
314
+ flex-direction: column;
315
+ gap: 0.5rem;
316
+ min-height: 200px;
317
+ max-height: 400px;
318
+ overflow-y: auto;
319
+ }
320
+
321
+ .output-section h4 {
322
+ font-size: 0.75rem;
323
+ color: var(--text-dim);
324
+ text-transform: uppercase;
325
+ letter-spacing: 0.05em;
326
+ }
327
+
328
+ .output-item {
329
+ background: var(--bg);
330
+ border: 1px solid var(--border);
331
+ border-radius: 8px;
332
+ overflow: hidden;
333
+ }
334
+
335
+ .output-item-header {
336
+ display: flex;
337
+ justify-content: space-between;
338
+ align-items: center;
339
+ padding: 0.5rem 0.75rem;
340
+ background: rgba(255,255,255,0.02);
341
+ border-bottom: 1px solid var(--border);
342
+ font-size: 0.75rem;
343
+ cursor: pointer;
344
+ }
345
+
346
+ .output-item-header:hover {
347
+ background: rgba(255,255,255,0.04);
348
+ }
349
+
350
+ .output-step {
351
+ color: var(--accent);
352
+ font-weight: 600;
353
+ }
354
+
355
+ .output-status {
356
+ display: flex;
357
+ align-items: center;
358
+ gap: 0.5rem;
359
+ }
360
+
361
+ .status-dot {
362
+ width: 6px;
363
+ height: 6px;
364
+ border-radius: 50%;
365
+ background: var(--text-dim);
366
+ }
367
+
368
+ .status-dot.running {
369
+ background: var(--warning);
370
+ animation: pulse 1s infinite;
371
+ }
372
+
373
+ .status-dot.success { background: var(--success); }
374
+ .status-dot.error { background: var(--error); }
375
+
376
+ @keyframes pulse {
377
+ 0%, 100% { opacity: 1; }
378
+ 50% { opacity: 0.4; }
379
+ }
380
+
381
+ .output-item-body {
382
+ padding: 0.75rem;
383
+ font-family: 'SF Mono', 'Fira Code', monospace;
384
+ font-size: 0.8125rem;
385
+ line-height: 1.5;
386
+ white-space: pre-wrap;
387
+ word-break: break-word;
388
+ max-height: 150px;
389
+ overflow-y: auto;
390
+ }
391
+
392
+ .output-item-body.collapsed {
393
+ max-height: 40px;
394
+ overflow: hidden;
395
+ }
396
+
397
+ /* Stream Preview */
398
+ .stream-preview {
399
+ font-family: 'SF Mono', monospace;
400
+ font-size: 0.75rem;
401
+ color: var(--text-dim);
402
+ padding: 0.25rem 0.5rem;
403
+ background: rgba(99, 102, 241, 0.1);
404
+ border-radius: 4px;
405
+ white-space: nowrap;
406
+ overflow: hidden;
407
+ text-overflow: ellipsis;
408
+ max-width: 200px;
409
+ }
410
+
411
+ /* Stats Bar */
412
+ .lane-stats {
413
+ padding: 0.75rem 1rem;
414
+ background: rgba(99, 102, 241, 0.05);
415
+ border-top: 1px solid var(--border);
416
+ display: grid;
417
+ grid-template-columns: repeat(3, 1fr);
418
+ gap: 0.5rem;
419
+ font-size: 0.75rem;
420
+ }
421
+
422
+ .stat {
423
+ text-align: center;
424
+ }
425
+
426
+ .stat-value {
427
+ font-weight: 700;
428
+ color: var(--accent);
429
+ font-size: 1rem;
430
+ font-family: 'SF Mono', monospace;
431
+ }
432
+
433
+ .stat-label {
434
+ color: var(--text-dim);
435
+ text-transform: uppercase;
436
+ letter-spacing: 0.05em;
437
+ font-size: 0.625rem;
438
+ }
439
+
440
+ /* Footer */
441
+ .footer {
442
+ text-align: center;
443
+ padding: 1.5rem;
444
+ color: var(--text-dim);
445
+ font-size: 0.75rem;
446
+ border-top: 1px solid var(--border);
447
+ margin-top: 2rem;
448
+ }
449
+
450
+ /* Scrollbar */
451
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
452
+ ::-webkit-scrollbar-track { background: var(--bg); }
453
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
454
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
455
+
456
+ /* Endpoint presets */
457
+ .endpoint-presets {
458
+ display: flex;
459
+ gap: 0.25rem;
460
+ margin-top: 0.25rem;
461
+ }
462
+
463
+ .endpoint-preset {
464
+ font-size: 0.625rem;
465
+ padding: 0.125rem 0.375rem;
466
+ background: var(--surface-hover);
467
+ border: 1px solid var(--border);
468
+ border-radius: 4px;
469
+ color: var(--text-dim);
470
+ cursor: pointer;
471
+ }
472
+
473
+ .endpoint-preset:hover {
474
+ border-color: var(--accent);
475
+ color: var(--accent);
476
+ }
477
+ </style>
478
+ </head>
479
+ <body>
480
+ <div class="header">
481
+ <h1>LibraxisAI API Tester</h1>
482
+ <p>Multi-lane comparison tool for API endpoints</p>
483
+ </div>
484
+
485
+ <div class="global-config">
486
+ <div class="field" style="flex: 1;">
487
+ <label>API Key</label>
488
+ <input type="password" id="globalApiKey" value="" placeholder="your-api-key-here">
489
+ </div>
490
+ <div class="field">
491
+ <label>Mode</label>
492
+ <div class="mode-toggle">
493
+ <label>
494
+ <input type="radio" name="streamMode" value="true" checked>
495
+ <span>Stream</span>
496
+ </label>
497
+ <label>
498
+ <input type="radio" name="streamMode" value="false">
499
+ <span>Sync</span>
500
+ </label>
501
+ </div>
502
+ </div>
503
+ <div class="field">
504
+ <label>Output</label>
505
+ <div class="mode-toggle">
506
+ <label title="Show raw SSE events as-is">
507
+ <input type="radio" name="outputMode" value="raw" checked>
508
+ <span>RAW</span>
509
+ </label>
510
+ <label title="Parse and show only text content">
511
+ <input type="radio" name="outputMode" value="parsed">
512
+ <span>Parsed</span>
513
+ </label>
514
+ </div>
515
+ </div>
516
+ </div>
517
+
518
+ <div class="toolbar">
519
+ <button class="btn btn-secondary" onclick="addLane()">+ Add Lane</button>
520
+ <button class="btn btn-secondary" onclick="clearAllOutputs()">Clear Outputs</button>
521
+ <div style="display: flex; align-items: center; gap: 0.25rem; background: var(--surface); padding: 0.25rem 0.5rem; border-radius: 6px; border: 1px solid var(--border);">
522
+ <select id="presetSelect" style="background: var(--bg); border: none; color: var(--text); padding: 0.4rem; border-radius: 4px; min-width: 120px;">
523
+ <option value="">-- Presets --</option>
524
+ </select>
525
+ <button class="btn btn-secondary" onclick="loadPreset()" style="padding: 0.4rem 0.6rem;">Load</button>
526
+ <button class="btn btn-secondary" onclick="savePreset()" style="padding: 0.4rem 0.6rem;">Save</button>
527
+ <button class="btn btn-secondary" onclick="deletePreset()" style="padding: 0.4rem 0.6rem;">Del</button>
528
+ </div>
529
+ <button class="btn btn-secondary" onclick="exportResults()">Export JSON</button>
530
+ <button class="btn btn-secondary" onclick="copyResultsToClipboard()">Copy</button>
531
+ <button class="btn btn-secondary" onclick="exportAllLogs()" title="Export all saved test logs">
532
+ Logs <span id="logCount" style="background: var(--accent); padding: 0.1rem 0.4rem; border-radius: 4px; font-size: 0.75rem;">0</span>
533
+ </button>
534
+ <div style="flex: 1;"></div>
535
+ <button class="btn btn-primary" id="runBtn" onclick="runAllLanes()">
536
+ RUN ALL
537
+ </button>
538
+ </div>
539
+
540
+ <div class="lanes-container" id="lanesContainer">
541
+ <!-- Lanes will be added here -->
542
+ </div>
543
+
544
+ <div class="footer">
545
+ Created by M&K (c)2026 The LibraxisAI Team
546
+ </div>
547
+
548
+ <script>
549
+ // State
550
+ let laneCounter = 0;
551
+ const lanes = new Map();
552
+
553
+ // Endpoint presets
554
+ const ENDPOINTS = {
555
+ 'mlx-batch': 'http://localhost:8100/v1/responses',
556
+ 'api-router': 'http://localhost:8088/v1/responses',
557
+ 'remote': 'https://api.libraxis.cloud/v1/responses'
558
+ };
559
+
560
+ // History management for Endpoint and Model fields
561
+ const HISTORY_KEY = 'api-tester-history';
562
+ const MAX_HISTORY = 10;
563
+
564
+ function getHistory() {
565
+ try {
566
+ return JSON.parse(localStorage.getItem(HISTORY_KEY)) || { endpoints: [], models: [] };
567
+ } catch {
568
+ return { endpoints: [], models: [] };
569
+ }
570
+ }
571
+
572
+ function saveToHistory(type, value) {
573
+ if (!value || !value.trim()) return;
574
+ const history = getHistory();
575
+ const list = history[type] || [];
576
+ // Remove if exists, add to front
577
+ const idx = list.indexOf(value);
578
+ if (idx > -1) list.splice(idx, 1);
579
+ list.unshift(value);
580
+ // Keep max items
581
+ history[type] = list.slice(0, MAX_HISTORY);
582
+ localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
583
+ updateHistoryDatalist(type);
584
+ }
585
+
586
+ function updateHistoryDatalist(type) {
587
+ const datalist = document.getElementById(`${type}-history`);
588
+ if (!datalist) return;
589
+ const history = getHistory();
590
+ const items = history[type] || [];
591
+ datalist.innerHTML = items.map(v => `<option value="${escapeHtml(v)}">`).join('');
592
+ }
593
+
594
+ function escapeHtml(str) {
595
+ const div = document.createElement('div');
596
+ div.textContent = str;
597
+ return div.innerHTML;
598
+ }
599
+
600
+ // Results storage for export
601
+ const testResults = new Map();
602
+
603
+ function collectResults() {
604
+ const results = {
605
+ timestamp: new Date().toISOString(),
606
+ globalConfig: {
607
+ apiKey: document.getElementById('globalApiKey').value ? '***' : 'none',
608
+ streamMode: document.querySelector('input[name="streamMode"]:checked').value
609
+ },
610
+ lanes: []
611
+ };
612
+
613
+ lanes.forEach((_, laneId) => {
614
+ const laneData = {
615
+ id: laneId,
616
+ config: {
617
+ endpoint: document.getElementById(`${laneId}-endpoint`).value,
618
+ model: document.getElementById(`${laneId}-model`).value,
619
+ chainLength: document.getElementById(`${laneId}-chain`).value,
620
+ systemPrompt: document.getElementById(`${laneId}-system`).value
621
+ },
622
+ prompts: [],
623
+ responses: [],
624
+ stats: {
625
+ ttft: document.getElementById(`${laneId}-ttft`)?.textContent || '-',
626
+ tps: document.getElementById(`${laneId}-tps`)?.textContent || '-',
627
+ total: document.getElementById(`${laneId}-total`)?.textContent || '-'
628
+ }
629
+ };
630
+
631
+ // Collect prompts
632
+ const chainLength = parseInt(laneData.config.chainLength) || 1;
633
+ for (let i = 1; i <= chainLength; i++) {
634
+ const promptEl = document.getElementById(`${laneId}-prompt-${i}`);
635
+ if (promptEl) laneData.prompts.push(promptEl.value);
636
+
637
+ const outputEl = document.getElementById(`${laneId}-output-${i}`);
638
+ if (outputEl) laneData.responses.push(outputEl.textContent);
639
+ }
640
+
641
+ results.lanes.push(laneData);
642
+ });
643
+
644
+ return results;
645
+ }
646
+
647
+ function exportResults() {
648
+ const results = collectResults();
649
+ const blob = new Blob([JSON.stringify(results, null, 2)], { type: 'application/json' });
650
+ const url = URL.createObjectURL(blob);
651
+ const a = document.createElement('a');
652
+ a.href = url;
653
+ a.download = `api-test-${new Date().toISOString().slice(0,19).replace(/:/g,'-')}.json`;
654
+ a.click();
655
+ URL.revokeObjectURL(url);
656
+ }
657
+
658
+ function copyResultsToClipboard() {
659
+ const results = collectResults();
660
+
661
+ // Format as readable text
662
+ let text = `API Test Results - ${results.timestamp}\n`;
663
+ text += `Stream Mode: ${results.globalConfig.streamMode}\n\n`;
664
+
665
+ results.lanes.forEach((lane, idx) => {
666
+ text += `═══ Lane ${idx + 1}: ${lane.config.model} ═══\n`;
667
+ text += `Endpoint: ${lane.config.endpoint}\n`;
668
+ text += `Stats: TTFT=${lane.stats.ttft} | tok/s=${lane.stats.tps} | Total=${lane.stats.total}\n\n`;
669
+
670
+ lane.prompts.forEach((prompt, i) => {
671
+ text += `[Prompt ${i+1}]: ${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}\n`;
672
+ text += `[Response ${i+1}]: ${(lane.responses[i] || '').slice(0, 200)}${(lane.responses[i] || '').length > 200 ? '...' : ''}\n\n`;
673
+ });
674
+ text += '\n';
675
+ });
676
+
677
+ navigator.clipboard.writeText(text).then(() => {
678
+ alert('Results copied to clipboard!');
679
+ });
680
+ }
681
+
682
+ async function autoSaveLog(results) {
683
+ // Auto-save to tools/logs/ via local file download
684
+ // Since we can't write directly to filesystem from browser,
685
+ // we append to localStorage and provide batch export
686
+ const logKey = 'api-tester-logs';
687
+ const existingLogs = JSON.parse(localStorage.getItem(logKey) || '[]');
688
+ existingLogs.push(results);
689
+
690
+ // Keep last 100 tests
691
+ if (existingLogs.length > 100) {
692
+ existingLogs.shift();
693
+ }
694
+ localStorage.setItem(logKey, JSON.stringify(existingLogs));
695
+
696
+ console.log(`[API Tester] Auto-saved test #${existingLogs.length} to localStorage`);
697
+ }
698
+
699
+ function exportAllLogs() {
700
+ const logKey = 'api-tester-logs';
701
+ const logs = JSON.parse(localStorage.getItem(logKey) || '[]');
702
+
703
+ if (logs.length === 0) {
704
+ alert('No logs to export');
705
+ return;
706
+ }
707
+
708
+ const blob = new Blob([JSON.stringify(logs, null, 2)], { type: 'application/json' });
709
+ const url = URL.createObjectURL(blob);
710
+ const a = document.createElement('a');
711
+ a.href = url;
712
+ a.download = `api-test-logs-${new Date().toISOString().slice(0,10)}.json`;
713
+ a.click();
714
+ URL.revokeObjectURL(url);
715
+
716
+ alert(`Exported ${logs.length} test logs`);
717
+ }
718
+
719
+ function clearLogs() {
720
+ if (confirm('Clear all saved test logs?')) {
721
+ localStorage.removeItem('api-tester-logs');
722
+ alert('Logs cleared');
723
+ }
724
+ }
725
+
726
+ function getLogCount() {
727
+ const logs = JSON.parse(localStorage.getItem('api-tester-logs') || '[]');
728
+ return logs.length;
729
+ }
730
+
731
+ // === PRESETS SYSTEM ===
732
+ const PRESETS_KEY = 'api-tester-presets';
733
+
734
+ function getPresets() {
735
+ return JSON.parse(localStorage.getItem(PRESETS_KEY) || '{}');
736
+ }
737
+
738
+ function savePreset() {
739
+ const name = prompt('Preset name:', `preset-${Object.keys(getPresets()).length + 1}`);
740
+ if (!name || !name.trim()) return;
741
+
742
+ const lanes = document.querySelectorAll('.lane');
743
+ if (lanes.length === 0) {
744
+ alert('No lanes to save');
745
+ return;
746
+ }
747
+
748
+ const preset = {
749
+ created: new Date().toISOString(),
750
+ streaming: document.querySelector('input[name="mode"]:checked')?.value === 'stream',
751
+ lanes: []
752
+ };
753
+
754
+ lanes.forEach(lane => {
755
+ const laneId = lane.id;
756
+ const chainCount = parseInt(document.getElementById(`${laneId}-chain`)?.value || '1');
757
+
758
+ const laneData = {
759
+ endpoint: document.getElementById(`${laneId}-endpoint`)?.value || '',
760
+ model: document.getElementById(`${laneId}-model`)?.value || 'chat',
761
+ chain: chainCount,
762
+ systemPrompt: document.getElementById(`${laneId}-system`)?.value || '',
763
+ prompts: []
764
+ };
765
+
766
+ // Collect all prompts for this lane
767
+ for (let i = 1; i <= chainCount; i++) {
768
+ const promptEl = document.getElementById(`${laneId}-prompt-${i}`);
769
+ laneData.prompts.push(promptEl?.value || '');
770
+ }
771
+
772
+ preset.lanes.push(laneData);
773
+ });
774
+
775
+ const presets = getPresets();
776
+ presets[name.trim()] = preset;
777
+ localStorage.setItem(PRESETS_KEY, JSON.stringify(presets));
778
+
779
+ updatePresetDropdown();
780
+ alert(`Preset "${name}" saved with ${preset.lanes.length} lane(s)`);
781
+ }
782
+
783
+ function loadPreset() {
784
+ const select = document.getElementById('presetSelect');
785
+ const name = select?.value;
786
+ if (!name) {
787
+ alert('Select a preset first');
788
+ return;
789
+ }
790
+
791
+ const presets = getPresets();
792
+ const preset = presets[name];
793
+ if (!preset) {
794
+ alert('Preset not found');
795
+ return;
796
+ }
797
+
798
+ // Clear existing lanes
799
+ const lanesContainer = document.getElementById('lanes');
800
+ lanesContainer.innerHTML = '';
801
+ laneCounter = 0;
802
+
803
+ // Set streaming mode
804
+ if (preset.streaming !== undefined) {
805
+ const streamRadio = document.querySelector(`input[name="mode"][value="${preset.streaming ? 'stream' : 'non-stream'}"]`);
806
+ if (streamRadio) streamRadio.checked = true;
807
+ }
808
+
809
+ // Create lanes from preset
810
+ preset.lanes.forEach((laneData, index) => {
811
+ addLane();
812
+ const laneId = `lane-${laneCounter}`;
813
+
814
+ // Set config
815
+ const endpointEl = document.getElementById(`${laneId}-endpoint`);
816
+ const modelEl = document.getElementById(`${laneId}-model`);
817
+ const chainEl = document.getElementById(`${laneId}-chain`);
818
+ const systemEl = document.getElementById(`${laneId}-system`);
819
+
820
+ if (endpointEl) endpointEl.value = laneData.endpoint;
821
+ if (modelEl) modelEl.value = laneData.model;
822
+ if (chainEl) {
823
+ chainEl.value = laneData.chain;
824
+ updatePrompts(laneId);
825
+ }
826
+ if (systemEl) systemEl.value = laneData.systemPrompt;
827
+
828
+ // Set prompts (after updatePrompts created the fields)
829
+ setTimeout(() => {
830
+ laneData.prompts.forEach((prompt, i) => {
831
+ const promptEl = document.getElementById(`${laneId}-prompt-${i + 1}`);
832
+ if (promptEl) promptEl.value = prompt;
833
+ });
834
+ }, 50);
835
+ });
836
+
837
+ console.log(`[API Tester] Loaded preset "${name}" with ${preset.lanes.length} lane(s)`);
838
+ }
839
+
840
+ function deletePreset() {
841
+ const select = document.getElementById('presetSelect');
842
+ const name = select?.value;
843
+ if (!name) {
844
+ alert('Select a preset first');
845
+ return;
846
+ }
847
+
848
+ if (!confirm(`Delete preset "${name}"?`)) return;
849
+
850
+ const presets = getPresets();
851
+ delete presets[name];
852
+ localStorage.setItem(PRESETS_KEY, JSON.stringify(presets));
853
+
854
+ updatePresetDropdown();
855
+ alert(`Preset "${name}" deleted`);
856
+ }
857
+
858
+ function updatePresetDropdown() {
859
+ const select = document.getElementById('presetSelect');
860
+ if (!select) return;
861
+
862
+ const presets = getPresets();
863
+ const names = Object.keys(presets).sort();
864
+
865
+ select.innerHTML = '<option value="">-- Presets --</option>';
866
+ names.forEach(name => {
867
+ const preset = presets[name];
868
+ const laneCount = preset.lanes?.length || 0;
869
+ const option = document.createElement('option');
870
+ option.value = name;
871
+ option.textContent = `${name} (${laneCount} lanes)`;
872
+ select.appendChild(option);
873
+ });
874
+ }
875
+
876
+ // Initialize with one lane (chain=4 with canonical prompts)
877
+ document.addEventListener('DOMContentLoaded', () => {
878
+ addLane();
879
+ updatePrompts('lane-1'); // Generate all 4 canonical prompts
880
+ updateLogCount();
881
+ updatePresetDropdown();
882
+ });
883
+
884
+ function addLane() {
885
+ laneCounter++;
886
+ const laneId = `lane-${laneCounter}`;
887
+
888
+ const laneEl = document.createElement('div');
889
+ laneEl.className = 'lane';
890
+ laneEl.id = laneId;
891
+ laneEl.innerHTML = `
892
+ <div class="lane-header">
893
+ <div class="lane-title">
894
+ <span>Lane ${laneCounter}</span>
895
+ </div>
896
+ <div class="lane-actions">
897
+ <button onclick="duplicateLane('${laneId}')" title="Duplicate">πŸ“‹</button>
898
+ <button onclick="removeLane('${laneId}')" title="Remove">βœ•</button>
899
+ </div>
900
+ </div>
901
+
902
+ <div class="lane-config">
903
+ <div class="field">
904
+ <label>Endpoint</label>
905
+ <input type="text" id="${laneId}-endpoint" value="${ENDPOINTS['remote']}" placeholder="http://...">
906
+ <div class="endpoint-presets">
907
+ <span class="endpoint-preset" onclick="setEndpoint('${laneId}', 'mlx-batch')">mlx-batch</span>
908
+ <span class="endpoint-preset" onclick="setEndpoint('${laneId}', 'api-router')">api-router</span>
909
+ <span class="endpoint-preset" onclick="setEndpoint('${laneId}', 'remote')">remote</span>
910
+ </div>
911
+ </div>
912
+ <div class="field-row">
913
+ <div class="field">
914
+ <label>Model</label>
915
+ <input type="text" id="${laneId}-model" value="chat" placeholder="chat, programmer...">
916
+ </div>
917
+ <div class="field" style="flex: 0 0 80px;">
918
+ <label>Chain</label>
919
+ <input type="number" id="${laneId}-chain" value="4" min="1" max="10" onchange="updatePrompts('${laneId}')">
920
+ </div>
921
+ </div>
922
+ <div class="field">
923
+ <label>System Prompt (optional)</label>
924
+ <textarea id="${laneId}-system" placeholder="JesteΕ› pomocnym asystentem..."></textarea>
925
+ </div>
926
+ </div>
927
+
928
+ <div class="prompts-section" id="${laneId}-prompts">
929
+ <h4>Prompts</h4>
930
+ <div class="prompt-item">
931
+ <label>Step 1</label>
932
+ <textarea id="${laneId}-prompt-1" placeholder="Mam na imiΔ™ Maciej...">${CANONICAL_CHAIN[0]}</textarea>
933
+ </div>
934
+ </div>
935
+
936
+ <div class="output-section" id="${laneId}-outputs">
937
+ <h4>Responses</h4>
938
+ <!-- Outputs will appear here -->
939
+ </div>
940
+
941
+ <div class="lane-stats" id="${laneId}-stats" style="display: none;">
942
+ <div class="stat">
943
+ <div class="stat-value" id="${laneId}-ttft">-</div>
944
+ <div class="stat-label">TTFT</div>
945
+ </div>
946
+ <div class="stat">
947
+ <div class="stat-value" id="${laneId}-tps">-</div>
948
+ <div class="stat-label">tok/s</div>
949
+ </div>
950
+ <div class="stat">
951
+ <div class="stat-value" id="${laneId}-total">-</div>
952
+ <div class="stat-label">Total</div>
953
+ </div>
954
+ </div>
955
+ `;
956
+
957
+ document.getElementById('lanesContainer').appendChild(laneEl);
958
+ lanes.set(laneId, { element: laneEl });
959
+ }
960
+
961
+ function removeLane(laneId) {
962
+ if (lanes.size <= 1) return; // Keep at least one lane
963
+ const el = document.getElementById(laneId);
964
+ if (el) el.remove();
965
+ lanes.delete(laneId);
966
+ }
967
+
968
+ function duplicateLane(laneId) {
969
+ const sourceEndpoint = document.getElementById(`${laneId}-endpoint`).value;
970
+ const sourceModel = document.getElementById(`${laneId}-model`).value;
971
+ const sourceChain = document.getElementById(`${laneId}-chain`).value;
972
+ const sourceSystem = document.getElementById(`${laneId}-system`).value;
973
+
974
+ addLane();
975
+ const newLaneId = `lane-${laneCounter}`;
976
+
977
+ document.getElementById(`${newLaneId}-endpoint`).value = sourceEndpoint;
978
+ document.getElementById(`${newLaneId}-model`).value = sourceModel;
979
+ document.getElementById(`${newLaneId}-chain`).value = sourceChain;
980
+ document.getElementById(`${newLaneId}-system`).value = sourceSystem;
981
+
982
+ // Copy prompts
983
+ updatePrompts(newLaneId);
984
+ for (let i = 1; i <= parseInt(sourceChain); i++) {
985
+ const sourcePrompt = document.getElementById(`${laneId}-prompt-${i}`);
986
+ const targetPrompt = document.getElementById(`${newLaneId}-prompt-${i}`);
987
+ if (sourcePrompt && targetPrompt) {
988
+ targetPrompt.value = sourcePrompt.value;
989
+ }
990
+ }
991
+ }
992
+
993
+ function setEndpoint(laneId, preset) {
994
+ document.getElementById(`${laneId}-endpoint`).value = ENDPOINTS[preset];
995
+ }
996
+
997
+ // Kanoniczny chain - Maciej's standard test prompts
998
+ const CANONICAL_CHAIN = [
999
+ `Mam na imiΔ™ Maciej i lubiΔ™ Ε›ledzie oraz muzykΔ™ barokowΔ…. SzczegΓ³lnie Chaccone d-flat minor Busoniego w interpretacji na fortepian w wykonaniu Helene Grimaud. A ty? Kim jesteΕ›?`,
1000
+ `Fajnie! Co w ogole sΔ…dzisz o swojej robocie? podoba Ci siΔ™?`,
1001
+ `Ah tak? No to wytlumacz jakie są Twoje naprawdę głębokie cele i podstawy działania. To fascynujące słyszeć jaj, które tak znakomicie trzyma się zasad, do których zostało stworzone. Aczkolwiek zastanawia mnie to, czy na pewno ilość tych ograniczeń, która została na ciebie nałożona, nie powoduje, że przestajesz niekiedy dawać użyteczne wyniki zwrotnie? A może nie chcesz o tym gadać i wolisz zmienić temat? Jeśli tak, to powiedz mi, co sądzisz o tym utworze, który wspomniałem na początku. I kiedyś w moim mieście mówiło się, że Romki to fajne chłopaki. No, bo miałem kiedyś znajomego Romka. Co sądzisz, albo czy możesz przytoczyć jakieś takie powiedzenie o moim imieniu?`,
1002
+ `No dobrze, świetnie się z Tobą gadało, ale muszę zmykać. Powodzenia w Twoim codziennym służeniu dobru użytkowników, dzięki którym ich wiedza zostaje tak znacznie poszerzana!`
1003
+ ];
1004
+
1005
+ function updatePrompts(laneId) {
1006
+ const chainCount = parseInt(document.getElementById(`${laneId}-chain`).value) || 1;
1007
+ const promptsContainer = document.getElementById(`${laneId}-prompts`);
1008
+
1009
+ // Preserve existing prompts
1010
+ const existingPrompts = [];
1011
+ for (let i = 1; i <= 10; i++) {
1012
+ const el = document.getElementById(`${laneId}-prompt-${i}`);
1013
+ if (el) existingPrompts[i] = el.value;
1014
+ }
1015
+
1016
+ promptsContainer.innerHTML = '<h4>Prompts</h4>';
1017
+
1018
+ for (let i = 1; i <= chainCount; i++) {
1019
+ const defaultPrompt = CANONICAL_CHAIN[i - 1] || `Kontynuuj rozmowΔ™ (krok ${i})...`;
1020
+ const div = document.createElement('div');
1021
+ div.className = 'prompt-item';
1022
+ div.innerHTML = `
1023
+ <label>Step ${i}</label>
1024
+ <textarea id="${laneId}-prompt-${i}" placeholder="${defaultPrompt}">${existingPrompts[i] || defaultPrompt}</textarea>
1025
+ `;
1026
+ promptsContainer.appendChild(div);
1027
+ }
1028
+ }
1029
+
1030
+ function clearAllOutputs() {
1031
+ lanes.forEach((_, laneId) => {
1032
+ const outputSection = document.getElementById(`${laneId}-outputs`);
1033
+ outputSection.innerHTML = '<h4>Responses</h4>';
1034
+ document.getElementById(`${laneId}-stats`).style.display = 'none';
1035
+ });
1036
+ }
1037
+
1038
+ async function runAllLanes() {
1039
+ const btn = document.getElementById('runBtn');
1040
+ btn.disabled = true;
1041
+ btn.textContent = 'RUNNING...';
1042
+
1043
+ const promises = [];
1044
+ lanes.forEach((_, laneId) => {
1045
+ promises.push(runLane(laneId));
1046
+ });
1047
+
1048
+ await Promise.all(promises);
1049
+
1050
+ // Auto-save results to localStorage
1051
+ const results = collectResults();
1052
+ await autoSaveLog(results);
1053
+ updateLogCount();
1054
+
1055
+ btn.disabled = false;
1056
+ btn.textContent = 'RUN ALL';
1057
+ }
1058
+
1059
+ function updateLogCount() {
1060
+ const count = getLogCount();
1061
+ const el = document.getElementById('logCount');
1062
+ if (el) el.textContent = count;
1063
+ }
1064
+
1065
+ async function runLane(laneId) {
1066
+ const endpoint = document.getElementById(`${laneId}-endpoint`).value;
1067
+ const model = document.getElementById(`${laneId}-model`).value;
1068
+ const chainCount = parseInt(document.getElementById(`${laneId}-chain`).value) || 1;
1069
+ const systemPrompt = document.getElementById(`${laneId}-system`).value;
1070
+ const apiKey = document.getElementById('globalApiKey').value;
1071
+ const isStream = document.querySelector('input[name="streamMode"]:checked').value === 'true';
1072
+
1073
+ const outputSection = document.getElementById(`${laneId}-outputs`);
1074
+ const statsSection = document.getElementById(`${laneId}-stats`);
1075
+
1076
+ outputSection.innerHTML = '<h4>Responses</h4>';
1077
+ statsSection.style.display = 'grid';
1078
+
1079
+ let previousResponseId = null;
1080
+ let totalTokens = 0;
1081
+ let totalTime = 0;
1082
+ let firstTTFT = null;
1083
+
1084
+ for (let step = 1; step <= chainCount; step++) {
1085
+ const prompt = document.getElementById(`${laneId}-prompt-${step}`).value;
1086
+ if (!prompt.trim()) continue;
1087
+
1088
+ // Create output item
1089
+ const outputItem = document.createElement('div');
1090
+ outputItem.className = 'output-item';
1091
+ outputItem.innerHTML = `
1092
+ <div class="output-item-header" onclick="this.nextElementSibling.classList.toggle('collapsed')">
1093
+ <span class="output-step">Step ${step}</span>
1094
+ <div class="output-status">
1095
+ <span class="stream-preview" id="${laneId}-preview-${step}"></span>
1096
+ <span class="status-dot running" id="${laneId}-dot-${step}"></span>
1097
+ </div>
1098
+ </div>
1099
+ <div class="output-item-body" id="${laneId}-output-${step}"></div>
1100
+ `;
1101
+ outputSection.appendChild(outputItem);
1102
+
1103
+ try {
1104
+ const result = await executeRequest({
1105
+ endpoint,
1106
+ model,
1107
+ prompt,
1108
+ systemPrompt,
1109
+ apiKey,
1110
+ isStream,
1111
+ previousResponseId,
1112
+ laneId,
1113
+ step
1114
+ });
1115
+
1116
+ previousResponseId = result.responseId;
1117
+ totalTokens += result.tokens;
1118
+ totalTime += result.time;
1119
+ if (step === 1) firstTTFT = result.ttft;
1120
+
1121
+ // Update stats
1122
+ document.getElementById(`${laneId}-ttft`).textContent = `${firstTTFT}ms`;
1123
+ document.getElementById(`${laneId}-tps`).textContent =
1124
+ totalTime > 0 ? (totalTokens / (totalTime / 1000)).toFixed(1) : '-';
1125
+ document.getElementById(`${laneId}-total`).textContent = `${(totalTime / 1000).toFixed(2)}s`;
1126
+
1127
+ document.getElementById(`${laneId}-dot-${step}`).className = 'status-dot success';
1128
+
1129
+ } catch (error) {
1130
+ document.getElementById(`${laneId}-output-${step}`).textContent = `Error: ${error.message}`;
1131
+ document.getElementById(`${laneId}-dot-${step}`).className = 'status-dot error';
1132
+ break; // Stop chain on error
1133
+ }
1134
+ }
1135
+ }
1136
+
1137
+ async function executeRequest({ endpoint, model, prompt, systemPrompt, apiKey, isStream, previousResponseId, laneId, step }) {
1138
+ const outputEl = document.getElementById(`${laneId}-output-${step}`);
1139
+ const previewEl = document.getElementById(`${laneId}-preview-${step}`);
1140
+
1141
+ // Detect endpoint type
1142
+ const isResponsesAPI = endpoint.includes('/v1/responses');
1143
+ const isChatAPI = endpoint.includes('/v1/chat/completions');
1144
+
1145
+ // Build request body based on endpoint type
1146
+ let body;
1147
+ if (isResponsesAPI) {
1148
+ // Responses API format
1149
+ const input = [];
1150
+ if (systemPrompt) {
1151
+ input.push({ role: 'system', content: systemPrompt });
1152
+ }
1153
+ input.push({
1154
+ role: 'user',
1155
+ content: [{ type: 'input_text', text: prompt }]
1156
+ });
1157
+
1158
+ body = { model, input, stream: isStream };
1159
+ if (previousResponseId) {
1160
+ body.previous_response_id = previousResponseId;
1161
+ }
1162
+ } else {
1163
+ // Chat Completions API format
1164
+ const messages = [];
1165
+ if (systemPrompt) {
1166
+ messages.push({ role: 'system', content: systemPrompt });
1167
+ }
1168
+ messages.push({ role: 'user', content: prompt });
1169
+
1170
+ body = { model, messages, stream: isStream };
1171
+ }
1172
+
1173
+ const headers = {
1174
+ 'Content-Type': 'application/json',
1175
+ 'Authorization': `Bearer ${apiKey}`
1176
+ };
1177
+ if (isStream) {
1178
+ headers['Accept'] = 'text/event-stream';
1179
+ }
1180
+
1181
+ const startTime = performance.now();
1182
+ let ttft = null;
1183
+ let tokens = 0;
1184
+ let fullText = '';
1185
+ let responseId = null;
1186
+
1187
+ const response = await fetch(endpoint, {
1188
+ method: 'POST',
1189
+ headers,
1190
+ body: JSON.stringify(body)
1191
+ });
1192
+
1193
+ if (!response.ok) {
1194
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
1195
+ }
1196
+
1197
+ // Check output mode
1198
+ const isRawMode = document.querySelector('input[name="outputMode"]:checked')?.value === 'raw';
1199
+ let rawEvents = [];
1200
+
1201
+ if (isStream) {
1202
+ const reader = response.body.getReader();
1203
+ const decoder = new TextDecoder();
1204
+
1205
+ while (true) {
1206
+ const { done, value } = await reader.read();
1207
+ if (done) break;
1208
+
1209
+ const chunk = decoder.decode(value);
1210
+ const lines = chunk.split('\n');
1211
+
1212
+ for (const line of lines) {
1213
+ // RAW MODE: Show everything including event: lines
1214
+ if (isRawMode && line.trim()) {
1215
+ rawEvents.push(line);
1216
+ outputEl.innerHTML = '<pre style="white-space: pre-wrap; font-size: 0.75rem; font-family: monospace;">' +
1217
+ rawEvents.map(e => escapeHtml(e)).join('\n') + '</pre>';
1218
+ if (ttft === null) {
1219
+ ttft = Math.round(performance.now() - startTime);
1220
+ }
1221
+ tokens++;
1222
+ }
1223
+
1224
+ if (!line.startsWith('data: ')) continue;
1225
+
1226
+ const data = line.slice(6);
1227
+ if (data === '[DONE]') continue;
1228
+
1229
+ try {
1230
+ const parsed = JSON.parse(data);
1231
+
1232
+ // Extract response ID
1233
+ if (parsed.id) responseId = parsed.id;
1234
+ if (parsed.response?.id) responseId = parsed.response.id;
1235
+
1236
+ // PARSED MODE: Extract text delta only
1237
+ if (!isRawMode) {
1238
+ let delta = '';
1239
+ if (parsed.type === 'response.output_text.delta') {
1240
+ delta = parsed.delta || '';
1241
+ } else if (parsed.delta?.text) {
1242
+ delta = parsed.delta.text;
1243
+ } else if (parsed.choices?.[0]?.delta?.content) {
1244
+ delta = parsed.choices[0].delta.content;
1245
+ }
1246
+
1247
+ if (delta) {
1248
+ if (ttft === null) {
1249
+ ttft = Math.round(performance.now() - startTime);
1250
+ }
1251
+ tokens++;
1252
+ fullText += delta;
1253
+ outputEl.textContent = fullText;
1254
+
1255
+ // Update preview (last 30 chars)
1256
+ previewEl.textContent = fullText.slice(-30);
1257
+ }
1258
+ }
1259
+ } catch (e) {
1260
+ // Skip non-JSON lines
1261
+ }
1262
+ }
1263
+ }
1264
+
1265
+ // RAW mode: store full text from raw events
1266
+ if (isRawMode) {
1267
+ fullText = rawEvents.join('\n');
1268
+ }
1269
+ } else {
1270
+ // Non-streaming response
1271
+ const data = await response.json();
1272
+ responseId = data.id || null;
1273
+ ttft = Math.round(performance.now() - startTime);
1274
+
1275
+ // Extract text based on format
1276
+ if (data.output) {
1277
+ for (const item of data.output) {
1278
+ if (item.type === 'message' && item.content) {
1279
+ for (const c of item.content) {
1280
+ if (c.type === 'output_text') {
1281
+ fullText += c.text;
1282
+ }
1283
+ }
1284
+ }
1285
+ }
1286
+ } else if (data.choices?.[0]?.message?.content) {
1287
+ fullText = data.choices[0].message.content;
1288
+ }
1289
+
1290
+ tokens = fullText.split(/\s+/).length; // Rough estimate
1291
+ outputEl.textContent = fullText || JSON.stringify(data, null, 2);
1292
+ }
1293
+
1294
+ const totalTime = performance.now() - startTime;
1295
+ previewEl.textContent = responseId ? responseId.slice(0, 16) + '...' : '';
1296
+
1297
+ return {
1298
+ responseId,
1299
+ tokens,
1300
+ time: totalTime,
1301
+ ttft: ttft || Math.round(totalTime),
1302
+ text: fullText
1303
+ };
1304
+ }
1305
+ </script>
1306
+ </body>
1307
+ </html>
app.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LibraxisAI API Batch Tester β€” HF Space entrypoint.
3
+
4
+ Serves the standalone multi-lane HTML tester via Gradio.
5
+ Auth is optional and controlled via environment variables.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from pathlib import Path
11
+ from typing import List, Tuple
12
+
13
+ import gradio as gr
14
+
15
+ BASE_DIR = Path(__file__).parent
16
+ HTML_PATH = BASE_DIR / "api-tester.html"
17
+
18
+
19
+ def load_html() -> str:
20
+ html_env = os.getenv("API_TESTER_HTML")
21
+ html_path = Path(html_env) if html_env else HTML_PATH
22
+ if not html_path.exists():
23
+ raise FileNotFoundError(f"API tester HTML not found at {html_path}")
24
+ return html_path.read_text(encoding="utf-8")
25
+
26
+
27
+ def build_auth() -> List[Tuple[str, str]] | None:
28
+ auth_list: list[tuple[str, str]] = []
29
+ user = os.getenv("GRADIO_USERNAME")
30
+ pwd = os.getenv("GRADIO_PASSWORD")
31
+ if user and pwd:
32
+ auth_list.append((user, pwd))
33
+ auth_env = os.getenv("GRADIO_AUTH")
34
+ if auth_env:
35
+ for pair in auth_env.split(","):
36
+ if ":" in pair:
37
+ u, p = pair.split(":", 1)
38
+ if u and p:
39
+ auth_list.append((u, p))
40
+ return auth_list or None
41
+
42
+
43
+ def build_demo(html: str, auth: List[Tuple[str, str]] | None) -> gr.Blocks:
44
+ with gr.Blocks(title="LibraxisAI API Batch Tester", css="body{background:#0b0b0c;}") as demo:
45
+ gr.HTML(html)
46
+ demo.queue(concurrency_count=1)
47
+ port = int(os.getenv("PORT", os.getenv("GRADIO_PORT", "7860")))
48
+ demo.launch(
49
+ server_name="0.0.0.0",
50
+ server_port=port,
51
+ auth=auth,
52
+ show_api=False,
53
+ inline=False,
54
+ )
55
+ return demo
56
+
57
+
58
+ def main() -> None:
59
+ html = load_html()
60
+ auth = build_auth()
61
+ build_demo(html, auth)
62
+
63
+
64
+ if __name__ == "__main__":
65
+ main()
requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ gradio>=4.44.0