omnigate-ai 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Dockerfile ADDED
@@ -0,0 +1,37 @@
1
+ # ========================================================
2
+ # Stage 1: Dependency builder
3
+ # ========================================================
4
+ FROM node:20-alpine AS builder
5
+
6
+ WORKDIR /usr/src/app
7
+
8
+ # Copy package descriptors
9
+ COPY package*.json ./
10
+
11
+ # Install only production dependencies cleanly
12
+ RUN npm ci --only=production
13
+
14
+ # ========================================================
15
+ # Stage 2: Production runner
16
+ # ========================================================
17
+ FROM node:20-alpine AS runner
18
+
19
+ # Set production environment variables
20
+ ENV NODE_ENV=production
21
+ ENV PORT=8080
22
+
23
+ WORKDIR /usr/src/app
24
+
25
+ # Copy production artifacts from builder and source code
26
+ COPY package*.json ./
27
+ COPY --from=builder /usr/src/app/node_modules ./node_modules
28
+ COPY server.js ./
29
+
30
+ # Run container as a non-root node user for container hardening
31
+ USER node
32
+
33
+ # Expose port
34
+ EXPOSE 8080
35
+
36
+ # Run the gateway server
37
+ CMD ["node", "server.js"]
@@ -0,0 +1,72 @@
1
+ Product Requirement Document (PRD)
2
+ Project: OmniGate AI (Enterprise AI Cost Gateway & Proxy)
3
+ Document Version: 1.0
4
+ Target Audience: Engineering Teams, AI Agencies, and B2B Development Shops
5
+ 1. Executive Summary & Product Goal
6
+ 1.1 Core Objective
7
+ OmniGate AI is a self-hosted, privacy-first API gateway and proxy designed for software development agencies and enterprise teams building with Large Language Models (LLMs). The product solves the critical problem of "API Bill Shock"—unpredictable, skyrocketing AI costs caused by developer terminal tools (e.g., Claude Code), IDE extensions (e.g., Cursor, VS Code), and custom frontends that recursively send massive conversation histories and consume invisible "thinking/reasoning" tokens.
8
+ 1.2 Key Value Propositions
9
+ Privacy-First Architecture: Self-hosted deployments mean sensitive codebases and client data never pass through a third-party SaaS infrastructure.
10
+ Absolute Cost Transparency: Intercepts and logs precise, true token consumption (including hidden reasoning tokens) in real-time.
11
+ Granular Governance: Allows agency administrators to set hard spending limits, track costs by project or developer, and prevent runaway automated agent loops.
12
+ 2. Problem Statement & Critical Roadblocks
13
+ Through rigorous technical analysis, the following structural challenges have been identified and directly addressed in this specification:
14
+ 2.1 The Privacy Hurdle
15
+ Agencies handling proprietary enterprise code or regulated data (e.g., healthcare, financial applications) will refuse to route API traffic through an external vendor's proxy.
16
+ Solution: 100% decoupling of the data plane and the control plane. The proxy runs entirely within the client's network. Only metadata (token counts, timestamps, user IDs) is synchronized outwards.
17
+ 2.2 The "Invisible" Token Problem (Reasoning/Thinking)
18
+ Modern tier-1 models (like OpenAI's o1/o3 or Anthropic's extended thinking models) do not expose thinking tokens inside the visible body of streamed text. Calculating costs by running a local tokenizer (like tiktoken) over raw text output will result in massive calculation deficits, causing the agency to underreport costs.
19
+ Solution: Abandon localized text tokenization for streaming data. The gateway must modify incoming requests to force provider-side usage monitoring and extract the definitive billing token metadata directly from the final Server-Sent Events (SSE) payload chunks.
20
+ 3. Product Architecture & Technical Specifications
21
+ [ Developer Tool ]
22
+ (VS Code, Claude Code, Terminal)
23
+
24
+ ▼ (Modified API Base URL)
25
+ ┌────────────────────────────────────────────────────────┐
26
+ │ CLIENT CLOUD INFRASTRUCTURE (Self-Hosted Docker) │
27
+ │ │
28
+ │ [ OmniGate Proxy Container ] │
29
+ │ │ │
30
+ │ ├─► 1. Modify Request & Forward Streaming ────┼───► [ AI Provider API ]
31
+ │ │ │ (OpenAI, Anthropic)
32
+ │ └─► 2. Intercept Final SSE "Usage" Chunk │
33
+ │ │
34
+ └─────────────────────────┬──────────────────────────────┘
35
+
36
+ ▼ (Anonymized Telemetry Metadata Only)
37
+ [ Cloud Analytics Dashboard ]
38
+ 3.1 Core Proxy Service (Data Plane)
39
+ Deployment Vector: Packaged exclusively as a lightweight Docker container (node:20-alpine) deployable via Docker Compose or Kubernetes on client infra.
40
+ Multi-Provider Translation: Must support unified routing to major LLM providers:
41
+ OpenAI (/v1/chat/completions)
42
+ Anthropic (/v1/messages)
43
+ Token Interception Protocol:
44
+ For OpenAI Streams: The proxy must inspect incoming payloads and append/override "stream_options": {"include_usage": true}. The engine must actively listen for the final, non-text chunk containing the unified usage block.
45
+ For Anthropic Streams: The engine must parse the message_start event for input tracking and compile the final message_delta/message_stop events to ensure output_tokens (inclusive of thinking blocks) are fully captured.
46
+ 3.2 Cloud Control Plane (SaaS Dashboard)
47
+ Central Analytics Interface: A multi-tenant web platform where agency administrators log in to view charts, manage developer identities, and set budget thresholds.
48
+ Telemetry Synchronization: The proxy container transmits single, lightweight, non-blocking telemetry payloads upon the completion of every successful API execution block.
49
+ 4. Functional Requirements
50
+ 4.1 Proxy & Security Configuration
51
+
52
+
53
+
54
+
55
+
56
+
57
+
58
+ 4.2 Tracking & Analytics
59
+
60
+ 4.3 Governance & Controls (B2B Features)
61
+
62
+ 5. Non-Functional Requirements (NFRs)
63
+ Performance / Latency Overhead: The proxy stream evaluation loop must introduce less than 15ms of latency to the time-to-first-token (TTFT) and maintain zero perceptible drag during continuous token streaming.
64
+ Resiliency: If connection drops occur between the self-hosted container and the centralized SaaS dashboard, the proxy must cache telemetry logs locally using an internal, ephemeral SQLite fallback mechanism and flush them once connectivity is restored. API traffic flowing to developers must never be blocked by external telemetry synchronization issues.
65
+ 6. Success Metrics & Future Milestones
66
+ 6.1 Phase 1 Success Metrics (MVP)
67
+ Data Integrity: Zero discrepancy between the cost tracked on OmniGate and the end-of-month official bills generated by OpenAI/Anthropic.
68
+ Infrastructure Churn: Less than 1% container crash frequency across diverse agency deployment environments.
69
+ 6.2 Future Enhancements (Phase 2 Roadmap)
70
+ The Prompt Compressor: An intelligent token-reduction layer that scans prompt vectors within the proxy context and safely minifies data payloads to reduce active enterprise spend by 20–30% automatically.
71
+ Semantic De-duplication: Internal caching of static context layers (like massive code files or dense system prompts) to prevent redundant upstream transmissions across adjacent development teams.
72
+ Does this PRD provide the structural depth you need to begin technical execution, or would you like to expand the specifications on how the circuit breaker architecture detects runaway developer loops?
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../server.js';
@@ -0,0 +1,16 @@
1
+ version: '3.8'
2
+
3
+ services:
4
+ gateway:
5
+ build:
6
+ context: .
7
+ dockerfile: Dockerfile
8
+ container_name: omnigate-gateway
9
+ ports:
10
+ - "8080:8080"
11
+ environment:
12
+ - PORT=8080
13
+ - AGENCY_GATEWAY_KEY=stub-agency-key-for-local-testing
14
+ - OPENAI_API_KEY=your-openai-api-key-stub
15
+ - ANTHROPIC_API_KEY=your-anthropic-api-key-stub
16
+ restart: unless-stopped
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "omnigate-ai",
3
+ "version": "1.0.0",
4
+ "description": "Privacy-first, self-hosted LLM API Gateway and Proxy for tracking token usage.",
5
+ "main": "server.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "omnigate": "./bin/omnigate.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node server.js",
12
+ "dev": "node --watch server.js"
13
+ },
14
+ "keywords": [
15
+ "openai",
16
+ "anthropic",
17
+ "proxy",
18
+ "gateway",
19
+ "telemetry",
20
+ "token-counting",
21
+ "privacy-first"
22
+ ],
23
+ "author": "OmniGate AI Team",
24
+ "license": "MIT",
25
+ "dependencies": {
26
+ "cors": "^2.8.5",
27
+ "dotenv": "^16.4.5",
28
+ "express": "^4.19.2"
29
+ }
30
+ }
@@ -0,0 +1 @@
1
+ {"model":"deepseek-chat","messages":[{"role":"user","content":"Generate CSS for a dark/light theme toggle with a glassmorphism aesthetic. The dark theme should use deep purple-blue gradient background with glass cards. The light theme should use a soft off-white/light blue gradient with frosted glass cards. Both should feel elegant and polished. Include CSS variables for both themes."}],"stream":false}
@@ -0,0 +1,60 @@
1
+ /**
2
+ * This helper configures OmniGate proxy to work with DeepSeek/OpenAI
3
+ * and displays token usage information.
4
+ *
5
+ * The proxy is already running on port 8080.
6
+ * Configure your API key via the dashboard at http://localhost:8080/dashboard/
7
+ * Gateway Key: stub-agency-key-for-local-testing
8
+ */
9
+
10
+ const PROXY_URL = 'http://localhost:8080';
11
+
12
+ // Pricing reference (from the dashboard)
13
+ const PRICING = {
14
+ 'gpt-4o': { in: 5.00, out: 15.00 },
15
+ 'gpt-4o-mini': { in: 0.15, out: 0.60 },
16
+ 'deepseek-chat': { in: 0.27, out: 1.10 },
17
+ 'claude-3-5-sonnet-20241022': { in: 3.00, out: 15.00 },
18
+ 'default': { in: 2.00, out: 10.00 }
19
+ };
20
+
21
+ export function calculateCost(model, inTokens, outTokens) {
22
+ const rates = PRICING[model] || PRICING['default'];
23
+ return (inTokens / 1000000) * rates.in + (outTokens / 1000000) * rates.out;
24
+ }
25
+
26
+ export async function getTelemetry() {
27
+ const res = await fetch(`${PROXY_URL}/api/telemetry`);
28
+ return res.json();
29
+ }
30
+
31
+ export async function getConfig() {
32
+ const res = await fetch(`${PROXY_URL}/api/config`, {
33
+ headers: { 'X-Gateway-Key': 'stub-agency-key-for-local-testing' }
34
+ });
35
+ return res.json();
36
+ }
37
+
38
+ console.log('\n╔══════════════════════════════════════════════════════════╗');
39
+ console.log('║ 🚪 OmniGate Proxy Helper ║');
40
+ console.log('║ ║');
41
+ console.log('║ 1. Open http://localhost:8080/dashboard/ in browser ║');
42
+ console.log('║ 2. Enter Gateway Key: stub-agency-key-for-local-testing ║');
43
+ console.log('║ 3. Paste your API key and Save ║');
44
+ console.log('║ 4. Come back here and I will send test requests ║');
45
+ console.log('║ through the proxy so you see them on dashboard ║');
46
+ console.log('╚══════════════════════════════════════════════════════════╝\n');
47
+
48
+ // Check current config status
49
+ const config = await getConfig();
50
+ console.log('Current proxy config:');
51
+ console.log(` OpenAI URL: ${config.openaiApiUrl}`);
52
+ console.log(` API Key set: ${config.openaiApiKey ? '✅ Yes' : '❌ No'}`);
53
+ console.log(` Gateway Key: ${config.agencyGatewayKey}\n`);
54
+
55
+ if (!config.openaiApiKey) {
56
+ console.log('⚠️ No API key configured yet.');
57
+ console.log('➡️ Go to http://localhost:8080/dashboard/ and enter your key.\n');
58
+ } else {
59
+ console.log('✅ API key is configured! Ready to route requests through proxy.\n');
60
+ }
package/public/app.js ADDED
@@ -0,0 +1,432 @@
1
+ document.addEventListener('DOMContentLoaded', () => {
2
+ // Constants for generic pricing estimates (per 1M tokens)
3
+ const PRICING = {
4
+ 'gpt-4o': { in: 5.00, out: 15.00 },
5
+ 'claude-3-5-sonnet-20241022': { in: 3.00, out: 15.00 },
6
+ 'default': { in: 2.00, out: 10.00 } // fallback
7
+ };
8
+
9
+ // State
10
+ let totalTokens = 0;
11
+ let totalInputTokens = 0;
12
+ let totalOutputTokens = 0;
13
+ let totalCost = 0.0;
14
+ let totalRequests = 0;
15
+
16
+ // DOM Elements
17
+ const elTotalTokens = document.getElementById('total-tokens');
18
+ const elInputTokens = document.getElementById('input-tokens');
19
+ const elOutputTokens = document.getElementById('output-tokens');
20
+
21
+ const elTotalCost = document.getElementById('total-cost');
22
+ const elAvgCost = document.getElementById('avg-cost');
23
+
24
+ const elRpmValue = document.getElementById('rpm-value');
25
+ const elTotalRequests = document.getElementById('total-requests');
26
+
27
+ const tokensGauge = document.getElementById('tokens-gauge');
28
+ const costGauge = document.getElementById('cost-gauge');
29
+ const rpmGauge = document.getElementById('rpm-gauge');
30
+ const tbody = document.getElementById('telemetry-tbody');
31
+
32
+ // Track timestamps for RPM calculation
33
+ const requestTimestamps = [];
34
+
35
+ function calculateCost(model, inTokens, outTokens) {
36
+ const rates = PRICING[model] || PRICING['default'];
37
+ return (inTokens / 1000000) * rates.in + (outTokens / 1000000) * rates.out;
38
+ }
39
+
40
+ function formatNumber(num) {
41
+ return new Intl.NumberFormat('en-US').format(num);
42
+ }
43
+
44
+ function updateGauges() {
45
+ // Max tokens scale (e.g., 50k max for the gauge visual to be more reactive to test data)
46
+ const MAX_TOKENS = 50000;
47
+ let tokenPct = Math.min(totalTokens / MAX_TOKENS, 1);
48
+ tokensGauge.style.strokeDashoffset = 283 - (283 * tokenPct);
49
+
50
+ // Max cost scale (e.g. $0.5 max for reactivity)
51
+ const MAX_COST = 0.5;
52
+ let costPct = Math.min(totalCost / MAX_COST, 1);
53
+ costGauge.style.strokeDashoffset = 283 - (283 * costPct);
54
+
55
+ // RPM gauge (max 60 RPM)
56
+ const now = Date.now();
57
+ // Keep requests from last 60 seconds
58
+ while (requestTimestamps.length > 0 && now - requestTimestamps[0] > 60000) {
59
+ requestTimestamps.shift();
60
+ }
61
+ const rpm = requestTimestamps.length;
62
+ let rpmPct = Math.min(rpm / 60, 1);
63
+ rpmGauge.style.strokeDashoffset = 283 - (283 * rpmPct);
64
+
65
+ elRpmValue.textContent = rpm;
66
+ }
67
+
68
+ // Periodic RPM update
69
+ setInterval(updateGauges, 2000);
70
+
71
+ function renderRow(tel, isNew = false) {
72
+ const cost = calculateCost(tel.model, tel.inputTokens, tel.outputTokens);
73
+
74
+ // Update State
75
+ totalTokens += (tel.inputTokens + tel.outputTokens);
76
+ totalInputTokens += tel.inputTokens;
77
+ totalOutputTokens += tel.outputTokens;
78
+ totalCost += cost;
79
+ totalRequests += 1;
80
+ requestTimestamps.push(new Date(tel.timestamp).getTime());
81
+
82
+ // Update DOM Text
83
+ elTotalTokens.textContent = formatNumber(totalTokens);
84
+ elInputTokens.textContent = formatNumber(totalInputTokens);
85
+ elOutputTokens.textContent = formatNumber(totalOutputTokens);
86
+
87
+ elTotalCost.textContent = `$${totalCost.toFixed(3)}`;
88
+ elAvgCost.textContent = `$${(totalCost / totalRequests).toFixed(4)}`;
89
+ elTotalRequests.textContent = formatNumber(totalRequests);
90
+
91
+ // Create Row
92
+ const tr = document.createElement('tr');
93
+ if (isNew) tr.className = 'new-row';
94
+
95
+ const time = new Date(tel.timestamp).toLocaleTimeString();
96
+
97
+ tr.innerHTML = `
98
+ <td>${time}</td>
99
+ <td><strong>${tel.provider.toUpperCase()}</strong><br><span style="color:#94a3b8;font-size:0.75rem">${tel.model}</span></td>
100
+ <td>${tel.projectId}</td>
101
+ <td>${tel.userId}</td>
102
+ <td class="text-right">${formatNumber(tel.inputTokens)}</td>
103
+ <td class="text-right">${formatNumber(tel.outputTokens)}</td>
104
+ <td class="text-right" style="color:var(--neon-cyan)">$${cost.toFixed(4)}</td>
105
+ `;
106
+
107
+ tbody.insertBefore(tr, tbody.firstChild);
108
+
109
+ // Keep table from growing infinitely
110
+ if (tbody.children.length > 50) {
111
+ tbody.removeChild(tbody.lastChild);
112
+ }
113
+
114
+ updateGauges();
115
+ }
116
+
117
+ let dashboardInitialized = false;
118
+ let evtSource = null;
119
+
120
+ function initializeDashboard() {
121
+ if (dashboardInitialized) return;
122
+ dashboardInitialized = true;
123
+
124
+ // 1. Fetch initial history
125
+ tbody.innerHTML = ''; // Clear rows
126
+ fetch('/api/telemetry')
127
+ .then(res => res.json())
128
+ .then(data => {
129
+ // Clear states before reloading
130
+ totalTokens = 0;
131
+ totalInputTokens = 0;
132
+ totalOutputTokens = 0;
133
+ totalCost = 0.0;
134
+ totalRequests = 0;
135
+ requestTimestamps.length = 0;
136
+
137
+ data.reverse().forEach(tel => renderRow(tel, false));
138
+ })
139
+ .catch(err => console.error("Error fetching telemetry:", err));
140
+
141
+ // 2. Connect to live SSE stream
142
+ if (evtSource) {
143
+ evtSource.close();
144
+ }
145
+ evtSource = new EventSource('/api/telemetry/stream');
146
+ evtSource.onmessage = (e) => {
147
+ try {
148
+ const tel = JSON.parse(e.data);
149
+ renderRow(tel, true);
150
+ } catch (err) {
151
+ console.error("Stream parse error:", err);
152
+ }
153
+ };
154
+ }
155
+
156
+ // ==========================================
157
+ // Configuration & Security Lock Logic
158
+ // ==========================================
159
+ const configForm = document.getElementById('config-form');
160
+ const openaiPreset = document.getElementById('openaiPreset');
161
+ const openaiApiUrl = document.getElementById('openaiApiUrl');
162
+ const configStatus = document.getElementById('config-status');
163
+
164
+ const gaugeWrapper = document.getElementById('gauge-cluster-wrapper');
165
+ const dataWrapper = document.getElementById('data-panel-wrapper');
166
+
167
+ function getGatewayKey() {
168
+ return localStorage.getItem('gatewayKey') || '';
169
+ }
170
+
171
+ function setLockedState(isLocked) {
172
+ if (isLocked) {
173
+ gaugeWrapper.classList.add('locked-section');
174
+ dataWrapper.classList.add('locked-section');
175
+ } else {
176
+ gaugeWrapper.classList.remove('locked-section');
177
+ dataWrapper.classList.remove('locked-section');
178
+ }
179
+ }
180
+
181
+ function showOnboardingModal() {
182
+ const modal = document.getElementById('onboarding-modal');
183
+ if (modal) modal.classList.remove('hidden');
184
+ }
185
+
186
+ function checkAuthAndLoad() {
187
+ const key = getGatewayKey();
188
+ if (!key) {
189
+ setLockedState(true);
190
+ showOnboardingModal();
191
+ return;
192
+ }
193
+
194
+ // Validate key and fetch configs
195
+ fetch('/api/config', {
196
+ headers: { 'x-gateway-key': key }
197
+ })
198
+ .then(res => {
199
+ if (res.status === 401) {
200
+ localStorage.removeItem('gatewayKey');
201
+ setLockedState(true);
202
+ showOnboardingModal();
203
+ alert("Unauthorized. Incorrect Gateway Secret Key.");
204
+ throw new Error("Unauthorized. Incorrect Gateway Secret Key.");
205
+ }
206
+ return res.json();
207
+ })
208
+ .then(config => {
209
+ // Success: Unlock dashboard!
210
+ setLockedState(false);
211
+ initializeDashboard();
212
+
213
+ document.getElementById('agencyGatewayKey').value = config.agencyGatewayKey || '';
214
+ document.getElementById('openaiApiUrl').value = config.openaiApiUrl || '';
215
+ document.getElementById('openaiApiKey').placeholder = config.openaiApiKey || 'Enter API key (leave blank to keep current)';
216
+ document.getElementById('anthropicApiUrl').value = config.anthropicApiUrl || '';
217
+ document.getElementById('anthropicApiKey').placeholder = config.anthropicApiKey || 'Enter API key (leave blank to keep current)';
218
+
219
+ // Select preset if matched
220
+ let foundPreset = false;
221
+ for (const option of openaiPreset.options) {
222
+ if (option.value === config.openaiApiUrl) {
223
+ openaiPreset.value = config.openaiApiUrl;
224
+ foundPreset = true;
225
+ break;
226
+ }
227
+ }
228
+ if (!foundPreset && config.openaiApiUrl) {
229
+ openaiPreset.value = 'custom';
230
+ }
231
+ })
232
+ .catch(err => {
233
+ console.error("Config load error:", err);
234
+ });
235
+ }
236
+
237
+ // Run initial check
238
+ checkAuthAndLoad();
239
+
240
+ // Handle preset selection
241
+ openaiPreset.addEventListener('change', (e) => {
242
+ if (e.target.value !== 'custom') {
243
+ openaiApiUrl.value = e.target.value;
244
+ }
245
+ });
246
+
247
+ // Handle save and unlock
248
+ configForm.addEventListener('submit', (e) => {
249
+ e.preventDefault();
250
+
251
+ configStatus.textContent = "Saving and validating...";
252
+ configStatus.className = "config-status";
253
+
254
+ const enteredKey = document.getElementById('agencyGatewayKey').value;
255
+ localStorage.setItem('gatewayKey', enteredKey);
256
+
257
+ const payload = {
258
+ agencyGatewayKey: enteredKey,
259
+ openaiApiUrl: document.getElementById('openaiApiUrl').value,
260
+ anthropicApiUrl: document.getElementById('anthropicApiUrl').value,
261
+ };
262
+
263
+ const openaiApiKey = document.getElementById('openaiApiKey').value;
264
+ if (openaiApiKey) payload.openaiApiKey = openaiApiKey;
265
+
266
+ const anthropicApiKey = document.getElementById('anthropicApiKey').value;
267
+ if (anthropicApiKey) payload.anthropicApiKey = anthropicApiKey;
268
+
269
+ fetch('/api/config', {
270
+ method: 'POST',
271
+ headers: {
272
+ 'Content-Type': 'application/json',
273
+ 'x-gateway-key': enteredKey
274
+ },
275
+ body: JSON.stringify(payload)
276
+ })
277
+ .then(res => {
278
+ if (res.status === 401) {
279
+ localStorage.removeItem('gatewayKey');
280
+ setLockedState(true);
281
+ throw new Error("Invalid Gateway Secret Key. Access Denied.");
282
+ }
283
+ return res.json();
284
+ })
285
+ .then(data => {
286
+ if (data.status === 'success') {
287
+ configStatus.textContent = "Dashboard unlocked and configurations saved!";
288
+ configStatus.classList.add('success');
289
+
290
+ setLockedState(false);
291
+ initializeDashboard();
292
+
293
+ // Clear password fields so placeholders remain
294
+ document.getElementById('openaiApiKey').value = '';
295
+ document.getElementById('anthropicApiKey').value = '';
296
+ } else {
297
+ configStatus.textContent = data.error || "Failed to save.";
298
+ configStatus.classList.add('error');
299
+ }
300
+ setTimeout(() => configStatus.textContent = "", 4000);
301
+ })
302
+ .catch(err => {
303
+ configStatus.textContent = err.message || "Network error. Failed to save.";
304
+ configStatus.classList.add('error');
305
+ setTimeout(() => configStatus.textContent = "", 4000);
306
+ });
307
+ });
308
+
309
+ // ==========================================
310
+ // Tab Switching Logic
311
+ // ==========================================
312
+ const tabButtons = document.querySelectorAll('.tab-btn');
313
+ const tabContents = document.querySelectorAll('.tab-content');
314
+
315
+ tabButtons.forEach(btn => {
316
+ btn.addEventListener('click', () => {
317
+ const targetTab = btn.getAttribute('data-tab');
318
+
319
+ tabButtons.forEach(b => b.classList.remove('active'));
320
+ tabContents.forEach(c => c.classList.remove('active'));
321
+
322
+ btn.classList.add('active');
323
+ const targetElement = document.getElementById(`tab-${targetTab}`);
324
+ if (targetElement) {
325
+ targetElement.classList.add('active');
326
+ }
327
+ });
328
+ });
329
+
330
+ // ==========================================
331
+ // Onboarding Modal Logic
332
+ // ==========================================
333
+ const modal = document.getElementById('onboarding-modal');
334
+ const btnNext = document.getElementById('btn-next');
335
+ const btnPrev = document.getElementById('btn-prev');
336
+ const btnFinish = document.getElementById('btn-finish');
337
+ const steps = document.querySelectorAll('.wizard-step');
338
+ const dots = document.querySelectorAll('.step-dot');
339
+
340
+ let currentStep = 1;
341
+ const totalSteps = steps.length;
342
+
343
+ function updateModalUI() {
344
+ steps.forEach(step => {
345
+ step.classList.remove('active', 'exit-left');
346
+ const stepNum = parseInt(step.id.split('-')[1]);
347
+ if (stepNum < currentStep) {
348
+ step.classList.add('exit-left');
349
+ } else if (stepNum === currentStep) {
350
+ step.classList.add('active');
351
+ }
352
+ });
353
+
354
+ dots.forEach(dot => {
355
+ const dotStep = parseInt(dot.getAttribute('data-step'));
356
+ dot.classList.toggle('active', dotStep === currentStep);
357
+ });
358
+
359
+ if (btnPrev) btnPrev.style.visibility = currentStep === 1 ? 'hidden' : 'visible';
360
+
361
+ if (currentStep === totalSteps) {
362
+ if (btnNext) btnNext.classList.add('hidden');
363
+ if (btnFinish) btnFinish.classList.remove('hidden');
364
+
365
+ const enteredKey = document.getElementById('onboardingGatewayKey').value;
366
+ const previewKeyEl = document.getElementById('preview-key');
367
+ if (previewKeyEl) {
368
+ previewKeyEl.textContent = enteredKey || 'your-key';
369
+ }
370
+ } else {
371
+ if (btnNext) btnNext.classList.remove('hidden');
372
+ if (btnFinish) btnFinish.classList.add('hidden');
373
+ }
374
+ }
375
+
376
+ if (btnNext) {
377
+ btnNext.addEventListener('click', () => {
378
+ if (currentStep < totalSteps) {
379
+ currentStep++;
380
+ updateModalUI();
381
+ }
382
+ });
383
+ }
384
+
385
+ if (btnPrev) {
386
+ btnPrev.addEventListener('click', () => {
387
+ if (currentStep > 1) {
388
+ currentStep--;
389
+ updateModalUI();
390
+ }
391
+ });
392
+ }
393
+
394
+ if (btnFinish) {
395
+ btnFinish.addEventListener('click', () => {
396
+ const enteredKey = document.getElementById('onboardingGatewayKey').value;
397
+ if (!enteredKey) {
398
+ alert("Please enter your Gateway Secret Key to continue.");
399
+ currentStep = 2;
400
+ updateModalUI();
401
+ return;
402
+ }
403
+
404
+ localStorage.setItem('gatewayKey', enteredKey);
405
+ if (modal) modal.classList.add('hidden');
406
+ checkAuthAndLoad();
407
+ });
408
+ }
409
+ });
410
+
411
+ // ==========================================
412
+ // Copy Code Snippet Helper
413
+ // ==========================================
414
+ window.copySnippet = function(id, btn) {
415
+ const codeElement = document.getElementById(id);
416
+ if (!codeElement) return;
417
+ const code = codeElement.textContent;
418
+ navigator.clipboard.writeText(code).then(() => {
419
+ const originalText = btn.textContent;
420
+ btn.textContent = "Copied!";
421
+ btn.style.borderColor = "var(--accent-green)";
422
+ btn.style.color = "var(--accent-green)";
423
+ setTimeout(() => {
424
+ btn.textContent = originalText;
425
+ btn.style.borderColor = "";
426
+ btn.style.color = "";
427
+ }, 2000);
428
+ }).catch(err => {
429
+ console.error("Failed to copy code snippet:", err);
430
+ });
431
+ };
432
+