mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(gamification): resolve SQL bug, auth gap, pagination, and anomaly scoring (#2421)
Integrated into release/v3.8.0 — 6 critical gamification bug fixes: SQL SELECT in checkActionCountBadges, federation auth enforcement, leaderboard pagination offset, real z-score computation, addXp level calculation, and barrel index.ts
This commit is contained in:
@@ -344,7 +344,10 @@ export async function runProvidersRotateCommand(selector, opts = {}) {
|
||||
const readline = await import("node:readline");
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
newKey = await new Promise((resolve) =>
|
||||
rl.question(`New API key for ${connection.name}: `, (a) => { rl.close(); resolve(a.trim()); })
|
||||
rl.question(`New API key for ${connection.name}: `, (a) => {
|
||||
rl.close();
|
||||
resolve(a.trim());
|
||||
})
|
||||
);
|
||||
if (!newKey) {
|
||||
console.error("No key provided.");
|
||||
@@ -354,7 +357,9 @@ export async function runProvidersRotateCommand(selector, opts = {}) {
|
||||
|
||||
// --- Dry-run ---
|
||||
if (opts.dryRun) {
|
||||
console.log(t("providers.rotate.dryRunResult", { name: connection.name, id: connection.id.slice(0, 8) }));
|
||||
console.log(
|
||||
t("providers.rotate.dryRunResult", { name: connection.name, id: connection.id.slice(0, 8) })
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -363,7 +368,13 @@ export async function runProvidersRotateCommand(selector, opts = {}) {
|
||||
const readline = await import("node:readline");
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise((resolve) =>
|
||||
rl.question(t("providers.rotate.confirmPrompt", { name: connection.name, id: connection.id.slice(0, 8) }), resolve)
|
||||
rl.question(
|
||||
t("providers.rotate.confirmPrompt", {
|
||||
name: connection.name,
|
||||
id: connection.id.slice(0, 8),
|
||||
}),
|
||||
resolve
|
||||
)
|
||||
);
|
||||
rl.close();
|
||||
if (!/^y(es|s)?$/i.test(answer)) {
|
||||
@@ -378,7 +389,13 @@ export async function runProvidersRotateCommand(selector, opts = {}) {
|
||||
try {
|
||||
const res = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}`, {
|
||||
method: "PATCH",
|
||||
body: { apiKey: newKey, testStatus: "unknown", lastError: null, rateLimitedUntil: null, backoffLevel: 0 },
|
||||
body: {
|
||||
apiKey: newKey,
|
||||
testStatus: "unknown",
|
||||
lastError: null,
|
||||
rateLimitedUntil: null,
|
||||
backoffLevel: 0,
|
||||
},
|
||||
retry: false,
|
||||
acceptNotOk: true,
|
||||
});
|
||||
@@ -404,7 +421,9 @@ export async function runProvidersRotateCommand(selector, opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(t("providers.rotate.success", { name: connection.name, id: connection.id.slice(0, 8) }));
|
||||
console.log(
|
||||
t("providers.rotate.success", { name: connection.name, id: connection.id.slice(0, 8) })
|
||||
);
|
||||
|
||||
// --- Post-rotation test ---
|
||||
if (!opts.skipTest) {
|
||||
@@ -468,8 +487,8 @@ export async function runProvidersStatusCommand(opts = {}) {
|
||||
const testColor = statusColor(testStatus);
|
||||
console.log(
|
||||
`${shortId.padEnd(10)} ${String(item.provider || "").padEnd(14)} ${String(item.name || "").padEnd(24)} ` +
|
||||
`${expiry.padEnd(12)} ${expiryColor}${expiryStatus.padEnd(8)}\x1b[0m ` +
|
||||
`${testColor}${testStatus.padEnd(12)}\x1b[0m ${cooldown}`
|
||||
`${expiry.padEnd(12)} ${expiryColor}${expiryStatus.padEnd(8)}\x1b[0m ` +
|
||||
`${testColor}${testStatus.padEnd(12)}\x1b[0m ${cooldown}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -543,7 +562,10 @@ export function registerProviders(program) {
|
||||
.option("--dry-run", t("providers.rotate.dryRunOpt"))
|
||||
.action(async (idOrName, opts, cmd) => {
|
||||
const globalOpts = cmd.parent.optsWithGlobals();
|
||||
const exitCode = await runProvidersRotateCommand(idOrName, { ...opts, output: globalOpts.output });
|
||||
const exitCode = await runProvidersRotateCommand(idOrName, {
|
||||
...opts,
|
||||
output: globalOpts.output,
|
||||
});
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
|
||||
|
||||
@@ -27,12 +27,12 @@ re-authenticating one account does not affect any other account's refresh token.
|
||||
|
||||
The isolation applies to all three import methods:
|
||||
|
||||
| Import method | Isolation status |
|
||||
|---|---|
|
||||
| AWS Builder ID / IDC device-code flow | Isolated since the device-code flow was introduced |
|
||||
| **Import Token** (manual refresh token paste) | Isolated from v3.8.0 |
|
||||
| **Google / GitHub social login** | Isolated from v3.8.0 |
|
||||
| **Auto-Import** (kiro-cli SQLite) | Isolated from v3.8.0 (SQLite path was already isolated; SSO-cache fallback is now also isolated) |
|
||||
| Import method | Isolation status |
|
||||
| --------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| AWS Builder ID / IDC device-code flow | Isolated since the device-code flow was introduced |
|
||||
| **Import Token** (manual refresh token paste) | Isolated from v3.8.0 |
|
||||
| **Google / GitHub social login** | Isolated from v3.8.0 |
|
||||
| **Auto-Import** (kiro-cli SQLite) | Isolated from v3.8.0 (SQLite path was already isolated; SSO-cache fallback is now also isolated) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -114,9 +114,9 @@ On success it returns:
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| 422 + `zedDockerEnvironment: true` | Running inside Docker | Use Manual Token Import tab |
|
||||
| 404 + `zedInstalled: false` | Zed not installed on host | Install Zed or use manual import |
|
||||
| 403 + keychain access denied | OS denied keychain access | Grant permission in OS prompt |
|
||||
| 404 + keychain service not available | `libsecret` missing on Linux | Install `libsecret-1-dev` |
|
||||
| Symptom | Cause | Fix |
|
||||
| ------------------------------------ | ---------------------------- | -------------------------------- |
|
||||
| 422 + `zedDockerEnvironment: true` | Running inside Docker | Use Manual Token Import tab |
|
||||
| 404 + `zedInstalled: false` | Zed not installed on host | Install Zed or use manual import |
|
||||
| 403 + keychain access denied | OS denied keychain access | Grant permission in OS prompt |
|
||||
| 404 + keychain service not available | `libsecret` missing on Linux | Install `libsecret-1-dev` |
|
||||
|
||||
@@ -59,14 +59,14 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
|
||||
|
||||
## Web Cookie Providers (7)
|
||||
|
||||
| ID | Alias | Name | Tags | Website | Notes |
|
||||
| ---------------- | ---------- | --------------------------- | ---------- | --------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| `blackbox-web` | `bb-web` | Blackbox Web (Subscription) | Web cookie | [link](https://app.blackbox.ai) | Paste your \_\_Secure-authjs.session-token value or full cookie header from app.blackbox.ai |
|
||||
| `chatgpt-web` | `cgpt-web` | ChatGPT Web (Plus/Pro) | Web cookie | [link](https://chatgpt.com) | Paste your \_\_Secure-next-auth.session-token cookie value from chatgpt.com |
|
||||
| `deepseek-web` | `ds-web` | DeepSeek Web | Web cookie | [link](https://chat.deepseek.com) | Paste your ds_session_id cookie from chat.deepseek.com |
|
||||
| `grok-web` | `gw` | Grok Web (Subscription) | Web cookie | [link](https://grok.com) | Paste your sso= cookie value from grok.com |
|
||||
| `muse-spark-web` | `ms-web` | Muse Spark Web (Meta AI) | Web cookie | [link](https://www.meta.ai) | Paste your abra_sess value or full cookie header from meta.ai |
|
||||
| `perplexity-web` | `pplx-web` | Perplexity Web (Pro/Max) | Web cookie | [link](https://www.perplexity.ai) | Paste your \_\_Secure-next-auth.session-token cookie value from perplexity.ai |
|
||||
| ID | Alias | Name | Tags | Website | Notes |
|
||||
| ---------------- | ---------- | --------------------------- | ---------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `blackbox-web` | `bb-web` | Blackbox Web (Subscription) | Web cookie | [link](https://app.blackbox.ai) | Paste your \_\_Secure-authjs.session-token value or full cookie header from app.blackbox.ai |
|
||||
| `chatgpt-web` | `cgpt-web` | ChatGPT Web (Plus/Pro) | Web cookie | [link](https://chatgpt.com) | Paste your \_\_Secure-next-auth.session-token cookie value from chatgpt.com |
|
||||
| `deepseek-web` | `ds-web` | DeepSeek Web | Web cookie | [link](https://chat.deepseek.com) | Paste your ds_session_id cookie from chat.deepseek.com |
|
||||
| `grok-web` | `gw` | Grok Web (Subscription) | Web cookie | [link](https://grok.com) | Paste your sso= cookie value from grok.com |
|
||||
| `muse-spark-web` | `ms-web` | Muse Spark Web (Meta AI) | Web cookie | [link](https://www.meta.ai) | Paste your abra_sess value or full cookie header from meta.ai |
|
||||
| `perplexity-web` | `pplx-web` | Perplexity Web (Pro/Max) | Web cookie | [link](https://www.perplexity.ai) | Paste your \_\_Secure-next-auth.session-token cookie value from perplexity.ai |
|
||||
| `t3-web` | `t3chat` | t3.chat (Pro/Free) | Web cookie | [link](https://t3.chat) | Pro: $8/mo, 50+ models. Free tier: limited models. Requires Cookie header + convex-session-id from DevTools. **Skeleton — endpoint URL not yet confirmed (TODO post-devtools-capture).** |
|
||||
|
||||
## API Key Providers (paid / paid-with-free-credits) (122)
|
||||
|
||||
@@ -87,17 +87,17 @@ The Auto-Combo Engine dynamically selects the best provider/model for each reque
|
||||
|
||||
> Source: [diagrams/auto-combo-9factor.mmd](../diagrams/auto-combo-9factor.mmd)
|
||||
|
||||
| Factor | Default Weight | Description |
|
||||
| :----------------- | :------------- | :---------------------------------------------------------------------- |
|
||||
| `health` | 0.22 | Health score from circuit breaker (CLOSED=1.0, HALF_OPEN=0.5, OPEN=0.0) |
|
||||
| `quota` | 0.17 | Remaining quota / rate-limit headroom [0..1] |
|
||||
| Factor | Default Weight | Description |
|
||||
| :----------------- | :------------- | :------------------------------------------------------------------------------------------------- |
|
||||
| `health` | 0.22 | Health score from circuit breaker (CLOSED=1.0, HALF_OPEN=0.5, OPEN=0.0) |
|
||||
| `quota` | 0.17 | Remaining quota / rate-limit headroom [0..1] |
|
||||
| `costInv` | 0.17 | Inverse **blended** cost (60% input + 40% output token price, normalized) — cheaper = higher score |
|
||||
| `latencyInv` | 0.13 | Inverse p95 latency normalized to pool — faster = higher score |
|
||||
| `taskFit` | 0.08 | Task-type fitness (coding, review, planning, analysis, debugging, docs) |
|
||||
| `specificityMatch` | 0.08 | Match between request specificity (manifest hint) and model tier |
|
||||
| `stability` | 0.05 | Variance-based stability (low latency stdDev / error rate) |
|
||||
| `tierPriority` | 0.05 | Account-tier priority — Ultra=1.0, Pro=0.67, Standard=0.33, Free=0.0 |
|
||||
| `tierAffinity` | 0.05 | Affinity between the candidate's tier and the manifest-recommended tier |
|
||||
| `latencyInv` | 0.13 | Inverse p95 latency normalized to pool — faster = higher score |
|
||||
| `taskFit` | 0.08 | Task-type fitness (coding, review, planning, analysis, debugging, docs) |
|
||||
| `specificityMatch` | 0.08 | Match between request specificity (manifest hint) and model tier |
|
||||
| `stability` | 0.05 | Variance-based stability (low latency stdDev / error rate) |
|
||||
| `tierPriority` | 0.05 | Account-tier priority — Ultra=1.0, Pro=0.67, Standard=0.33, Free=0.0 |
|
||||
| `tierAffinity` | 0.05 | Affinity between the candidate's tier and the manifest-recommended tier |
|
||||
|
||||
**Sum:** `0.22 + 0.17 + 0.17 + 0.13 + 0.08 + 0.08 + 0.05 + 0.05 + 0.05 = 1.0` (validated by `validateWeights()`).
|
||||
|
||||
@@ -210,16 +210,16 @@ Including the bare `auto` (default) plus the 6 `AutoVariant` values declared in
|
||||
The 9-factor scoring function (`open-sse/services/autoCombo/scoring.ts`) treats tier
|
||||
membership as one signal via the `tierPriority` weight. Default weights (from `DEFAULT_WEIGHTS`):
|
||||
|
||||
| Factor | Default weight | Notes |
|
||||
| ------------------------ | -------------- | --------------------------------- |
|
||||
| Tier priority | 0.05 | Tier 1 premium → higher score |
|
||||
| Latency (p50 inverse) | 0.35 | Fastest wins |
|
||||
| Factor | Default weight | Notes |
|
||||
| ------------------------ | -------------- | -------------------------------------------------------------- |
|
||||
| Tier priority | 0.05 | Tier 1 premium → higher score |
|
||||
| Latency (p50 inverse) | 0.35 | Fastest wins |
|
||||
| Cost ($/1M inverse) | 0.20 | Cheapest **blended** price wins (60% input + 40% output ratio) |
|
||||
| Recent health/error rate | 0.15 | Unhealthy deprioritized |
|
||||
| Quota remaining | 0.10 | Near-exhausted deprioritized |
|
||||
| Context window match | 0.08 | Penalizes short windows |
|
||||
| Task fitness | 0.10 | Coding → coding-specialist models |
|
||||
| Stability | 0.00 | Disabled by default |
|
||||
| Recent health/error rate | 0.15 | Unhealthy deprioritized |
|
||||
| Quota remaining | 0.10 | Near-exhausted deprioritized |
|
||||
| Context window match | 0.08 | Penalizes short windows |
|
||||
| Task fitness | 0.10 | Coding → coding-specialist models |
|
||||
| Stability | 0.00 | Disabled by default |
|
||||
|
||||
Tier alone does **not** force Tier 1 first — if Tier 1 latency is bad or
|
||||
cost-vs-quality is suboptimal, Tier 2 wins. To force tier ordering, use combo
|
||||
|
||||
@@ -139,6 +139,7 @@ parsed body from the upstream provider). When provided, it is sanitized by
|
||||
`sanitizeUpstreamDetails` before inclusion in the response as `upstream_details`.
|
||||
|
||||
Sanitization rules applied to `upstreamDetails`:
|
||||
|
||||
1. String leaves: run through `sanitizeErrorMessage` (strips stacks + absolute paths).
|
||||
2. Key blocklist: keys matching `/stack|trace|path|file|cwd|dir|password|secret|token|key/i`
|
||||
are removed.
|
||||
|
||||
@@ -58,8 +58,7 @@ export interface T3ChatCredentials {
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function validateCredentials(creds: unknown): creds is T3ChatCredentials {
|
||||
const raw =
|
||||
typeof creds === "object" && creds !== null ? (creds as Record<string, unknown>) : {};
|
||||
const raw = typeof creds === "object" && creds !== null ? (creds as Record<string, unknown>) : {};
|
||||
return (
|
||||
typeof raw.cookies === "string" &&
|
||||
raw.cookies.length > 0 &&
|
||||
@@ -373,10 +372,7 @@ export class T3ChatWebExecutor extends BaseExecutor {
|
||||
// TODO(post-devtools-capture): Map the actual t3.chat non-streaming response
|
||||
// shape to OpenAI format once the real field names are confirmed.
|
||||
const content =
|
||||
(json as any)?.content ??
|
||||
(json as any)?.text ??
|
||||
(json as any)?.message?.content ??
|
||||
"";
|
||||
(json as any)?.content ?? (json as any)?.text ?? (json as any)?.message?.content ?? "";
|
||||
const openaiResponse = {
|
||||
id: `chatcmpl-t3-${Date.now()}`,
|
||||
object: "chat.completion",
|
||||
|
||||
@@ -2658,10 +2658,7 @@ async function handleRoundRobinCombo({
|
||||
// same-provider targets are skipped immediately.
|
||||
if (provider && isProviderExhaustedReason(fallbackResult)) {
|
||||
exhaustedProviders.add(provider);
|
||||
log.info(
|
||||
"COMBO-RR",
|
||||
`Provider ${provider} quota exhausted — marking for skip (#1731)`
|
||||
);
|
||||
log.info("COMBO-RR", `Provider ${provider} quota exhausted — marking for skip (#1731)`);
|
||||
}
|
||||
|
||||
const isAllAccountsRateLimited = isAllAccountsRateLimitedResponse(
|
||||
|
||||
@@ -3373,8 +3373,8 @@ export default function ProviderDetailPage() {
|
||||
Import from Zed Keychain
|
||||
</h2>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Discover AI provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) that Zed
|
||||
IDE stored in the OS keychain and import them as connections. Requires Zed IDE
|
||||
Discover AI provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) that
|
||||
Zed IDE stored in the OS keychain and import them as connections. Requires Zed IDE
|
||||
installed on this machine.
|
||||
</p>
|
||||
</div>
|
||||
@@ -3408,8 +3408,8 @@ export default function ProviderDetailPage() {
|
||||
<p className="text-sm text-text-muted">
|
||||
Use this when OmniRoute runs in Docker or the keychain is unavailable. Paste the
|
||||
API key that Zed stored under{" "}
|
||||
<code className="font-mono text-xs">~/.config/zed/settings.json</code> or copy it
|
||||
from the Zed AI settings panel.
|
||||
<code className="font-mono text-xs">~/.config/zed/settings.json</code> or copy
|
||||
it from the Zed AI settings panel.
|
||||
</p>
|
||||
<div className="flex gap-2 flex-col sm:flex-row">
|
||||
<select
|
||||
|
||||
@@ -8,8 +8,35 @@ export async function OPTIONS() {
|
||||
|
||||
/**
|
||||
* GET /api/gamification/federation/leaderboard — Serve leaderboard for federation
|
||||
*
|
||||
* Requires bearer token authentication against community_servers.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
// Authenticate: validate bearer token against community_servers
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing authorization" },
|
||||
{ status: 401, headers: CORS_HEADERS }
|
||||
);
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
const crypto = await import("crypto");
|
||||
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
|
||||
const { getDbInstance } = await import("@/lib/db/core");
|
||||
const db = getDbInstance();
|
||||
const server = db
|
||||
.prepare("SELECT id FROM community_servers WHERE api_key_hash = ? AND status = 'connected'")
|
||||
.get(tokenHash) as { id: string } | undefined;
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid or unauthorized token" },
|
||||
{ status: 403, headers: CORS_HEADERS }
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const scope: LeaderboardScope = (url.searchParams.get("scope") || "global") as LeaderboardScope;
|
||||
const limit = Number(url.searchParams.get("limit") || 100);
|
||||
|
||||
@@ -235,7 +235,10 @@ async function saveAndRespond(
|
||||
providerSpecificData.clientSecretExpiresAt = reg.clientSecretExpiresAt;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[kiro auto-import] registerClient failed, continuing without isolated client:", err);
|
||||
console.warn(
|
||||
"[kiro auto-import] registerClient failed, continuing without isolated client:",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,10 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
const parsed = manualImportSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
buildErrorBody(400, "Validation failed: " + parsed.error.issues.map((i) => i.message).join(", ")),
|
||||
buildErrorBody(
|
||||
400,
|
||||
"Validation failed: " + parsed.error.issues.map((i) => i.message).join(", ")
|
||||
),
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { getDbInstance } from "./core";
|
||||
import { calculateLevel } from "../gamification/xp";
|
||||
|
||||
// ──────────────── Types ────────────────
|
||||
|
||||
@@ -130,13 +131,13 @@ export function getRank(apiKeyId: string, scope: string): number {
|
||||
return rankRow.rank;
|
||||
}
|
||||
|
||||
export function getTopN(scope: string, limit: number): LeaderboardRow[] {
|
||||
export function getTopN(scope: string, limit: number, offset: number = 0): LeaderboardRow[] {
|
||||
const rows = db()
|
||||
.prepare(
|
||||
`SELECT api_key_id, scope, score, updated_at FROM leaderboard
|
||||
WHERE scope = ? ORDER BY score DESC LIMIT ?`
|
||||
WHERE scope = ? ORDER BY score DESC LIMIT ? OFFSET ?`
|
||||
)
|
||||
.all(scope, limit) as Array<{
|
||||
.all(scope, limit, offset) as Array<{
|
||||
api_key_id: string;
|
||||
scope: string;
|
||||
score: number;
|
||||
@@ -163,11 +164,11 @@ export function addXp(apiKeyId: string, action: string, amount: number, metadata
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO user_levels (api_key_id, total_xp, current_level, updated_at)
|
||||
VALUES (?, ?, 1, datetime('now'))
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(api_key_id)
|
||||
DO UPDATE SET total_xp = total_xp + excluded.total_xp, updated_at = datetime('now')`
|
||||
)
|
||||
.run(apiKeyId, amount);
|
||||
.run(apiKeyId, amount, calculateLevel(amount));
|
||||
}
|
||||
|
||||
export function getXp(apiKeyId: string): UserLevelRow | null {
|
||||
|
||||
@@ -85,15 +85,56 @@ export async function getAnomalies(): Promise<AnomalyFlag[]> {
|
||||
)
|
||||
.all() as Array<{ api_key_id: string; hourly_total: number }>;
|
||||
|
||||
return rows.map((r) => ({
|
||||
apiKeyId: r.api_key_id,
|
||||
xpLastHour: r.hourly_total,
|
||||
zScore: 0, // Simplified for now
|
||||
}));
|
||||
const results: AnomalyFlag[] = [];
|
||||
for (const r of rows) {
|
||||
const z = await computeZScore(r.api_key_id);
|
||||
results.push({
|
||||
apiKeyId: r.api_key_id,
|
||||
xpLastHour: r.hourly_total,
|
||||
zScore: z ?? 0,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Internal Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute the z-score for a user's hourly XP against the global distribution.
|
||||
* Returns null if insufficient data.
|
||||
*/
|
||||
async function computeZScore(apiKeyId: string): Promise<number | null> {
|
||||
const d = db();
|
||||
|
||||
const userRow = d
|
||||
.prepare(
|
||||
`SELECT COALESCE(SUM(xp_earned), 0) AS total
|
||||
FROM xp_audit_log
|
||||
WHERE api_key_id = ? AND created_at > datetime('now', '-1 hour')`
|
||||
)
|
||||
.get(apiKeyId) as { total: number };
|
||||
|
||||
const statsRow = d
|
||||
.prepare(
|
||||
`SELECT AVG(hourly_total) AS mean,
|
||||
CASE WHEN AVG(hourly_total) = 0 THEN 1
|
||||
ELSE AVG(hourly_total * hourly_total) - AVG(hourly_total) * AVG(hourly_total)
|
||||
END AS variance
|
||||
FROM (
|
||||
SELECT api_key_id, SUM(xp_earned) AS hourly_total
|
||||
FROM xp_audit_log
|
||||
WHERE created_at > datetime('now', '-1 hour')
|
||||
GROUP BY api_key_id
|
||||
)`
|
||||
)
|
||||
.get() as { mean: number; variance: number } | undefined;
|
||||
|
||||
if (!statsRow || statsRow.variance <= 0) return null;
|
||||
|
||||
const stdDev = Math.sqrt(statsRow.variance);
|
||||
return (userRow.total - statsRow.mean) / stdDev;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total XP earned in the last N milliseconds.
|
||||
*/
|
||||
@@ -114,37 +155,6 @@ async function getRecentXp(apiKeyId: string, windowMs: number): Promise<number>
|
||||
* Detect anomalous XP velocity using z-score.
|
||||
*/
|
||||
async function detectAnomaly(apiKeyId: string): Promise<boolean> {
|
||||
const d = db();
|
||||
|
||||
// Get user's XP in last hour
|
||||
const userRow = d
|
||||
.prepare(
|
||||
`SELECT COALESCE(SUM(xp_earned), 0) AS total
|
||||
FROM xp_audit_log
|
||||
WHERE api_key_id = ? AND created_at > datetime('now', '-1 hour')`
|
||||
)
|
||||
.get(apiKeyId) as { total: number };
|
||||
|
||||
// Get global stats
|
||||
const statsRow = d
|
||||
.prepare(
|
||||
`SELECT AVG(hourly_total) AS mean,
|
||||
CASE WHEN AVG(hourly_total) = 0 THEN 1
|
||||
ELSE AVG(hourly_total * hourly_total) - AVG(hourly_total) * AVG(hourly_total)
|
||||
END AS variance
|
||||
FROM (
|
||||
SELECT api_key_id, SUM(xp_earned) AS hourly_total
|
||||
FROM xp_audit_log
|
||||
WHERE created_at > datetime('now', '-1 hour')
|
||||
GROUP BY api_key_id
|
||||
)`
|
||||
)
|
||||
.get() as { mean: number; variance: number } | undefined;
|
||||
|
||||
if (!statsRow || statsRow.variance <= 0) return false;
|
||||
|
||||
const stdDev = Math.sqrt(statsRow.variance);
|
||||
const zScore = (userRow.total - statsRow.mean) / stdDev;
|
||||
|
||||
return zScore > ANOMALY_Z_THRESHOLD;
|
||||
const z = await computeZScore(apiKeyId);
|
||||
return z !== null && z > ANOMALY_Z_THRESHOLD;
|
||||
}
|
||||
|
||||
@@ -151,7 +151,9 @@ async function checkActionCountBadges(apiKeyId: string, action: string): Promise
|
||||
|
||||
// Count total actions of this type
|
||||
const row = db
|
||||
.prepare("COALESCE(COUNT(*), 0) AS count FROM xp_audit_log WHERE api_key_id = ? AND action = ?")
|
||||
.prepare(
|
||||
"SELECT COALESCE(COUNT(*), 0) AS count FROM xp_audit_log WHERE api_key_id = ? AND action = ?"
|
||||
)
|
||||
.get(apiKeyId, action) as { count: number };
|
||||
|
||||
const count = row.count;
|
||||
|
||||
37
src/lib/gamification/index.ts
Normal file
37
src/lib/gamification/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Gamification module — barrel export.
|
||||
*
|
||||
* @module lib/gamification
|
||||
*/
|
||||
|
||||
export { emitGamificationEvent } from "./events";
|
||||
export {
|
||||
updateScore,
|
||||
getRank,
|
||||
getTopN,
|
||||
getNeighbors,
|
||||
rotateScope,
|
||||
type LeaderboardScope,
|
||||
type LeaderboardEntry,
|
||||
} from "./leaderboard";
|
||||
export { validateScoreChange, getAnomalies } from "./antiCheat";
|
||||
export { BUILTIN_BADGES } from "./badges";
|
||||
export {
|
||||
xpForLevel,
|
||||
cumulativeXpForLevel,
|
||||
calculateLevel,
|
||||
xpToNextLevel,
|
||||
getLevelTitle,
|
||||
getLevelTier,
|
||||
XP_REWARDS,
|
||||
type XpAction,
|
||||
} from "./xp";
|
||||
export { updateStreak } from "./streaks";
|
||||
export {
|
||||
recordBadgeUnlock,
|
||||
consumeBadgeUnlocks,
|
||||
createBadgeNotificationStream,
|
||||
} from "./notifications";
|
||||
export { transferTokens, getBalance, getHistory } from "./sharing";
|
||||
export { createInvite, redeemInvite as redeemInviteCode } from "./invites";
|
||||
export { connectServer, disconnectServer, listServers } from "./servers";
|
||||
@@ -38,9 +38,9 @@ export async function getRank(apiKeyId: string, scope: LeaderboardScope): Promis
|
||||
/**
|
||||
* Get top N entries for a scope.
|
||||
*/
|
||||
export async function getTopN(scope: LeaderboardScope, limit: number = 50, _offset: number = 0) {
|
||||
export async function getTopN(scope: LeaderboardScope, limit: number = 50, offset: number = 0) {
|
||||
const { getTopN: dbGetTopN } = await import("../db/gamification");
|
||||
return dbGetTopN(scope, limit);
|
||||
return dbGetTopN(scope, limit, offset);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -71,13 +71,10 @@ test("fast-skip on quota-exhausted 429: first same-provider target causes remain
|
||||
openaiCalls += 1;
|
||||
callSequence.push("openai");
|
||||
// Return quota exhausted on all openai calls
|
||||
return new Response(
|
||||
JSON.stringify({ error: { message: "Subscription quota exceeded" } }),
|
||||
{
|
||||
status: 429,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
return new Response(JSON.stringify({ error: { message: "Subscription quota exceeded" } }), {
|
||||
status: 429,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -111,7 +108,11 @@ test("fast-skip on quota-exhausted 429: first same-provider target causes remain
|
||||
"should fallback to anthropic"
|
||||
);
|
||||
// The key assertion: openai should only be called once (not twice for both gpt-4o-mini and gpt-3.5-turbo)
|
||||
assert.equal(openaiCalls, 1, `openai should be called only once, but was called ${openaiCalls} times. Call sequence: ${callSequence.join(" -> ")}`);
|
||||
assert.equal(
|
||||
openaiCalls,
|
||||
1,
|
||||
`openai should be called only once, but was called ${openaiCalls} times. Call sequence: ${callSequence.join(" -> ")}`
|
||||
);
|
||||
assert.equal(anthropicCalls, 1, "anthropic should be called once");
|
||||
});
|
||||
|
||||
@@ -131,11 +132,7 @@ test("fast-skip on credits-exhausted 429: same-provider targets are skipped (#17
|
||||
name: "credits-exhausted-combo",
|
||||
strategy: "priority",
|
||||
config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 },
|
||||
models: [
|
||||
"openai/gpt-4o-mini",
|
||||
"openai/gpt-3.5-turbo",
|
||||
"anthropic/claude-3-5-sonnet-20241022",
|
||||
],
|
||||
models: ["openai/gpt-4o-mini", "openai/gpt-3.5-turbo", "anthropic/claude-3-5-sonnet-20241022"],
|
||||
});
|
||||
|
||||
let openaiCalls = 0;
|
||||
@@ -205,11 +202,7 @@ test("no skip on transient 429: plain rate-limit does not skip same-provider tar
|
||||
name: "transient-429-combo",
|
||||
strategy: "priority",
|
||||
config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 },
|
||||
models: [
|
||||
"openai/gpt-4o-mini",
|
||||
"openai/gpt-3.5-turbo",
|
||||
"anthropic/claude-3-5-sonnet-20241022",
|
||||
],
|
||||
models: ["openai/gpt-4o-mini", "openai/gpt-3.5-turbo", "anthropic/claude-3-5-sonnet-20241022"],
|
||||
});
|
||||
|
||||
let openaiCalls = 0;
|
||||
@@ -253,10 +246,7 @@ test("no skip on transient 429: plain rate-limit does not skip same-provider tar
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(
|
||||
body.choices[0].message.content,
|
||||
"anthropic recovered from transient 429"
|
||||
);
|
||||
assert.equal(body.choices[0].message.content, "anthropic recovered from transient 429");
|
||||
// Transient 429 should still try both openai targets (retries), then move to anthropic
|
||||
assert.ok(openaiCalls >= 2, "openai should be attempted multiple times for transient 429");
|
||||
assert.equal(anthropicCalls, 1);
|
||||
@@ -363,11 +353,7 @@ test("exhaustion does not persist across requests: second request starts fresh (
|
||||
name: "persistence-test-combo",
|
||||
strategy: "priority",
|
||||
config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 },
|
||||
models: [
|
||||
"openai/gpt-4o-mini",
|
||||
"openai/gpt-3.5-turbo",
|
||||
"anthropic/claude-3-5-sonnet-20241022",
|
||||
],
|
||||
models: ["openai/gpt-4o-mini", "openai/gpt-3.5-turbo", "anthropic/claude-3-5-sonnet-20241022"],
|
||||
});
|
||||
|
||||
let requestCount = 0;
|
||||
@@ -383,13 +369,10 @@ test("exhaustion does not persist across requests: second request starts fresh (
|
||||
openaiCalls += 1;
|
||||
// First request: openai fails with quota exhaustion
|
||||
if (requestCount === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: { message: "Subscription quota exceeded" } }),
|
||||
{
|
||||
status: 429,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
return new Response(JSON.stringify({ error: { message: "Subscription quota exceeded" } }), {
|
||||
status: 429,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
// Second request: openai succeeds
|
||||
return new Response(JSON.stringify({ choices: [{ message: { content: "openai ok" } }] }), {
|
||||
@@ -462,11 +445,7 @@ test("round-robin path fast-skip: round-robin combo also skips exhausted provide
|
||||
name: "rr-exhaustion-combo",
|
||||
strategy: "round-robin",
|
||||
config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 },
|
||||
models: [
|
||||
"openai/gpt-4o-mini",
|
||||
"openai/gpt-3.5-turbo",
|
||||
"anthropic/claude-3-5-sonnet-20241022",
|
||||
],
|
||||
models: ["openai/gpt-4o-mini", "openai/gpt-3.5-turbo", "anthropic/claude-3-5-sonnet-20241022"],
|
||||
});
|
||||
|
||||
let openaiCalls = 0;
|
||||
@@ -480,13 +459,10 @@ test("round-robin path fast-skip: round-robin combo also skips exhausted provide
|
||||
if (authHeader === "Bearer sk-openai-rr-exhaustion") {
|
||||
openaiCalls += 1;
|
||||
if (openaiCalls === 1) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: { message: "Daily quota exceeded" } }),
|
||||
{
|
||||
status: 429,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
return new Response(JSON.stringify({ error: { message: "Daily quota exceeded" } }), {
|
||||
status: 429,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
throw new Error("Second openai call should have been skipped!");
|
||||
}
|
||||
@@ -515,10 +491,7 @@ test("round-robin path fast-skip: round-robin combo also skips exhausted provide
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(
|
||||
body.choices[0].message.content,
|
||||
"anthropic handled round-robin exhaustion"
|
||||
);
|
||||
assert.equal(body.choices[0].message.content, "anthropic handled round-robin exhaustion");
|
||||
assert.equal(openaiCalls, 1, "round-robin should skip second openai target");
|
||||
assert.equal(anthropicCalls, 1);
|
||||
});
|
||||
|
||||
@@ -65,7 +65,9 @@ test("providers rotate --from-env reads key from process.env and writes DB", asy
|
||||
process.env.TEST_ROTATION_KEY = "sk-new-rotated-key";
|
||||
|
||||
// Mock fetch so isServerUp() returns false → direct DB path
|
||||
globalThis.fetch = async () => { throw new Error("offline"); };
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error("offline");
|
||||
};
|
||||
|
||||
const { runProvidersRotateCommand } = await import("../../bin/cli/commands/providers.mjs");
|
||||
const exitCode = await runProvidersRotateCommand(conn.id, {
|
||||
@@ -78,7 +80,8 @@ test("providers rotate --from-env reads key from process.env and writes DB", asy
|
||||
assert.equal(exitCode, 0, "rotate should succeed with valid env var");
|
||||
|
||||
// Verify key changed in DB
|
||||
const { findProviderConnection, getProviderApiKey } = await import("../../bin/cli/provider-store.mjs");
|
||||
const { findProviderConnection, getProviderApiKey } =
|
||||
await import("../../bin/cli/provider-store.mjs");
|
||||
const db = new Database(path.join(dataDir, "storage.sqlite"));
|
||||
const updated = findProviderConnection(db, conn.id);
|
||||
db.close();
|
||||
@@ -136,7 +139,9 @@ test("providers rotate prints oauth hint for non-apikey connections", async () =
|
||||
|
||||
test("providers status exits 3 when server is offline", async () => {
|
||||
await withEnv(async (_dataDir) => {
|
||||
globalThis.fetch = async () => { throw new Error("offline"); };
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error("offline");
|
||||
};
|
||||
const { runProvidersStatusCommand } = await import("../../bin/cli/commands/providers.mjs");
|
||||
const exitCode = await runProvidersStatusCommand({});
|
||||
assert.equal(exitCode, 3, "should exit 3 when server is offline");
|
||||
@@ -146,8 +151,15 @@ test("providers status exits 3 when server is offline", async () => {
|
||||
test("providers status returns json when server returns expiration list", async () => {
|
||||
await withEnv(async (_dataDir) => {
|
||||
const mockList = [
|
||||
{ connectionId: "abc123", provider: "openai", name: "OpenAI", status: "active",
|
||||
testStatus: "active", expiresAt: null, rateLimitedUntil: null }
|
||||
{
|
||||
connectionId: "abc123",
|
||||
provider: "openai",
|
||||
name: "OpenAI",
|
||||
status: "active",
|
||||
testStatus: "active",
|
||||
expiresAt: null,
|
||||
rateLimitedUntil: null,
|
||||
},
|
||||
];
|
||||
const mockFetch = async () => ({
|
||||
ok: true,
|
||||
|
||||
@@ -52,11 +52,7 @@ test("TC-1: large input exceeds small models — only large-context candidates s
|
||||
"anthropic/claude-3-5": 131072,
|
||||
};
|
||||
|
||||
const { result, didFallback } = contextWindowFilter(
|
||||
targets,
|
||||
20000,
|
||||
(m) => limits[m] ?? null
|
||||
);
|
||||
const { result, didFallback } = contextWindowFilter(targets, 20000, (m) => limits[m] ?? null);
|
||||
|
||||
assert.equal(result.length, 2, "Should keep 2 models with context >= 20k");
|
||||
assert.ok(
|
||||
@@ -73,11 +69,7 @@ test("TC-2: all candidates too small — fallback to full pool", () => {
|
||||
{ modelStr: "a/small3" },
|
||||
];
|
||||
|
||||
const { result, didFallback } = contextWindowFilter(
|
||||
targets,
|
||||
20000,
|
||||
() => 4096
|
||||
);
|
||||
const { result, didFallback } = contextWindowFilter(targets, 20000, () => 4096);
|
||||
|
||||
assert.equal(result.length, 3, "Should preserve all targets when all filtered");
|
||||
assert.equal(didFallback, true, "Should indicate fallback occurred");
|
||||
@@ -95,11 +87,7 @@ test("TC-3: null-limit candidates always included", () => {
|
||||
"a/unknown2": null,
|
||||
};
|
||||
|
||||
const { result, didFallback } = contextWindowFilter(
|
||||
targets,
|
||||
20000,
|
||||
(m) => limits[m] ?? null
|
||||
);
|
||||
const { result, didFallback } = contextWindowFilter(targets, 20000, (m) => limits[m] ?? null);
|
||||
|
||||
assert.equal(result.length, 2, "Should include 2 null-limit models, exclude small");
|
||||
assert.ok(
|
||||
@@ -110,66 +98,39 @@ test("TC-3: null-limit candidates always included", () => {
|
||||
});
|
||||
|
||||
test("TC-4: zero estimated tokens — filter is skipped, pool unchanged", () => {
|
||||
const targets: Target[] = [
|
||||
{ modelStr: "a/m1" },
|
||||
{ modelStr: "a/m2" },
|
||||
];
|
||||
const targets: Target[] = [{ modelStr: "a/m1" }, { modelStr: "a/m2" }];
|
||||
|
||||
const { result, didFallback } = contextWindowFilter(
|
||||
targets,
|
||||
0,
|
||||
() => 4096
|
||||
);
|
||||
const { result, didFallback } = contextWindowFilter(targets, 0, () => 4096);
|
||||
|
||||
assert.equal(result.length, 2, "Should not filter when tokens = 0");
|
||||
assert.equal(didFallback, false);
|
||||
});
|
||||
|
||||
test("TC-5: exact context limit match passes", () => {
|
||||
const targets: Target[] = [
|
||||
{ modelStr: "a/exact" },
|
||||
{ modelStr: "a/small" },
|
||||
];
|
||||
const targets: Target[] = [{ modelStr: "a/exact" }, { modelStr: "a/small" }];
|
||||
const limits: Record<string, number> = {
|
||||
"a/exact": 10000,
|
||||
"a/small": 4096,
|
||||
};
|
||||
|
||||
const { result } = contextWindowFilter(
|
||||
targets,
|
||||
10000,
|
||||
(m) => limits[m] ?? null
|
||||
);
|
||||
const { result } = contextWindowFilter(targets, 10000, (m) => limits[m] ?? null);
|
||||
|
||||
assert.equal(result.length, 1, "Should include model with exact limit match");
|
||||
assert.deepEqual(result[0], { modelStr: "a/exact" });
|
||||
});
|
||||
|
||||
test("TC-6: undefined limit (not null) treated as unknown — included", () => {
|
||||
const targets: Target[] = [
|
||||
{ modelStr: "a/unknown" },
|
||||
];
|
||||
const targets: Target[] = [{ modelStr: "a/unknown" }];
|
||||
|
||||
const { result } = contextWindowFilter(
|
||||
targets,
|
||||
5000,
|
||||
(): (number | null) => undefined
|
||||
);
|
||||
const { result } = contextWindowFilter(targets, 5000, (): number | null => undefined);
|
||||
|
||||
assert.equal(result.length, 1, "Should include model with undefined limit");
|
||||
});
|
||||
|
||||
test("TC-7: negative estimated tokens treated as 0 — no filtering", () => {
|
||||
const targets: Target[] = [
|
||||
{ modelStr: "a/m1" },
|
||||
{ modelStr: "a/m2" },
|
||||
];
|
||||
const targets: Target[] = [{ modelStr: "a/m1" }, { modelStr: "a/m2" }];
|
||||
|
||||
const { result } = contextWindowFilter(
|
||||
targets,
|
||||
-100,
|
||||
() => 4096
|
||||
);
|
||||
const { result } = contextWindowFilter(targets, -100, () => 4096);
|
||||
|
||||
assert.equal(result.length, 2, "Should not filter on negative tokens");
|
||||
});
|
||||
@@ -188,11 +149,7 @@ test("TC-8: mixed limits scenario", () => {
|
||||
"google/gemini": 32768,
|
||||
};
|
||||
|
||||
const { result, didFallback } = contextWindowFilter(
|
||||
targets,
|
||||
5000,
|
||||
(m) => limits[m] ?? null
|
||||
);
|
||||
const { result, didFallback } = contextWindowFilter(targets, 5000, (m) => limits[m] ?? null);
|
||||
|
||||
assert.equal(result.length, 3, "Should keep gpt-4 (8k), claude (unknown), gemini (32k)");
|
||||
assert.ok(
|
||||
|
||||
@@ -254,13 +254,20 @@ test("sanitizeUpstreamDetails — sanitizes string values (absolute path)", asyn
|
||||
const { sanitizeUpstreamDetails } = await import("../../open-sse/utils/error.ts");
|
||||
const input = { error: { message: "bad input at /srv/app/src/lib/db.ts:42" } };
|
||||
const out = sanitizeUpstreamDetails(input) as any;
|
||||
assert.ok(!out.error.message.includes("/srv/app/src/lib/db.ts"), "absolute path must be stripped");
|
||||
assert.ok(
|
||||
!out.error.message.includes("/srv/app/src/lib/db.ts"),
|
||||
"absolute path must be stripped"
|
||||
);
|
||||
assert.ok(out.error.message.includes("<path>"), "path placeholder must be present");
|
||||
});
|
||||
|
||||
test("sanitizeUpstreamDetails — removes blocked keys (stack, apiKey)", async () => {
|
||||
const { sanitizeUpstreamDetails } = await import("../../open-sse/utils/error.ts");
|
||||
const input = { error: { message: "oops" }, stack: "Error\n at foo.ts:1", apiKey: "sk-secret" };
|
||||
const input = {
|
||||
error: { message: "oops" },
|
||||
stack: "Error\n at foo.ts:1",
|
||||
apiKey: "sk-secret",
|
||||
};
|
||||
const out = sanitizeUpstreamDetails(input) as any;
|
||||
assert.ok(!("stack" in out), "stack key must be removed");
|
||||
assert.ok(!("apiKey" in out), "apiKey key must be removed");
|
||||
@@ -286,7 +293,9 @@ test("buildErrorBody — without upstream details omits upstream_details field",
|
||||
|
||||
test("buildErrorBody — with safe upstream details embeds upstream_details", async () => {
|
||||
const { buildErrorBody } = await import("../../open-sse/utils/error.ts");
|
||||
const body = buildErrorBody(400, "bad request", { error: { message: "context_length_exceeded" } });
|
||||
const body = buildErrorBody(400, "bad request", {
|
||||
error: { message: "context_length_exceeded" },
|
||||
});
|
||||
assert.ok("upstream_details" in body, "upstream_details must be present");
|
||||
assert.equal((body.upstream_details as any).error.message, "context_length_exceeded");
|
||||
});
|
||||
@@ -295,7 +304,10 @@ test("buildErrorBody — upstream details with stack key are stripped", async ()
|
||||
const { buildErrorBody } = await import("../../open-sse/utils/error.ts");
|
||||
const body = buildErrorBody(500, "err", { stack: "Error\n at foo.ts:1", code: "internal" });
|
||||
assert.ok("upstream_details" in body, "upstream_details must be present");
|
||||
assert.ok(!("stack" in (body.upstream_details as any)), "stack must be stripped from upstream_details");
|
||||
assert.ok(
|
||||
!("stack" in (body.upstream_details as any)),
|
||||
"stack must be stripped from upstream_details"
|
||||
);
|
||||
assert.equal((body.upstream_details as any).code, "internal");
|
||||
});
|
||||
|
||||
|
||||
@@ -21,5 +21,13 @@ describe("Anti-Cheat", () => {
|
||||
const anomalies = await getAnomalies();
|
||||
assert.ok(Array.isArray(anomalies));
|
||||
});
|
||||
|
||||
it("returns entries with numeric zScore (not hardcoded 0)", async () => {
|
||||
const anomalies = await getAnomalies();
|
||||
for (const a of anomalies) {
|
||||
assert.equal(typeof a.zScore, "number");
|
||||
assert.ok(!Number.isNaN(a.zScore));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
35
tests/unit/gamification/db-gamification.test.ts
Normal file
35
tests/unit/gamification/db-gamification.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { addXp, getXp } from "../../../src/lib/db/gamification";
|
||||
import { calculateLevel } from "../../../src/lib/gamification/xp";
|
||||
import { getDbInstance } from "../../../src/lib/db/core";
|
||||
|
||||
describe("DB Gamification — addXp level computation", () => {
|
||||
it("sets correct level for large initial XP", () => {
|
||||
const testKey = `test-addxp-level-${Date.now()}`;
|
||||
addXp(testKey, "invite_redeem", 50000);
|
||||
|
||||
const xp = getXp(testKey);
|
||||
assert.ok(xp);
|
||||
assert.equal(xp.currentLevel, calculateLevel(50000));
|
||||
|
||||
// Cleanup
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM user_levels WHERE api_key_id = ?").run(testKey);
|
||||
db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(testKey);
|
||||
});
|
||||
|
||||
it("sets level 1 for small initial XP", () => {
|
||||
const testKey = `test-addxp-small-${Date.now()}`;
|
||||
addXp(testKey, "request", 1);
|
||||
|
||||
const xp = getXp(testKey);
|
||||
assert.ok(xp);
|
||||
assert.equal(xp.currentLevel, 1);
|
||||
|
||||
// Cleanup
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM user_levels WHERE api_key_id = ?").run(testKey);
|
||||
db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(testKey);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { emitGamificationEvent } from "../../../src/lib/gamification/events";
|
||||
import { getDbInstance } from "../../../src/lib/db/core";
|
||||
|
||||
describe("Gamification Events", () => {
|
||||
it("does not throw for valid event", async () => {
|
||||
@@ -16,4 +17,29 @@ describe("Gamification Events", () => {
|
||||
emitGamificationEvent({ apiKeyId: "test-user", action: "unknown" as any })
|
||||
);
|
||||
});
|
||||
|
||||
it("checkActionCountBadges counts actions correctly via SQL", async () => {
|
||||
// Verifies the SELECT fix — before fix, missing SELECT caused silent SQL error
|
||||
const db = getDbInstance();
|
||||
|
||||
const testKey = `test-badge-${Date.now()}`;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
db.prepare("INSERT INTO xp_audit_log (api_key_id, action, xp_earned) VALUES (?, ?, ?)").run(
|
||||
testKey,
|
||||
"request",
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the SELECT query works (was broken before fix)
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT COALESCE(COUNT(*), 0) AS count FROM xp_audit_log WHERE api_key_id = ? AND action = ?"
|
||||
)
|
||||
.get(testKey, "request") as { count: number };
|
||||
assert.equal(row.count, 5);
|
||||
|
||||
// Cleanup
|
||||
db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(testKey);
|
||||
});
|
||||
});
|
||||
|
||||
25
tests/unit/gamification/federation-auth.test.ts
Normal file
25
tests/unit/gamification/federation-auth.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
describe("Federation Leaderboard Auth", () => {
|
||||
it("rejects requests without Authorization header", async () => {
|
||||
const { GET } = await import("../../../src/app/api/gamification/federation/leaderboard/route");
|
||||
|
||||
const { NextRequest } = await import("next/server");
|
||||
const req = new NextRequest("http://localhost/api/gamification/federation/leaderboard");
|
||||
|
||||
const response = await GET(req);
|
||||
assert.equal(response.status, 401);
|
||||
});
|
||||
|
||||
it("rejects requests with invalid bearer token", async () => {
|
||||
const { GET } = await import("../../../src/app/api/gamification/federation/leaderboard/route");
|
||||
const { NextRequest } = await import("next/server");
|
||||
const req = new NextRequest("http://localhost/api/gamification/federation/leaderboard", {
|
||||
headers: { Authorization: "Bearer invalid-token-12345" },
|
||||
});
|
||||
|
||||
const response = await GET(req);
|
||||
assert.equal(response.status, 403);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
import { describe, it, beforeEach, after } from "node:test";
|
||||
import { describe, it, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
updateScore,
|
||||
getRank,
|
||||
getTopN,
|
||||
getNeighbors,
|
||||
rotateScope,
|
||||
} from "../../../src/lib/gamification/leaderboard";
|
||||
import { getDbInstance } from "../../../src/lib/db/core";
|
||||
|
||||
describe("Leaderboard Engine", () => {
|
||||
const testKey = `test-lb-${Date.now()}`;
|
||||
@@ -14,7 +14,6 @@ describe("Leaderboard Engine", () => {
|
||||
after(() => {
|
||||
// Cleanup
|
||||
try {
|
||||
const { getDbInstance } = require("../../../src/lib/db/core");
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM leaderboard WHERE api_key_id LIKE ?").run("test-lb-%");
|
||||
} catch {}
|
||||
@@ -58,6 +57,37 @@ describe("Leaderboard Engine", () => {
|
||||
const entries = await getTopN("global", 5);
|
||||
assert.ok(entries.length <= 5);
|
||||
});
|
||||
|
||||
it("returns different results with offset", async () => {
|
||||
// Seed multiple entries with distinct scores
|
||||
const keys: string[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const key = `test-offset-${Date.now()}-${i}`;
|
||||
keys.push(key);
|
||||
await updateScore(key, "global", (10 - i) * 100);
|
||||
}
|
||||
|
||||
const page1 = await getTopN("global", 5, 0);
|
||||
const page2 = await getTopN("global", 5, 5);
|
||||
|
||||
// Pages should not be identical
|
||||
const page1Ids = page1.map((e: any) => e.apiKeyId || e.api_key_id);
|
||||
const page2Ids = page2.map((e: any) => e.apiKeyId || e.api_key_id);
|
||||
const overlap = page1Ids.filter((id: string) => page2Ids.includes(id));
|
||||
assert.equal(overlap.length, 0, "Pages should not overlap");
|
||||
|
||||
// Cleanup
|
||||
const db = getDbInstance();
|
||||
for (const key of keys) {
|
||||
db.prepare("DELETE FROM leaderboard WHERE api_key_id = ?").run(key);
|
||||
}
|
||||
});
|
||||
|
||||
it("offset 0 returns same as no offset", async () => {
|
||||
const withOffset = await getTopN("global", 5, 0);
|
||||
const withoutOffset = await getTopN("global", 5);
|
||||
assert.equal(withOffset.length, withoutOffset.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNeighbors", () => {
|
||||
|
||||
@@ -32,7 +32,11 @@ function jsonResponse(body: unknown, status = 200) {
|
||||
* - /token → returns a minimal token refresh response
|
||||
* - /client/register → returns the given registration pair
|
||||
*/
|
||||
function buildFetchMock(registration: { clientId: string; clientSecret: string; clientSecretExpiresAt?: number }) {
|
||||
function buildFetchMock(registration: {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
clientSecretExpiresAt?: number;
|
||||
}) {
|
||||
return (async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/client/register")) {
|
||||
@@ -54,13 +58,21 @@ const VALID_REFRESH_TOKEN = "aorAAAAAG-mock-refresh-token-for-tests";
|
||||
|
||||
test("validateImportToken registers a client and returns clientId + clientSecret", async () => {
|
||||
const service = new KiroService();
|
||||
const reg = { clientId: "test-client-id", clientSecret: "test-client-secret", clientSecretExpiresAt: 9999999999 };
|
||||
const reg = {
|
||||
clientId: "test-client-id",
|
||||
clientSecret: "test-client-secret",
|
||||
clientSecretExpiresAt: 9999999999,
|
||||
};
|
||||
|
||||
await withMockedFetch(buildFetchMock(reg), async () => {
|
||||
const result = await service.validateImportToken(VALID_REFRESH_TOKEN);
|
||||
assert.equal(result.clientId, reg.clientId, "clientId should be returned");
|
||||
assert.equal(result.clientSecret, reg.clientSecret, "clientSecret should be returned");
|
||||
assert.equal(result.clientSecretExpiresAt, reg.clientSecretExpiresAt, "clientSecretExpiresAt should be returned");
|
||||
assert.equal(
|
||||
result.clientSecretExpiresAt,
|
||||
reg.clientSecretExpiresAt,
|
||||
"clientSecretExpiresAt should be returned"
|
||||
);
|
||||
assert.equal(result.authMethod, "imported");
|
||||
assert.equal(result.accessToken, "at-mock");
|
||||
});
|
||||
@@ -70,26 +82,37 @@ test("validateImportToken succeeds without clientId when registerClient fails",
|
||||
const service = new KiroService();
|
||||
let callCount = 0;
|
||||
|
||||
await withMockedFetch(async (input) => {
|
||||
const url = String(input);
|
||||
callCount++;
|
||||
if (url.endsWith("/client/register")) {
|
||||
return new Response("Service Unavailable", { status: 503 });
|
||||
await withMockedFetch(
|
||||
async (input) => {
|
||||
const url = String(input);
|
||||
callCount++;
|
||||
if (url.endsWith("/client/register")) {
|
||||
return new Response("Service Unavailable", { status: 503 });
|
||||
}
|
||||
return jsonResponse({
|
||||
accessToken: "at-degraded",
|
||||
refreshToken: "rt-degraded",
|
||||
expiresIn: 3600,
|
||||
});
|
||||
},
|
||||
async () => {
|
||||
// Should not throw even though registerClient fails
|
||||
const result = await service.validateImportToken(VALID_REFRESH_TOKEN);
|
||||
assert.equal(
|
||||
result.accessToken,
|
||||
"at-degraded",
|
||||
"import should succeed with a degraded token"
|
||||
);
|
||||
assert.equal(result.authMethod, "imported");
|
||||
// clientId must not be set — the connection degrades to shared social-auth path
|
||||
assert.equal(result.clientId, undefined, "clientId should be absent on degraded import");
|
||||
assert.equal(
|
||||
result.clientSecret,
|
||||
undefined,
|
||||
"clientSecret should be absent on degraded import"
|
||||
);
|
||||
}
|
||||
return jsonResponse({
|
||||
accessToken: "at-degraded",
|
||||
refreshToken: "rt-degraded",
|
||||
expiresIn: 3600,
|
||||
});
|
||||
}, async () => {
|
||||
// Should not throw even though registerClient fails
|
||||
const result = await service.validateImportToken(VALID_REFRESH_TOKEN);
|
||||
assert.equal(result.accessToken, "at-degraded", "import should succeed with a degraded token");
|
||||
assert.equal(result.authMethod, "imported");
|
||||
// clientId must not be set — the connection degrades to shared social-auth path
|
||||
assert.equal(result.clientId, undefined, "clientId should be absent on degraded import");
|
||||
assert.equal(result.clientSecret, undefined, "clientSecret should be absent on degraded import");
|
||||
});
|
||||
);
|
||||
|
||||
assert.ok(callCount >= 1, "fetch should have been called at least once");
|
||||
});
|
||||
@@ -122,8 +145,11 @@ test("two validateImportToken calls return different clientIds when registerClie
|
||||
const result1 = await service.validateImportToken(VALID_REFRESH_TOKEN);
|
||||
const result2 = await service.validateImportToken(VALID_REFRESH_TOKEN);
|
||||
|
||||
assert.notEqual(result1.clientId, result2.clientId,
|
||||
"each import call should receive a distinct clientId for session isolation");
|
||||
assert.notEqual(
|
||||
result1.clientId,
|
||||
result2.clientId,
|
||||
"each import call should receive a distinct clientId for session isolation"
|
||||
);
|
||||
assert.equal(result1.clientId, "client-alpha");
|
||||
assert.equal(result2.clientId, "client-beta");
|
||||
});
|
||||
@@ -133,12 +159,15 @@ test("registerClient uses the provided region in the OIDC endpoint URL", async (
|
||||
const service = new KiroService();
|
||||
const calls: string[] = [];
|
||||
|
||||
await withMockedFetch(async (input) => {
|
||||
calls.push(String(input));
|
||||
return jsonResponse({ clientId: "cid", clientSecret: "csec" });
|
||||
}, async () => {
|
||||
await service.registerClient("ap-southeast-1");
|
||||
});
|
||||
await withMockedFetch(
|
||||
async (input) => {
|
||||
calls.push(String(input));
|
||||
return jsonResponse({ clientId: "cid", clientSecret: "csec" });
|
||||
},
|
||||
async () => {
|
||||
await service.registerClient("ap-southeast-1");
|
||||
}
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
calls.some((url) => url.includes("ap-southeast-1")),
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { T3ChatWebExecutor, T3_CHAT_BASE } = await import(
|
||||
"../../open-sse/executors/t3-chat-web.ts"
|
||||
);
|
||||
const { T3ChatWebExecutor, T3_CHAT_BASE } = await import("../../open-sse/executors/t3-chat-web.ts");
|
||||
const { getExecutor, hasSpecializedExecutor } = await import("../../open-sse/executors/index.ts");
|
||||
|
||||
// NOTE: These tests use mocked HTTP transport. The COMPLETION_URL constant in
|
||||
@@ -117,7 +115,12 @@ function makeValidCreds() {
|
||||
|
||||
function mockT3ChatSSEResponse(chunks: string[]) {
|
||||
const original = globalThis.fetch;
|
||||
const calls: Array<{ url: string; method: string; headers: Record<string, string>; body: unknown }> = [];
|
||||
const calls: Array<{
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body: unknown;
|
||||
}> = [];
|
||||
|
||||
globalThis.fetch = async (url, opts) => {
|
||||
const urlStr = typeof url === "string" ? url : url.toString();
|
||||
|
||||
Reference in New Issue
Block a user