diff --git a/CHANGELOG.md b/CHANGELOG.md index 795c353c89..e298d4f7f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/architecture/CODEBASE_DOCUMENTATION.md b/docs/architecture/CODEBASE_DOCUMENTATION.md index 3c6f09022b..ccf2ea0d36 100644 --- a/docs/architecture/CODEBASE_DOCUMENTATION.md +++ b/docs/architecture/CODEBASE_DOCUMENTATION.md @@ -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 diff --git a/scripts/check/check-t11-any-budget.mjs b/scripts/check/check-t11-any-budget.mjs index d1ae1706c2..3330aa2158 100644 --- a/scripts/check/check-t11-any-budget.mjs +++ b/scripts/check/check-t11-any-budget.mjs @@ -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 }, diff --git a/src/lib/usage/usageHistory.ts b/src/lib/usage/usageHistory.ts index 74fec28f16..a13ffb13f6 100644 --- a/src/lib/usage/usageHistory.ts +++ b/src/lib/usage/usageHistory.ts @@ -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 []; } }