ldrouter 1.17.1 → 1.17.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +24 -0
- package/dist/server/db/repositories/qoder-accounts.js +22 -1
- package/dist/server/providers/codex.js +78 -9
- package/dist/server/providers/pooled.js +4 -2
- package/dist/server/providers/qoder/catalog.js +8 -3
- package/dist/server/providers/qoder/client.js +46 -0
- package/dist/server/providers/qoder/constants.js +2 -0
- package/dist/server/providers/qoder/credentials.js +7 -4
- package/dist/server/providers/qoder/credits.js +72 -0
- package/dist/server/routes/admin/qoder.js +35 -3
- package/dist/web/assets/index-DQd2y4FN.css +1 -0
- package/dist/web/assets/{index-DAzlm3Y6.js → index-f8A3f3uE.js} +77 -77
- package/dist/web/index.html +2 -2
- package/migrations/0008_qoder_credits.sql +7 -0
- package/package.json +1 -1
- package/dist/web/assets/index-Xccks9E5.css +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,26 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/) and the project adheres to
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [1.17.3] - 2026-09-17
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Qoder accounts now show Credits — the account's real usage.** Qoder does not bill in tokens: its chat stream carries no usage block at all, so every Qoder request logged zero tokens and the panel had no usage figure to show. Credits are now read from Qoder's quota API (`GET /api/v2/quota/usage`) and shown per account: plan / add-on / org buckets, percentage used, expiry, and whether the quota is exhausted. A **Refresh credits** action re-reads them for every account.
|
|
12
|
+
- **Promotion-covered models are shown next to Credits.** Qoder keeps a model free by marking its catalog entry `is_free` — currently `qmodel_38max` (Qwen3.8-Max). Such a model spends no Credits, so an account at zero Credits still answers on it while every other model is refused. The panel now pairs **No plan credits** with **Free now: <model>** so a partly-working account reads as intended instead of looking like a router fault.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **A Qoder quota refusal was reported as a token rejection.** The upstream returns HTTP 200 with a `403 code 112` envelope inside the streamed body for quota/billing refusals; the attempt classifier checked `401/403` before billing, so a depleted account was labelled "job token was rejected after a refresh" and the account was force-re-exchanged on every request. Billing-shaped refusals are no longer mistaken for credential failures.
|
|
17
|
+
- **The account Test button reported a blocked account as working.** It only probed the model catalog, which proves the PAT exchanges and nothing else — the refusal arrives as a `403` envelope inside an HTTP 200 body, so a check that never sends a message cannot see it. Test now sends one real request, preferring a promotion-covered model so testing never spends Credits, and names the free models that still work when the account is out of them.
|
|
18
|
+
|
|
19
|
+
## [1.17.2] - 2026-09-17
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- **Every second message to a `codex/` model failed with `502 Codex upstream HTTP 400`.** The Responses `input` array was built with one content block type for all roles: assistant history went upstream as `input_text`, which the backend rejects ("Invalid value: 'input_text'. Supported values are: 'output_text' and 'refusal'"). The first message of a conversation worked because it carries no assistant turn; every follow-up carries the previous answer, so chat, combo and agent traffic died from the second turn onward. Assistant prose is now sent as `output_text`. Verified live against chatgpt.com with a two-turn conversation (both turns 200).
|
|
24
|
+
- **Tool history was rejected the same way.** `function_call` and `function_call_output` are top-level `input` items on the Responses API, never content blocks — a call nested in `content` answers `400 invalid_value` listing the permitted block types. Tool calls and their results are now emitted as flat items correlated by `call_id`.
|
|
25
|
+
- **`Codex upstream HTTP 400` said nothing about the cause.** The upstream error body, which names the offending field (`param input[i].content[j]`), was discarded. It is now included in the thrown message, secret-redacted and truncated.
|
|
26
|
+
|
|
7
27
|
## [1.17.1] - 2026-09-17
|
|
8
28
|
|
|
9
29
|
### Added
|
package/README.md
CHANGED
|
@@ -81,6 +81,30 @@ Because the gateway serves model discovery from that live catalog, a new account
|
|
|
81
81
|
|
|
82
82
|
The panel shows the masked user id, job-token expiry, when the catalog was last fetched, health, and an enabled toggle. Routing order is set by dragging rows, exactly as with Codex.
|
|
83
83
|
|
|
84
|
+
### Qoder usage, Credits and promotions
|
|
85
|
+
|
|
86
|
+
Qoder does not meter models in tokens: its chat stream carries no usage block at all, so request logs
|
|
87
|
+
show zero tokens for every Qoder model and the panel's **Credits** column is the account's real
|
|
88
|
+
usage. It is read from Qoder's quota API (`GET /api/v2/quota/usage` on `openapi.qoder.sh`) and shows
|
|
89
|
+
the plan / add-on / org buckets, the percentage used, and whether the account is exhausted.
|
|
90
|
+
|
|
91
|
+
**Credits and free models are different things, and the panel shows both.** Qoder keeps promoting a
|
|
92
|
+
model — currently `qmodel_38max`, the Qwen3.8-Max route — by marking its catalog entry `is_free`.
|
|
93
|
+
Such a model spends no Credits, so an account at **zero Credits** still answers on it while every
|
|
94
|
+
other model returns an envelope `403 code 112` with a `pricingUrl`. That pairing is what makes an
|
|
95
|
+
"out of credits" account look partly broken: the panel labels the column **No plan credits** next to
|
|
96
|
+
**Free now: qmodel_38max** so the contradiction is visible instead of looking like a router fault.
|
|
97
|
+
|
|
98
|
+
There is an upstream edge worth knowing when reading logs: a quota refusal arrives as **HTTP 200**
|
|
99
|
+
with a `403` envelope inside the streamed body, so it is not an HTTP error at the transport layer.
|
|
100
|
+
|
|
101
|
+
Use **Refresh credits** to re-read the snapshot for every account. It deliberately reuses the live
|
|
102
|
+
`job token` instead of exchanging the PAT again — a fresh exchange invalidates the token a
|
|
103
|
+
concurrent request may be using.
|
|
104
|
+
|
|
105
|
+
**Test** sends one real message, because a catalog probe cannot see a quota refusal. It picks a
|
|
106
|
+
promotion-covered model when the account has one, so testing never spends Credits.
|
|
107
|
+
|
|
84
108
|
## Environment variables
|
|
85
109
|
|
|
86
110
|
| Variable | Description | Default |
|
|
@@ -5,6 +5,18 @@ import { decryptSecret, encryptSecret } from '../../auth/crypto.js';
|
|
|
5
5
|
import { uuid } from '../../auth/ids.js';
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
7
|
import { getRawDb } from '../index.js';
|
|
8
|
+
/** A corrupted or absent snapshot reads as "no data", never as a thrown error on a list call. */
|
|
9
|
+
function parseStoredCredits(json) {
|
|
10
|
+
if (!json)
|
|
11
|
+
return null;
|
|
12
|
+
try {
|
|
13
|
+
const parsed = JSON.parse(json);
|
|
14
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
8
20
|
export function maskQoderValue(value) {
|
|
9
21
|
if (!value)
|
|
10
22
|
return null;
|
|
@@ -18,7 +30,7 @@ export function identityFromQoderRecord(record) {
|
|
|
18
30
|
label: record.label,
|
|
19
31
|
};
|
|
20
32
|
}
|
|
21
|
-
const SUMMARY_COLUMNS = 'id,provider_id AS providerId,label,email,qoder_user_id AS qoderUserId,machine_id AS machineId,enabled,health_state AS healthState,priority,job_token_expires_at AS jobTokenExpiresAt,catalog_fetched_at AS catalogFetchedAt,last_error AS lastError,consecutive_failures AS consecutiveFailures,created_at AS createdAt,updated_at AS updatedAt';
|
|
33
|
+
const SUMMARY_COLUMNS = 'id,provider_id AS providerId,label,email,qoder_user_id AS qoderUserId,machine_id AS machineId,enabled,health_state AS healthState,priority,job_token_expires_at AS jobTokenExpiresAt,catalog_fetched_at AS catalogFetchedAt,credits_json AS creditsJson,credits_updated_at AS creditsUpdatedAt,credits_error AS creditsError,last_error AS lastError,consecutive_failures AS consecutiveFailures,created_at AS createdAt,updated_at AS updatedAt';
|
|
22
34
|
export function toQoderAccountSummary(row) {
|
|
23
35
|
return {
|
|
24
36
|
id: row.id,
|
|
@@ -31,6 +43,9 @@ export function toQoderAccountSummary(row) {
|
|
|
31
43
|
priority: row.priority,
|
|
32
44
|
jobTokenExpiresAt: row.jobTokenExpiresAt,
|
|
33
45
|
catalogFetchedAt: row.catalogFetchedAt,
|
|
46
|
+
credits: (row.credits ?? null) || parseStoredCredits(row.creditsJson ?? null),
|
|
47
|
+
creditsUpdatedAt: row.creditsUpdatedAt ?? null,
|
|
48
|
+
creditsError: row.creditsError ?? null,
|
|
34
49
|
lastError: row.lastError,
|
|
35
50
|
consecutiveFailures: row.consecutiveFailures,
|
|
36
51
|
createdAt: row.createdAt,
|
|
@@ -157,6 +172,12 @@ export function saveQoderCatalog(id, catalogJson, fetchedAt) {
|
|
|
157
172
|
.prepare('UPDATE qoder_accounts SET catalog_json=?, catalog_fetched_at=?, updated_at=? WHERE id=?')
|
|
158
173
|
.run(catalogJson, fetchedAt ?? new Date().toISOString(), nextUpdatedAt(id), id);
|
|
159
174
|
}
|
|
175
|
+
/** Credits are observability, not routing state: a failed fetch is recorded without touching health. */
|
|
176
|
+
export function saveQoderCredits(id, creditsJson, error, fetchedAt) {
|
|
177
|
+
getRawDb()
|
|
178
|
+
.prepare('UPDATE qoder_accounts SET credits_json=COALESCE(?, credits_json), credits_updated_at=?, credits_error=?, updated_at=? WHERE id=?')
|
|
179
|
+
.run(creditsJson, fetchedAt, error, nextUpdatedAt(id), id);
|
|
180
|
+
}
|
|
160
181
|
// Keeps `updated_at` strictly increasing even within the same millisecond, so a caller can
|
|
161
182
|
// detect a write it just made. Copied from the Codex repository for consistency.
|
|
162
183
|
function nextUpdatedAt(id) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { withCodexCredentials, refreshCodexAccount } from './codex-refresh.js';
|
|
2
|
+
import { redactString, redactValue } from '../security/redact.js';
|
|
2
3
|
const base = (url) => url.replace(/\/$/, '');
|
|
3
4
|
/**
|
|
4
5
|
* Reported Codex CLI client version. The models endpoint rejects requests without it:
|
|
@@ -75,13 +76,7 @@ export async function codexModels(cfg) {
|
|
|
75
76
|
*/
|
|
76
77
|
const CODEX_REQUIRED_FLAGS = { store: false, stream: true };
|
|
77
78
|
export function codexRequestPayload(req, targetModel = req.model) {
|
|
78
|
-
const input = req.messages
|
|
79
|
-
if (block.type === 'text')
|
|
80
|
-
return { type: 'input_text', text: block.text ?? '' };
|
|
81
|
-
if (block.type === 'image' && (block.image?.url || block.image?.base64))
|
|
82
|
-
return { type: 'input_image', image_url: block.image?.url ?? `data:${block.image?.mimeType ?? 'image/png'};base64,${block.image?.base64}` };
|
|
83
|
-
return null;
|
|
84
|
-
}).filter(Boolean) }));
|
|
79
|
+
const input = codexInputItems(req.messages);
|
|
85
80
|
const payload = { model: targetModel, input, ...CODEX_REQUIRED_FLAGS };
|
|
86
81
|
if (req.system)
|
|
87
82
|
payload.instructions = req.system;
|
|
@@ -94,6 +89,64 @@ export function codexRequestPayload(req, targetModel = req.model) {
|
|
|
94
89
|
payload.reasoning = { effort: req.reasoning.effort };
|
|
95
90
|
return payload;
|
|
96
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Translate canonical messages into the Responses `input` array.
|
|
94
|
+
*
|
|
95
|
+
* The backend is strict about which item shapes are legal and rejects the rest
|
|
96
|
+
* with `400 invalid_value`, naming the offending `input[i]` path:
|
|
97
|
+
* - a text block on an assistant turn must be `output_text`; `input_text` is
|
|
98
|
+
* rejected ("Invalid value: 'input_text'. Supported values are: 'output_text'
|
|
99
|
+
* and 'refusal'"). So a plain chat follow-up — history contains an assistant
|
|
100
|
+
* turn — failed while the first message of a conversation worked.
|
|
101
|
+
* - tool calls and their results are NOT content blocks: a `function_call`
|
|
102
|
+
* inside `content` is rejected ("Supported values are: 'input_text',
|
|
103
|
+
* 'input_image', …"). They are top-level items, `function_call` and
|
|
104
|
+
* `function_call_output`, correlated by `call_id`.
|
|
105
|
+
* Verified live against chatgpt.com.
|
|
106
|
+
*/
|
|
107
|
+
export function codexInputItems(messages) {
|
|
108
|
+
const items = [];
|
|
109
|
+
for (const message of messages) {
|
|
110
|
+
const content = [];
|
|
111
|
+
const flushMessage = () => {
|
|
112
|
+
if (content.length > 0)
|
|
113
|
+
items.push({ role: message.role, content: [...content] });
|
|
114
|
+
content.length = 0;
|
|
115
|
+
};
|
|
116
|
+
for (const block of message.content) {
|
|
117
|
+
if (block.type === 'text') {
|
|
118
|
+
// Assistant prose is "output" from the API's point of view; everything else is input.
|
|
119
|
+
content.push(message.role === 'assistant' ? { type: 'output_text', text: block.text ?? '' } : { type: 'input_text', text: block.text ?? '' });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (block.type === 'image' && (block.image?.url || block.image?.base64)) {
|
|
123
|
+
content.push({ type: 'input_image', image_url: block.image?.url ?? `data:${block.image?.mimeType ?? 'image/png'};base64,${block.image?.base64}` });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (block.type === 'tool_use' && block.toolUse) {
|
|
127
|
+
// Top-level item, so the message wrapping the call is emitted first.
|
|
128
|
+
flushMessage();
|
|
129
|
+
items.push({
|
|
130
|
+
type: 'function_call',
|
|
131
|
+
call_id: block.toolUse.id,
|
|
132
|
+
name: block.toolUse.name,
|
|
133
|
+
arguments: typeof block.toolUse.input === 'string' ? block.toolUse.input : JSON.stringify(block.toolUse.input ?? {}),
|
|
134
|
+
});
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (block.type === 'tool_result' && block.toolResult) {
|
|
138
|
+
flushMessage();
|
|
139
|
+
items.push({
|
|
140
|
+
type: 'function_call_output',
|
|
141
|
+
call_id: block.toolResult.toolUseId,
|
|
142
|
+
output: typeof block.toolResult.content === 'string' ? block.toolResult.content : JSON.stringify(block.toolResult.content ?? ''),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
flushMessage();
|
|
147
|
+
}
|
|
148
|
+
return items;
|
|
149
|
+
}
|
|
97
150
|
function usage(value) {
|
|
98
151
|
const u = (value && typeof value === 'object' ? value : {});
|
|
99
152
|
const input = typeof u.input_tokens === 'number' ? u.input_tokens : 0;
|
|
@@ -146,7 +199,20 @@ export function codexStreamEventToCanonical(event) {
|
|
|
146
199
|
return { text: '', isLast: false };
|
|
147
200
|
}
|
|
148
201
|
async function responseError(response) {
|
|
149
|
-
|
|
202
|
+
// The backend explains every rejection (`{"error":{"message":"Invalid value: ...","param":"input[1].content[0]"}}`).
|
|
203
|
+
// Without it an operator only sees "HTTP 400" and has to re-derive the cause by hand.
|
|
204
|
+
let detail = '';
|
|
205
|
+
try {
|
|
206
|
+
const parsed = JSON.parse(await response.text());
|
|
207
|
+
const message = typeof parsed.error?.message === 'string' ? parsed.error.message : typeof parsed.detail === 'string' ? parsed.detail : null;
|
|
208
|
+
// redactValue, not just redactString: an error body can echo a secret under a
|
|
209
|
+
// secret-named key, which only the structured pass recognizes.
|
|
210
|
+
detail = message
|
|
211
|
+
? `: ${message}${typeof parsed.error?.param === 'string' ? ` (param ${parsed.error.param})` : ''}`
|
|
212
|
+
: `: ${JSON.stringify(redactValue(parsed))}`;
|
|
213
|
+
}
|
|
214
|
+
catch { /* a non-JSON or already-read body must not mask the status */ }
|
|
215
|
+
const error = Object.assign(new Error(`Codex upstream HTTP ${response.status}${redactString(detail).slice(0, 500)}`), { status: response.status });
|
|
150
216
|
return error;
|
|
151
217
|
}
|
|
152
218
|
export async function callCodexNonStreaming(cfg, req) {
|
|
@@ -172,8 +238,11 @@ export async function callCodexStreaming(cfg, req, onEvent) {
|
|
|
172
238
|
const ctl = timeout(tokenCfg.totalTimeoutMs);
|
|
173
239
|
try {
|
|
174
240
|
const response = await fetch(codexRequest(tokenCfg, '/responses'), { method: 'POST', headers: codexHeaders(tokenCfg, 'text/event-stream'), body: JSON.stringify(codexRequestPayload(req)), signal: ctl.signal });
|
|
175
|
-
|
|
241
|
+
// Only a failed response is read as text here; a 2xx with no body must still report its status.
|
|
242
|
+
if (!response.ok)
|
|
176
243
|
throw await responseError(response);
|
|
244
|
+
if (!response.body)
|
|
245
|
+
throw Object.assign(new Error(`Codex upstream HTTP ${response.status}: empty response body`), { status: response.status });
|
|
177
246
|
const reader = response.body.getReader();
|
|
178
247
|
const decoder = new TextDecoder();
|
|
179
248
|
let buffer = '';
|
|
@@ -6,7 +6,7 @@ import { listCodexAccountSummaries, chatgptAccountIdOf } from '../db/repositorie
|
|
|
6
6
|
import { listQoderAccountSummaries } from '../db/repositories/qoder-accounts.js';
|
|
7
7
|
import { probeCodex, codexModels, CODEX_BASE_URL } from './codex.js';
|
|
8
8
|
import { withCodexCredentials, codexCredentialError } from './codex-refresh.js';
|
|
9
|
-
import {
|
|
9
|
+
import { probeQoderInference, qoderModels } from './qoder/client.js';
|
|
10
10
|
import { withQoderCredentials } from './qoder/credentials.js';
|
|
11
11
|
import { QODER_INFERENCE_BASE } from './qoder/constants.js';
|
|
12
12
|
export const POOLED_PROVIDER_TYPES = ['codex', 'qoder'];
|
|
@@ -49,7 +49,9 @@ const qoderStrategy = {
|
|
|
49
49
|
// through the credential seam so a stale job token is refreshed before the upstream is asked.
|
|
50
50
|
probe: async (provider) => {
|
|
51
51
|
const account = firstEligible(qoderStrategy.listAccounts(provider.id));
|
|
52
|
-
|
|
52
|
+
// Inference, not just the catalog — see probeQoderInference: a catalog probe cannot see the
|
|
53
|
+
// billing envelope that Qoder returns with HTTP 200 when an account is out of Credits.
|
|
54
|
+
return withQoderCredentials(account.id, (config) => probeQoderInference({ ...config, accountRecordId: account.id, totalTimeoutMs: Math.min(provider.totalTimeoutMs, 30_000) }))
|
|
53
55
|
.catch((error) => { throw qoderCredentialError(error); });
|
|
54
56
|
},
|
|
55
57
|
discover: async (provider) => {
|
|
@@ -43,6 +43,7 @@ export function parseCatalog(body, fetchedAt) {
|
|
|
43
43
|
enabled: raw.enable !== false,
|
|
44
44
|
isReasoning: raw.is_reasoning === true,
|
|
45
45
|
isVl: raw.is_vl === true,
|
|
46
|
+
isFree: raw.is_free === true,
|
|
46
47
|
maxInputTokens: numberOr(raw.max_input_tokens, 0),
|
|
47
48
|
maxOutputTokens: numberOr(raw.max_output_tokens, 0),
|
|
48
49
|
raw,
|
|
@@ -65,9 +66,13 @@ export function deserializeCatalog(raw) {
|
|
|
65
66
|
const parsed = JSON.parse(raw);
|
|
66
67
|
const entries = new Map();
|
|
67
68
|
if (Array.isArray(parsed.entries)) {
|
|
68
|
-
for (const entry of parsed.entries)
|
|
69
|
-
if (entry?.key)
|
|
70
|
-
|
|
69
|
+
for (const entry of parsed.entries) {
|
|
70
|
+
if (!entry?.key)
|
|
71
|
+
continue;
|
|
72
|
+
// Catalogs cached before `isFree` existed carry only `raw.is_free`, so re-derive it
|
|
73
|
+
// rather than showing an empty free-model list until the next catalog refresh.
|
|
74
|
+
entries.set(entry.key, entry.isFree === undefined ? { ...entry, isFree: entry.raw?.is_free === true } : entry);
|
|
75
|
+
}
|
|
71
76
|
}
|
|
72
77
|
return { entries, fetchedAt: typeof parsed.fetchedAt === 'string' ? parsed.fetchedAt : '' };
|
|
73
78
|
}
|
|
@@ -229,6 +229,52 @@ export async function probeQoder(cfg) {
|
|
|
229
229
|
}
|
|
230
230
|
return { ok: true, detail: 'Connected (catalog loaded)', latencyMs: Date.now() - started, modelCount: catalogue.entries.size };
|
|
231
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* A catalog probe proves the PAT exchanges, nothing more. Qoder refuses inference with an
|
|
234
|
+
* envelope-level 403 (code 112, billing) on an HTTP 200 response, so an account can pass the
|
|
235
|
+
* catalog probe while every chat it serves is refused — which reads as "the API key is fine"
|
|
236
|
+
* right up until a real request fails. This probe sends one real message to settle it.
|
|
237
|
+
*
|
|
238
|
+
* It prefers a catalog entry marked `is_free` (a promotion spends no Credits) so testing an
|
|
239
|
+
* account never consumes paid quota.
|
|
240
|
+
*/
|
|
241
|
+
export async function probeQoderInference(cfg) {
|
|
242
|
+
const started = Date.now();
|
|
243
|
+
const catalogue = cfg.catalog;
|
|
244
|
+
if (!catalogue || catalogue.entries.size === 0) {
|
|
245
|
+
return { ok: false, detail: 'Model catalog is empty — check the personal access token', latencyMs: Date.now() - started };
|
|
246
|
+
}
|
|
247
|
+
const entries = [...catalogue.entries.values()];
|
|
248
|
+
const model = entries.find((entry) => entry.isFree) ?? entries[0];
|
|
249
|
+
const probe = {
|
|
250
|
+
model: model.key,
|
|
251
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: 'ping' }] }],
|
|
252
|
+
maxOutputTokens: 16,
|
|
253
|
+
stream: false,
|
|
254
|
+
};
|
|
255
|
+
try {
|
|
256
|
+
await callQoderNonStreaming({ ...cfg, totalTimeoutMs: Math.min(cfg.totalTimeoutMs, 30_000) }, probe);
|
|
257
|
+
return { ok: true, detail: `Inference OK (${model.key}${model.isFree ? ', free' : ''})`, latencyMs: Date.now() - started, modelCount: entries.length };
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
if (error instanceof QoderUpstreamError && error.billing) {
|
|
261
|
+
const free = freeModelHint(entries);
|
|
262
|
+
return {
|
|
263
|
+
ok: false,
|
|
264
|
+
detail: `Account is out of Credits — ${model.key} was refused as billing. ${free ?? 'No promotion-covered model is available on this account, so nothing it serves will work until Credits are added.'}`,
|
|
265
|
+
latencyMs: Date.now() - started,
|
|
266
|
+
modelCount: entries.length,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
const detail = error instanceof QoderUpstreamError ? `HTTP ${error.status}: ${error.message}` : error instanceof Error ? error.message : 'inference probe failed';
|
|
270
|
+
return { ok: false, detail: redactString(detail), latencyMs: Date.now() - started, modelCount: entries.length };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/** Null when the account has no promotion-covered model — the caller words that case itself. */
|
|
274
|
+
function freeModelHint(entries) {
|
|
275
|
+
const free = entries.filter((entry) => entry.isFree).map((entry) => entry.key);
|
|
276
|
+
return free.length > 0 ? `Free models (promotions) still work: ${free.join(', ')}` : null;
|
|
277
|
+
}
|
|
232
278
|
/** Catalog entries become discovered models; hidden (enable:false) keys stay routable. */
|
|
233
279
|
export function qoderModels(catalog) {
|
|
234
280
|
return [...catalog.entries.values()].map((entry) => ({
|
|
@@ -9,6 +9,8 @@ export const QODER_INFERENCE_BASE = 'https://api2.qoder.sh';
|
|
|
9
9
|
export const QODER_LOGIN_URL = 'https://qoder.com/account/integrations';
|
|
10
10
|
export const QODER_JOB_TOKEN_EXCHANGE_URL = `${QODER_OPENAPI_BASE}/api/v1/jobToken/exchange`;
|
|
11
11
|
export const QODER_USERINFO_URL = `${QODER_OPENAPI_BASE}/api/v1/userinfo`;
|
|
12
|
+
// Credits (Qoder's billing unit) live on openapi, not on the inference host.
|
|
13
|
+
export const QODER_CREDITS_URL = `${QODER_OPENAPI_BASE}/api/v2/quota/usage`;
|
|
12
14
|
export const QODER_CHAT_SIG_PATH = '/api/v2/service/pro/sse/agent_chat_generation';
|
|
13
15
|
export const QODER_CHAT_URL = `${QODER_INFERENCE_BASE}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1`;
|
|
14
16
|
export const QODER_MODEL_LIST_URL = `${QODER_INFERENCE_BASE}/algo/api/v2/model/list`;
|
|
@@ -102,6 +102,13 @@ export async function qoderCredentialsFor(accountRecordId, options = {}) {
|
|
|
102
102
|
export async function qoderAttemptFailure(accountRecordId, error) {
|
|
103
103
|
const status = error.status ?? 502;
|
|
104
104
|
const billing = Boolean(error.billing);
|
|
105
|
+
// Billing is checked first because a quota refusal carries status 403 (code 112) and would
|
|
106
|
+
// otherwise be classified as a rejected credential: that mislabels the cause, force-re-exchanges
|
|
107
|
+
// a perfectly good PAT on every request, and hides the real answer (the account is out of quota).
|
|
108
|
+
if (billing) {
|
|
109
|
+
setQoderAccountHealth(accountRecordId, 'degraded', 'upstream reported a quota or billing block');
|
|
110
|
+
throw new GatewayError('upstream_error', 'Qoder account is out of quota', { status: 502, code: 'qoder_billing_block' });
|
|
111
|
+
}
|
|
105
112
|
if (status === 401 || status === 403) {
|
|
106
113
|
// The PAT may still be valid — the job token may merely have expired mid-request. Force one
|
|
107
114
|
// exchange before condemning the account; a second rejection is the PAT's fault.
|
|
@@ -112,10 +119,6 @@ export async function qoderAttemptFailure(accountRecordId, error) {
|
|
|
112
119
|
}
|
|
113
120
|
throw new GatewayError('upstream_auth_error', 'Qoder job token was rejected after a refresh', { status: 502, code: 'qoder_auth_failed' });
|
|
114
121
|
}
|
|
115
|
-
if (billing) {
|
|
116
|
-
setQoderAccountHealth(accountRecordId, 'degraded', 'upstream reported a quota or billing block');
|
|
117
|
-
throw new GatewayError('upstream_error', 'Qoder account is out of quota', { status: 502, code: 'qoder_billing_block' });
|
|
118
|
-
}
|
|
119
122
|
throw upstreamHttpError(status, '', error);
|
|
120
123
|
}
|
|
121
124
|
/** Probe/discover share this: a catalog is the only proof the PAT works end to end. */
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Qoder Credits: the account's real usage. Qoder bills in Credits, not tokens — its chat
|
|
2
|
+
// stream carries no usage block at all — so the quota API is the only upstream truth about
|
|
3
|
+
// consumption. `is_free` on a catalog entry is the separate signal that a model is covered
|
|
4
|
+
// by a promotion (e.g. Qwen3.8-Max) and therefore does not draw on Credits at all.
|
|
5
|
+
import { QODER_CREDITS_URL } from './constants.js';
|
|
6
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
7
|
+
const numberOr = (value, fallback) => {
|
|
8
|
+
const parsed = Number(value);
|
|
9
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
10
|
+
};
|
|
11
|
+
function isoOrNull(value) {
|
|
12
|
+
const ms = typeof value === 'number' ? value : Number(value);
|
|
13
|
+
// 253402214400000 is the far-future sentinel Qoder sends for "no expiry".
|
|
14
|
+
if (!Number.isFinite(ms) || ms >= 253402214400000)
|
|
15
|
+
return null;
|
|
16
|
+
return new Date(ms).toISOString();
|
|
17
|
+
}
|
|
18
|
+
function bucket(label, raw) {
|
|
19
|
+
if (!raw || typeof raw !== 'object')
|
|
20
|
+
return null;
|
|
21
|
+
const value = raw;
|
|
22
|
+
const total = numberOr(value.total, 0);
|
|
23
|
+
if (total <= 0)
|
|
24
|
+
return null;
|
|
25
|
+
const used = numberOr(value.used, 0);
|
|
26
|
+
return {
|
|
27
|
+
label,
|
|
28
|
+
total,
|
|
29
|
+
used,
|
|
30
|
+
remaining: numberOr(value.remaining, Math.max(0, total - used)),
|
|
31
|
+
unit: typeof value.unit === 'string' && value.unit ? value.unit : 'credits',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function parseQoderCredits(body, freeModels, fetchedAt) {
|
|
35
|
+
const raw = (body && typeof body === 'object' ? body : {});
|
|
36
|
+
const buckets = [bucket('Plan credits', raw.userQuota), bucket('Add-on credits', raw.addOnQuota), bucket('Org resource package', raw.orgResourcePackage)].filter((entry) => entry !== null);
|
|
37
|
+
return {
|
|
38
|
+
userType: typeof raw.userType === 'string' ? raw.userType : 'unknown',
|
|
39
|
+
freeModels,
|
|
40
|
+
buckets,
|
|
41
|
+
totalUsedPercent: numberOr(raw.totalUsagePercentage, 0),
|
|
42
|
+
exhausted: raw.isQuotaExceeded === true,
|
|
43
|
+
expiresAt: isoOrNull(raw.expiresAt),
|
|
44
|
+
upgradeUrl: typeof raw.upgradeUrl === 'string' && raw.upgradeUrl ? raw.upgradeUrl : null,
|
|
45
|
+
fetchedAt,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Live Credits snapshot. A failure is reported as `unavailable` rather than thrown: Credits are
|
|
50
|
+
* observability, and an account that cannot report them must keep serving requests.
|
|
51
|
+
*/
|
|
52
|
+
export async function fetchQoderCredits(creds, freeModels, deps = {}) {
|
|
53
|
+
const fetchedAt = new Date().toISOString();
|
|
54
|
+
const controller = new AbortController();
|
|
55
|
+
const timer = setTimeout(() => controller.abort(), deps.timeoutMs ?? FETCH_TIMEOUT_MS);
|
|
56
|
+
try {
|
|
57
|
+
const response = await (deps.fetchImpl ?? fetch)(QODER_CREDITS_URL, {
|
|
58
|
+
method: 'GET',
|
|
59
|
+
headers: { authorization: `Bearer ${creds.jobToken}`, accept: 'application/json', 'user-agent': 'qodercli/1.0.0' },
|
|
60
|
+
signal: controller.signal,
|
|
61
|
+
});
|
|
62
|
+
if (!response.ok)
|
|
63
|
+
return { ...parseQoderCredits({}, freeModels, fetchedAt), unavailable: `HTTP ${response.status}` };
|
|
64
|
+
return parseQoderCredits(await response.json(), freeModels, fetchedAt);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
return { ...parseQoderCredits({}, freeModels, fetchedAt), unavailable: error instanceof Error ? error.message : 'Credits request failed' };
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -4,9 +4,10 @@ import { requireAdminAuth, requireAdminCsrf } from '../../auth/middleware.js';
|
|
|
4
4
|
import { recordAudit } from '../../db/repositories/audit.js';
|
|
5
5
|
import { getRawDb } from '../../db/index.js';
|
|
6
6
|
import { uuid } from '../../auth/ids.js';
|
|
7
|
-
import { listQoderAccountSummaries, getQoderAccountDetailById, setQoderAccountHealth, upsertQoderAccount, findQoderAccountForImport, saveQoderCatalog, toQoderAccountSummary, } from '../../db/repositories/qoder-accounts.js';
|
|
7
|
+
import { listQoderAccountSummaries, getQoderAccountDetailById, setQoderAccountHealth, upsertQoderAccount, findQoderAccountForImport, saveQoderCatalog, saveQoderCredits, toQoderAccountSummary, } from '../../db/repositories/qoder-accounts.js';
|
|
8
8
|
import { exchangeQoderPat, fetchQoderCatalog, serializeCatalog } from '../../providers/qoder/catalog.js';
|
|
9
|
-
import {
|
|
9
|
+
import { probeQoderInference } from '../../providers/qoder/client.js';
|
|
10
|
+
import { fetchQoderCredits } from '../../providers/qoder/credits.js';
|
|
10
11
|
import { qoderCredentialsFor, withQoderCredentials } from '../../providers/qoder/credentials.js';
|
|
11
12
|
import { parseQoderImportText, toQoderPreview, qoderTokenFingerprint } from '../../providers/qoder/qoder-import.js';
|
|
12
13
|
import { redactString } from '../../security/redact.js';
|
|
@@ -85,6 +86,18 @@ function summaryOrThrow(id) {
|
|
|
85
86
|
throw new GatewayError('invalid_request_error', 'Qoder account not found', { status: 404 });
|
|
86
87
|
return toQoderAccountSummary(row);
|
|
87
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Fetch and persist the Credits snapshot. Free models come from the account's cached catalog
|
|
91
|
+
* because `is_free` is a catalog property, not a quota one — it is why an account at zero
|
|
92
|
+
* Credits can still serve a promotional model.
|
|
93
|
+
*/
|
|
94
|
+
async function refreshQoderCredits(id, config) {
|
|
95
|
+
const resolved = config ?? (await qoderCredentialsFor(id)).config;
|
|
96
|
+
const freeModels = resolved.catalog ? [...resolved.catalog.entries.values()].filter((entry) => entry.isFree).map((entry) => entry.key) : [];
|
|
97
|
+
const credits = await fetchQoderCredits({ jobToken: resolved.jobToken }, freeModels, { timeoutMs: 10_000 });
|
|
98
|
+
saveQoderCredits(id, credits.unavailable ? null : JSON.stringify(credits), credits.unavailable ?? null, credits.fetchedAt);
|
|
99
|
+
return credits;
|
|
100
|
+
}
|
|
88
101
|
/**
|
|
89
102
|
* A PAT that works but a catalog that 502s is recoverable — the operator retries later — so the
|
|
90
103
|
* exchange result is stored even when the catalog fetch fails. When the exchange itself fails
|
|
@@ -133,6 +146,8 @@ export async function registerQoderRoutes(app) {
|
|
|
133
146
|
saveQoderCatalog(result.id, serializeCatalog(catalog), catalog.fetchedAt);
|
|
134
147
|
if (catalogError)
|
|
135
148
|
setQoderAccountHealth(result.id, 'unknown', catalogError);
|
|
149
|
+
// Best effort: an account that cannot report Credits is still a usable account.
|
|
150
|
+
await refreshQoderCredits(result.id).catch(() => { });
|
|
136
151
|
recordAudit({ action: 'qoder.accounts.add', success: true, targetType: 'qoder_account', targetId: result.id, targetName: record.label ?? undefined, ip: req.ip, metadata: { status: result.status, catalogError: catalogError ? 'yes' : 'no' } });
|
|
137
152
|
return { account: summaryOrThrow(result.id), status: result.status, catalogError };
|
|
138
153
|
});
|
|
@@ -253,7 +268,9 @@ export async function registerQoderRoutes(app) {
|
|
|
253
268
|
if (!account)
|
|
254
269
|
throw new GatewayError('invalid_request_error', 'Qoder account not found', { status: 404 });
|
|
255
270
|
const provider = providerOrThrow(account.providerId);
|
|
256
|
-
|
|
271
|
+
// Test must exercise inference, not just the catalog: Qoder refuses chat with a billing
|
|
272
|
+
// envelope on HTTP 200, so a catalog-only probe reports a blocked account as working.
|
|
273
|
+
const result = await withQoderCredentials(id, (config) => probeQoderInference({ ...config, accountRecordId: id, totalTimeoutMs: Math.min(provider.total_timeout_ms, 30_000) }));
|
|
257
274
|
setQoderAccountHealth(id, result.ok ? 'healthy' : 'down', result.ok ? null : redactString(result.detail));
|
|
258
275
|
recordAudit({ action: 'qoder.accounts.test', success: result.ok, targetType: 'qoder_account', targetId: id, ip: req.ip, metadata: { detail: redactString(result.detail) } });
|
|
259
276
|
return { ok: result.ok, detail: redactString(result.detail), latencyMs: result.latencyMs, modelCount: result.modelCount ?? null };
|
|
@@ -264,8 +281,23 @@ export async function registerQoderRoutes(app) {
|
|
|
264
281
|
if (!getQoderAccountDetailById(id))
|
|
265
282
|
throw new GatewayError('invalid_request_error', 'Qoder account not found', { status: 404 });
|
|
266
283
|
const { config } = await qoderCredentialsFor(id, { force: true });
|
|
284
|
+
await refreshQoderCredits(id, config).catch(() => { });
|
|
267
285
|
const keys = config.catalog ? [...config.catalog.entries.keys()] : [];
|
|
268
286
|
recordAudit({ action: 'qoder.accounts.catalog', success: Boolean(config.catalog), targetType: 'qoder_account', targetId: id, ip: req.ip, metadata: { modelCount: keys.length } });
|
|
269
287
|
return { modelCount: keys.length, modelKeys: keys, catalogFetchedAt: config.catalog?.fetchedAt ?? null };
|
|
270
288
|
});
|
|
289
|
+
/**
|
|
290
|
+
* Refresh the Credits snapshot on demand. Qoder meters usage in Credits and its chat stream
|
|
291
|
+
* reports no usage at all, so this quota call is the only way to show real consumption.
|
|
292
|
+
*/
|
|
293
|
+
app.post('/api/admin/qoder/accounts/:id/credits', MutationOptions, async (req) => {
|
|
294
|
+
const { id } = req.params;
|
|
295
|
+
if (!getQoderAccountDetailById(id))
|
|
296
|
+
throw new GatewayError('invalid_request_error', 'Qoder account not found', { status: 404 });
|
|
297
|
+
// Deliberately not forced: a fresh exchange invalidates the job token in flight, so reading
|
|
298
|
+
// Credits must reuse the live one rather than churn it.
|
|
299
|
+
const credits = await refreshQoderCredits(id);
|
|
300
|
+
recordAudit({ action: 'qoder.accounts.credits', success: !credits.unavailable, targetType: 'qoder_account', targetId: id, ip: req.ip, metadata: { freeModels: credits.freeModels.length } });
|
|
301
|
+
return { account: summaryOrThrow(id) };
|
|
302
|
+
});
|
|
271
303
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{--background: 0 0% 100%;--foreground: 240 10% 3.9%;--card: 0 0% 100%;--card-foreground: 240 10% 3.9%;--popover: 0 0% 100%;--popover-foreground: 240 10% 3.9%;--primary: 341 86% 41%;--primary-foreground: 0 0% 100%;--secondary: 240 4.8% 95.9%;--secondary-foreground: 240 5.9% 10%;--muted: 240 4.8% 95.9%;--muted-foreground: 240 3.8% 46.1%;--accent: 240 4.8% 95.9%;--accent-foreground: 240 5.9% 10%;--destructive: 0 84.2% 60.2%;--destructive-foreground: 0 0% 98%;--border: 240 5.9% 90%;--input: 240 5.9% 90%;--ring: 341 86% 41%;--sidebar: 240 5.9% 98%;--sidebar-foreground: 240 10% 3.9%;--radius: .5rem}.dark{--background: 240 10% 3.9%;--foreground: 0 0% 98%;--card: 240 10% 6%;--card-foreground: 0 0% 98%;--popover: 240 10% 5%;--popover-foreground: 0 0% 98%;--primary: 341 86% 55%;--primary-foreground: 0 0% 100%;--secondary: 240 3.7% 15.9%;--secondary-foreground: 0 0% 98%;--muted: 240 3.7% 15.9%;--muted-foreground: 240 5% 64.9%;--accent: 240 3.7% 15.9%;--accent-foreground: 0 0% 98%;--destructive: 0 72% 51%;--destructive-foreground: 0 0% 98%;--border: 240 3.7% 15.9%;--input: 240 3.7% 15.9%;--ring: 341 86% 55%;--sidebar: 240 10% 5%;--sidebar-foreground: 0 0% 98%}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground));font-feature-settings:"rlig" 1,"calt" 1}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.left-1\/2{left:50%}.left-2{left:.5rem}.left-2\.5{left:.625rem}.left-\[50\%\]{left:50%}.right-2{right:.5rem}.right-4{right:1rem}.top-1\/2{top:50%}.top-16{top:4rem}.top-2\.5{top:.625rem}.top-4{top:1rem}.top-\[50\%\]{top:50%}.z-10{z-index:10}.z-50{z-index:50}.-mx-1{margin-left:-.25rem;margin-right:-.25rem}.my-1{margin-top:.25rem;margin-bottom:.25rem}.mb-1{margin-bottom:.25rem}.mb-3{margin-bottom:.75rem}.mb-6{margin-bottom:1.5rem}.ml-0\.5{margin-left:.125rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.mr-0\.5{margin-right:.125rem}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-14{height:3.5rem}.h-2{height:.5rem}.h-20{height:5rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-40{height:10rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-auto{height:auto}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-40{max-height:10rem}.max-h-48{max-height:12rem}.max-h-60{max-height:15rem}.max-h-80{max-height:20rem}.max-h-96{max-height:24rem}.max-h-\[calc\(100vh-8rem\)\]{max-height:calc(100vh - 8rem)}.min-h-32{min-height:8rem}.min-h-\[60px\]{min-height:60px}.min-h-screen{min-height:100vh}.w-1\.5{width:.375rem}.w-16{width:4rem}.w-2{width:.5rem}.w-20{width:5rem}.w-28{width:7rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-32{width:8rem}.w-36{width:9rem}.w-4{width:1rem}.w-40{width:10rem}.w-5{width:1.25rem}.w-56{width:14rem}.w-60{width:15rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-full{width:100%}.min-w-0{min-width:0px}.min-w-\[10rem\]{min-width:10rem}.min-w-\[8rem\]{min-width:8rem}.min-w-\[9rem\]{min-width:9rem}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-64{max-width:16rem}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.caption-bottom{caption-side:bottom}.translate-x-\[-50\%\]{--tw-translate-x: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-y-\[-50\%\]{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-rotate-90{--tw-rotate: -90deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-default{cursor:default}.cursor-grab{cursor:grab}.cursor-pointer{cursor:pointer}.touch-none{touch-action:none}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.items-stretch{align-items:stretch}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(.25rem * var(--tw-space-x-reverse));margin-left:calc(.25rem * calc(1 - var(--tw-space-x-reverse)))}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-amber-200{--tw-border-opacity: 1;border-color:rgb(253 230 138 / var(--tw-border-opacity, 1))}.border-amber-500\/40{border-color:#f59e0b66}.border-amber-600\/50{border-color:#d9770680}.border-border{border-color:hsl(var(--border))}.border-destructive{border-color:hsl(var(--destructive))}.border-destructive\/40{border-color:hsl(var(--destructive) / .4)}.border-input{border-color:hsl(var(--input))}.border-primary{border-color:hsl(var(--primary))}.border-transparent{border-color:transparent}.bg-amber-400{--tw-bg-opacity: 1;background-color:rgb(251 191 36 / var(--tw-bg-opacity, 1))}.bg-amber-50{--tw-bg-opacity: 1;background-color:rgb(255 251 235 / var(--tw-bg-opacity, 1))}.bg-amber-500{--tw-bg-opacity: 1;background-color:rgb(245 158 11 / var(--tw-bg-opacity, 1))}.bg-amber-500\/90{background-color:#f59e0be6}.bg-background{background-color:hsl(var(--background))}.bg-black\/15{background-color:#00000026}.bg-black\/80{background-color:#000c}.bg-card{background-color:hsl(var(--card))}.bg-destructive{background-color:hsl(var(--destructive))}.bg-destructive\/10{background-color:hsl(var(--destructive) / .1)}.bg-emerald-100{--tw-bg-opacity: 1;background-color:rgb(209 250 229 / var(--tw-bg-opacity, 1))}.bg-emerald-400{--tw-bg-opacity: 1;background-color:rgb(52 211 153 / var(--tw-bg-opacity, 1))}.bg-emerald-500{--tw-bg-opacity: 1;background-color:rgb(16 185 129 / var(--tw-bg-opacity, 1))}.bg-emerald-600{--tw-bg-opacity: 1;background-color:rgb(5 150 105 / var(--tw-bg-opacity, 1))}.bg-green-100{--tw-bg-opacity: 1;background-color:rgb(220 252 231 / var(--tw-bg-opacity, 1))}.bg-muted{background-color:hsl(var(--muted))}.bg-muted\/30{background-color:hsl(var(--muted) / .3)}.bg-muted\/60{background-color:hsl(var(--muted) / .6)}.bg-popover{background-color:hsl(var(--popover))}.bg-primary{background-color:hsl(var(--primary))}.bg-primary\/10{background-color:hsl(var(--primary) / .1)}.bg-primary\/70{background-color:hsl(var(--primary) / .7)}.bg-red-100{--tw-bg-opacity: 1;background-color:rgb(254 226 226 / var(--tw-bg-opacity, 1))}.bg-red-400{--tw-bg-opacity: 1;background-color:rgb(248 113 113 / var(--tw-bg-opacity, 1))}.bg-secondary{background-color:hsl(var(--secondary))}.bg-sidebar{background-color:hsl(var(--sidebar))}.bg-transparent{background-color:transparent}.fill-amber-400{fill:#fbbf24}.fill-amber-500\/10{fill:#f59e0b1a}.fill-emerald-400{fill:#34d399}.fill-emerald-500{fill:#10b981}.fill-foreground{fill:hsl(var(--foreground))}.fill-muted-foreground{fill:hsl(var(--muted-foreground))}.fill-primary\/10{fill:hsl(var(--primary) / .1)}.fill-red-400{fill:#f87171}.fill-red-500{fill:#ef4444}.stroke-amber-500{stroke:#f59e0b}.stroke-emerald-500{stroke:#10b981}.stroke-primary{stroke:hsl(var(--primary))}.object-contain{-o-object-fit:contain;object-fit:contain}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.pb-1{padding-bottom:.25rem}.pb-2{padding-bottom:.5rem}.pb-6{padding-bottom:1.5rem}.pl-8{padding-left:2rem}.pr-0{padding-right:0}.pr-2{padding-right:.5rem}.pt-0{padding-top:0}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.align-text-bottom{vertical-align:text-bottom}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.tabular-nums{--tw-numeric-spacing: tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.tracking-tight{letter-spacing:-.025em}.text-amber-500{--tw-text-opacity: 1;color:rgb(245 158 11 / var(--tw-text-opacity, 1))}.text-amber-600{--tw-text-opacity: 1;color:rgb(217 119 6 / var(--tw-text-opacity, 1))}.text-amber-800{--tw-text-opacity: 1;color:rgb(146 64 14 / var(--tw-text-opacity, 1))}.text-amber-900{--tw-text-opacity: 1;color:rgb(120 53 15 / var(--tw-text-opacity, 1))}.text-amber-950{--tw-text-opacity: 1;color:rgb(69 26 3 / var(--tw-text-opacity, 1))}.text-blue-500{--tw-text-opacity: 1;color:rgb(59 130 246 / var(--tw-text-opacity, 1))}.text-card-foreground{color:hsl(var(--card-foreground))}.text-current{color:currentColor}.text-destructive{color:hsl(var(--destructive))}.text-destructive-foreground{color:hsl(var(--destructive-foreground))}.text-emerald-500{--tw-text-opacity: 1;color:rgb(16 185 129 / var(--tw-text-opacity, 1))}.text-emerald-600{--tw-text-opacity: 1;color:rgb(5 150 105 / var(--tw-text-opacity, 1))}.text-emerald-700{--tw-text-opacity: 1;color:rgb(4 120 87 / var(--tw-text-opacity, 1))}.text-foreground{color:hsl(var(--foreground))}.text-green-800{--tw-text-opacity: 1;color:rgb(22 101 52 / var(--tw-text-opacity, 1))}.text-muted-foreground{color:hsl(var(--muted-foreground))}.text-muted-foreground\/60{color:hsl(var(--muted-foreground) / .6)}.text-popover-foreground{color:hsl(var(--popover-foreground))}.text-primary{color:hsl(var(--primary))}.text-primary-foreground{color:hsl(var(--primary-foreground))}.text-red-500{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.text-red-700{--tw-text-opacity: 1;color:rgb(185 28 28 / var(--tw-text-opacity, 1))}.text-secondary-foreground{color:hsl(var(--secondary-foreground))}.text-sidebar-foreground{color:hsl(var(--sidebar-foreground))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-90{opacity:.9}.shadow{--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.ring-0{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-offset-background{--tw-ring-offset-color: hsl(var(--background))}.blur{--tw-blur: blur(8px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}@keyframes route-active-pulse{0%{opacity:.35}50%{opacity:1}to{opacity:.35}}.route-active{animation:route-active-pulse 1.4s ease-in-out infinite}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-thumb{background:hsl(var(--border));border-radius:4px}::-webkit-scrollbar-track{background:transparent}.file\:border-0::file-selector-button{border-width:0px}.file\:bg-transparent::file-selector-button{background-color:transparent}.file\:text-sm::file-selector-button{font-size:.875rem;line-height:1.25rem}.file\:font-medium::file-selector-button{font-weight:500}.placeholder\:text-muted-foreground::-moz-placeholder{color:hsl(var(--muted-foreground))}.placeholder\:text-muted-foreground::placeholder{color:hsl(var(--muted-foreground))}.last\:border-0:last-child{border-width:0px}.hover\:bg-accent:hover{background-color:hsl(var(--accent))}.hover\:bg-destructive\/90:hover{background-color:hsl(var(--destructive) / .9)}.hover\:bg-muted:hover{background-color:hsl(var(--muted))}.hover\:bg-muted\/50:hover{background-color:hsl(var(--muted) / .5)}.hover\:bg-primary\/90:hover{background-color:hsl(var(--primary) / .9)}.hover\:bg-secondary\/80:hover{background-color:hsl(var(--secondary) / .8)}.hover\:text-accent-foreground:hover{color:hsl(var(--accent-foreground))}.hover\:text-foreground:hover{color:hsl(var(--foreground))}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-95:hover{opacity:.95}.focus\:bg-accent:focus{background-color:hsl(var(--accent))}.focus\:text-accent-foreground:focus{color:hsl(var(--accent-foreground))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-1:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-ring:focus{--tw-ring-color: hsl(var(--ring))}.focus-visible\:outline-none:focus-visible{outline:2px solid transparent;outline-offset:2px}.focus-visible\:ring-1:focus-visible{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus-visible\:ring-ring:focus-visible{--tw-ring-color: hsl(var(--ring))}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width: 2px}.focus-visible\:ring-offset-background:focus-visible{--tw-ring-offset-color: hsl(var(--background))}.active\:cursor-grabbing:active{cursor:grabbing}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.peer:disabled~.peer-disabled\:cursor-not-allowed{cursor:not-allowed}.peer:disabled~.peer-disabled\:opacity-70{opacity:.7}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[state\=checked\]\:translate-x-4[data-state=checked]{--tw-translate-x: 1rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.data-\[state\=unchecked\]\:translate-x-0[data-state=unchecked]{--tw-translate-x: 0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.data-\[state\=active\]\:bg-background[data-state=active]{background-color:hsl(var(--background))}.data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:hsl(var(--primary))}.data-\[state\=selected\]\:bg-muted[data-state=selected]{background-color:hsl(var(--muted))}.data-\[state\=unchecked\]\:bg-input[data-state=unchecked]{background-color:hsl(var(--input))}.data-\[state\=active\]\:text-foreground[data-state=active]{color:hsl(var(--foreground))}.data-\[state\=checked\]\:text-primary-foreground[data-state=checked]{color:hsl(var(--primary-foreground))}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[state\=active\]\:shadow[data-state=active]{--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.dark\:border-amber-800:is(.dark *){--tw-border-opacity: 1;border-color:rgb(146 64 14 / var(--tw-border-opacity, 1))}.dark\:bg-amber-950\/20:is(.dark *){background-color:#451a0333}.dark\:bg-emerald-900\/40:is(.dark *){background-color:#064e3b66}.dark\:bg-green-900:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(20 83 45 / var(--tw-bg-opacity, 1))}.dark\:bg-red-900\/40:is(.dark *){background-color:#7f1d1d66}.dark\:text-amber-200:is(.dark *){--tw-text-opacity: 1;color:rgb(253 230 138 / var(--tw-text-opacity, 1))}.dark\:text-amber-300:is(.dark *){--tw-text-opacity: 1;color:rgb(252 211 77 / var(--tw-text-opacity, 1))}.dark\:text-emerald-300:is(.dark *){--tw-text-opacity: 1;color:rgb(110 231 183 / var(--tw-text-opacity, 1))}.dark\:text-green-200:is(.dark *){--tw-text-opacity: 1;color:rgb(187 247 208 / var(--tw-text-opacity, 1))}.dark\:text-red-300:is(.dark *){--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}@media(min-width:640px){.sm\:max-w-\[425px\]{max-width:425px}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:justify-end{justify-content:flex-end}.sm\:space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(.5rem * var(--tw-space-x-reverse));margin-left:calc(.5rem * calc(1 - var(--tw-space-x-reverse)))}.sm\:rounded-lg{border-radius:var(--radius)}.sm\:text-left{text-align:left}}@media(min-width:768px){.md\:flex{display:flex}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-col{flex-direction:column}}@media(min-width:1024px){.lg\:col-span-2{grid-column:span 2 / span 2}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}.\[\&_tr\:last-child\]\:border-0 tr:last-child{border-width:0px}.\[\&_tr\]\:border-b tr{border-bottom-width:1px}
|