refactor(usage): type saveRequestUsage with UsageEntry interface + any-budget ratchet (#3512) (#6809)

Replace saveRequestUsage(entry: any) with a typed UsageEntry interface
mirroring the usage_history columns 1:1. Fields stay optional/nullable
since different writers (chatCore success/failure, rejected-request
accounting, Codex Responses WS) populate the row incrementally; tokens
stays unknown since callers pass either raw provider-shaped usage or
the normalized {input,output,cacheRead,...} shape.

Also cleaned the file's other any usages (getUsageHistory filter,
getUsageDb next-cursor cast, appendRequestLog tokens param,
getRecentLogs catch) so it now sits at zero any and can be added to
the check:any-budget:t11 zero-any allowlist.

Documents the DB-entity <-> TS-interface convention in
docs/architecture/CODEBASE_DOCUMENTATION.md Sec 11.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-11 04:32:10 -03:00
committed by GitHub
parent 6d3d122b84
commit d96bd80343
4 changed files with 76 additions and 6 deletions

View File

@@ -80,6 +80,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral
- **chore(open-sse):** removed the vestigial `// @ts-nocheck` directive from `open-sse/utils/usageTracking.ts` ([#6173](https://github.com/diegosouzapw/OmniRoute/pull/6173)) — `tsc` under the standard `typecheck:core` gate reports 0 errors for this 595-line hot-path file (executed for every provider response), so the suppression was no longer needed; removing it restores type-checking on the token-usage extraction/normalization path. (thanks @KooshaPari)
- **chore(cli):** the shell-completion cache paths (`readCache`/`refreshCache`/`writeCache`) in `bin/cli/commands/completion.mjs` no longer swallow errors into a bare `catch {}` ([#6257](https://github.com/diegosouzapw/OmniRoute/pull/6257)) — each now binds the error and, when the new `OMNIROUTE_DEBUG_COMPLETION` env var is set, emits a `[omniroute completion]` diagnostic to `stderr`; the caches still fail silently by default so a missing/corrupt cache never breaks tab-completion. (thanks @KooshaPari)
- **chore(quality):** `validate-release-green --full-ci` reproduces the full `ci.yml` static gate set locally — the pre-flight now reads `ci.yml` itself and runs every `npm run check:*` from the `lint` / `quality-gate` / `quality-extended` / `docs-sync-strict` / `pr-test-policy` jobs (`--` ratchet flags preserved, `test-masking` against `GITHUB_BASE_REF=main`), skipping only the non-local `pr-evidence`/`codeql-ratchet`. Closes the gap where 11 static base-reds leaked to the v3.8.46 release PR in ~2h of layered CI. Also wired into `nightly-release-green` so a static base-red opens a tracking issue the night it lands. Regression guard: `tests/unit/validate-release-green.test.ts` (+5 `extractCiGates` cases).
- **refactor(usage):** `saveRequestUsage(entry: any)` is now typed with a new `UsageEntry` interface mirroring the `usage_history` columns 1:1 (#3512) — the other stray `any`s in `src/lib/usage/usageHistory.ts` (`getUsageHistory` filter, the `getUsageDb` next-cursor cast, `appendRequestLog`'s legacy `tokens` param, `getRecentLogs`'s catch) were cleaned in the same pass, so the file now sits in the `check:any-budget:t11` zero-`any` allowlist. The DB-entity ↔ TS-interface convention is documented in `docs/architecture/CODEBASE_DOCUMENTATION.md` §11.
### 🙌 Contributors

View File

@@ -793,6 +793,20 @@ See [A2A-SERVER.md § Adding a New Skill](../frameworks/A2A-SERVER.md). Skills l
inference for cross-module boundaries.
- **Database**: never write raw SQL in routes or handlers — always go through
`src/lib/db/` modules. Never add logic to `src/lib/localDb.ts`.
- **DB-entity typing (#3512)**: a function that writes or reads a DB table's
row shape should take/return a named TS interface mirroring that table's
columns 1:1, not `any` or an inline anonymous type at the call site. Land
the interface next to the function (e.g. `export interface UsageEntry` in
`src/lib/usage/usageHistory.ts` above `saveRequestUsage`), keep individual
fields optional/nullable when different writers populate the row
incrementally, and prefer `unknown` over `any` for a field whose shape
varies across callers (documented on the field, e.g. `UsageEntry.tokens`
accepts both raw provider-shaped usage and the normalized shape). Once a
file's `any` count reaches zero this way, add it to the
`check:any-budget:t11` allowlist (`scripts/check/check-t11-any-budget.mjs`,
`maxAny: 0`) so it can't regress. This is a first-slice convention — the
broader "no anonymous `any`" cleanup is iterative across the rest of the
codebase.
- **Errors**: try/catch with specific error types, log with pino context. Never
silently swallow errors in SSE streams; use abort signals for cleanup.
- **Security**: never use `eval()` / `new Function()` / implied eval. Validate

View File

@@ -22,6 +22,10 @@ const budget = [
{ file: "src/lib/db/prompts.ts", maxAny: 0 },
{ file: "src/lib/db/providers.ts", maxAny: 0 },
{ file: "src/lib/db/settings.ts", maxAny: 0 },
// #3512: saveRequestUsage typed with UsageEntry (DB-entity 1:1 interface); the
// other any's in this file (getUsageHistory filter, nextCursor cast,
// appendRequestLog tokens, getRecentLogs catch) were cleaned in the same pass.
{ file: "src/lib/usage/usageHistory.ts", maxAny: 0 },
{ file: "open-sse/config/providerRegistry.ts", maxAny: 0 },
{ file: "open-sse/config/providerModels.ts", maxAny: 0 },
{ file: "open-sse/mcp-server/audit.ts", maxAny: 0 },

View File

@@ -553,17 +553,58 @@ export async function getUsageDb(sinceIso?: string | null, limit?: number, curso
});
// Provide next cursor if we hit the limit (more rows exist)
const nextCursor = rows.length === maxRows ? (rows[rows.length - 1] as any)?.timestamp : null;
const nextCursor =
rows.length === maxRows ? toStringOrNull(asRecord(rows[rows.length - 1]).timestamp) : null;
return { data: { history, nextCursor } };
}
// ──────────────── Save Request Usage ────────────────
/**
* DB-entity-mapped shape accepted by {@link saveRequestUsage}, mirroring the
* `usage_history` table columns 1:1 (see `src/lib/db/migrations/`). Convention
* (#3512): every `usage_history` writer should type its entry against this
* interface instead of an inline anonymous object or `any` — call sites are
* intentionally permissive (fields optional/nullable) because rows are built
* incrementally across several extraction points (chatCore success/failure
* paths, rejected-request accounting, the Codex Responses WS bridge).
*
* `tokens` stays `unknown` on purpose: callers pass either the raw
* provider-shaped usage object (OpenAI `prompt_tokens`/`completion_tokens`,
* Anthropic `input_tokens`/`cache_read_input_tokens`, …) or the already
* normalized `{ input, output, cacheRead, cacheCreation, reasoning }` shape —
* `getLoggedInputTokens`/`getLoggedOutputTokens`/`getPromptCache*Tokens` in
* `./tokenAccounting` accept both and extract the right fields.
*/
export interface UsageEntry {
provider?: string | null;
model?: string | null;
/** Raw or normalized token usage — see the interface doc above. */
tokens?: unknown;
status?: string | null;
success?: boolean;
latencyMs?: number;
timeToFirstTokenMs?: number;
errorCode?: string | null;
/** ISO timestamp; defaults to `new Date().toISOString()` when omitted. */
timestamp?: string;
connectionId?: string | null;
apiKeyId?: string | null;
apiKeyName?: string | null;
serviceTier?: string | null;
/** @deprecated legacy snake_case fallback, read only if `serviceTier` is unset. */
service_tier?: string | null;
comboStrategy?: string | null;
/** @deprecated legacy snake_case fallback, read only if `comboStrategy` is unset. */
combo_strategy?: string | null;
endpoint?: string | null;
}
/**
* Save request usage entry to SQLite.
*/
export async function saveRequestUsage(entry: any) {
export async function saveRequestUsage(entry: UsageEntry) {
if (!shouldPersistToDisk) return;
try {
@@ -666,10 +707,17 @@ export async function saveRequestUsage(entry: any) {
// ──────────────── Get Usage History ────────────────
export interface UsageHistoryFilter {
provider?: string;
model?: string;
startDate?: string | number | Date;
endDate?: string | number | Date;
}
/**
* Get usage history with optional filters.
*/
export async function getUsageHistory(filter: any = {}) {
export async function getUsageHistory(filter: UsageHistoryFilter = {}) {
const db = getDbInstance();
let sql = "SELECT * FROM usage_history";
const conditions: string[] = [];
@@ -875,7 +923,7 @@ export async function appendRequestLog({
model?: string;
provider?: string;
connectionId?: string;
tokens?: any;
tokens?: unknown;
status?: string | number;
}) {
// Deprecated: request summaries now come from SQLite call_logs.
@@ -909,8 +957,11 @@ export async function getRecentLogs(limit = 200) {
const status = typeof row.status === "number" ? row.status : String(row.status || "-");
return `${timestamp} | ${model} | ${provider} | ${account} | ${tokensIn} | ${tokensOut} | ${status}`;
});
} catch (error: any) {
console.error("[usageDb] Failed to read recent call logs:", error.message);
} catch (error) {
console.error(
"[usageDb] Failed to read recent call logs:",
error instanceof Error ? error.message : String(error)
);
return [];
}
}