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:
Paijo
2026-05-20 11:36:56 +07:00
committed by GitHub
parent 7dfcd0a4fd
commit 8536593bdc
28 changed files with 469 additions and 260 deletions

View File

@@ -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);
});

View File

@@ -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) |
---

View File

@@ -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` |

View File

@@ -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)

View File

@@ -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

View File

@@ -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.

View File

@@ -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",

View File

@@ -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(

View File

@@ -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

View File

@@ -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);

View File

@@ -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
);
}
}

View File

@@ -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 }
);
}

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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;

View 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";

View File

@@ -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);
}
/**

View File

@@ -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);
});

View File

@@ -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,

View File

@@ -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(

View File

@@ -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");
});

View File

@@ -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));
}
});
});
});

View 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);
});
});

View File

@@ -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);
});
});

View 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);
});
});

View File

@@ -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", () => {

View File

@@ -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")),

View File

@@ -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();