/** * @file Enriched OpenAPI OVERRIDE operations for the Pricing and Alerts routes. * * These eight paths are ALREADY documented in `server/openapi.js`. This module * re-declares the SAME operations (identical operationId / tags / request & * response `$ref` schema names / parameters) but layers on richer prose * descriptions plus realistic request/response/parameter examples so the * generated Swagger UI is self-explanatory. The wire contract is unchanged — * no new schemas, no new tags. The base `$ref`s under * `#/components/{schemas,parameters}` are reused verbatim. * * Shape: `{ tags: [], schemas: {}, paths: { ... } }`. The `tags` and `schemas` * collections are intentionally empty; everything here is a path-level override. * * @author Nguyễn Ngọc Trí Vĩ */ // --------------------------------------------------------------------------- // Reusable realistic examples (kept here, NOT as components — examples live // inline on the operations so the override carries no schema/component state). // --------------------------------------------------------------------------- /** A representative stored pricing rule (matches PricingRule schema fields). */ const PRICING_RULE_EXAMPLE = { model_pattern: "claude-opus-4%", display_name: "Claude Opus 4 (family)", input_per_mtok: 15, output_per_mtok: 75, cache_read_per_mtok: 1.5, cache_write_per_mtok: 18.75, cache_write_1h_per_mtok: 30, fast_input_per_mtok: 0, fast_output_per_mtok: 0, updated_at: "2026-06-25T18:42:11.000Z", }; /** A second rule to make the list example look like a real catalog. */ const PRICING_RULE_EXAMPLE_2 = { model_pattern: "claude-haiku%", display_name: "Claude Haiku (family)", input_per_mtok: 0.8, output_per_mtok: 4, cache_read_per_mtok: 0.08, cache_write_per_mtok: 1, cache_write_1h_per_mtok: 1.6, fast_input_per_mtok: 0, fast_output_per_mtok: 0, updated_at: "2026-06-20T09:15:00.000Z", }; /** A full CostResult-shaped example body returned by both cost endpoints. */ const COST_RESULT_EXAMPLE = { total_cost: 12.8431, breakdown: [ { model: "claude-opus-4-8", speed: "standard", inference_geo: "global", service_tier: "standard", input_tokens: 184320, output_tokens: 51200, cache_read_tokens: 920000, cache_write_tokens: 64000, cache_write_1h_tokens: 12000, web_search_requests: 8, web_fetch_requests: 3, code_execution_requests: 2, cost: 8.4127, matched_rule: "claude-opus-4%", }, { model: "claude-haiku-4-5", speed: "fast", inference_geo: "us", service_tier: "standard", input_tokens: 512000, output_tokens: 128000, cache_read_tokens: 64000, cache_write_tokens: 8000, cache_write_1h_tokens: 0, web_search_requests: 0, web_fetch_requests: 0, code_execution_requests: 0, cost: 1.5904, matched_rule: "claude-haiku%", }, ], feature_costs: { web_search_cost: 0.08, web_fetch_cost: 0, code_execution_cost: 0, code_execution_hours_estimated: 0.1667, code_execution_free_hours: 50, }, unpriced_models: [ { model: "claude-experimental-preview", input_tokens: 4096, output_tokens: 2048, cache_read_tokens: 0, cache_write_tokens: 0, }, ], daily_costs: [ { date: "2026-06-23", cost: 3.1102 }, { date: "2026-06-24", cost: 5.7421 }, { date: "2026-06-25", cost: 3.9908 }, ], }; /** A single fired-alert event row. `details` is a JSON STRING, per the route. */ const ALERT_EVENT_EXAMPLE = { id: 42, rule_id: "7c1d8e2a-9b34-4f50-a1c2-6d8e0f3b5a91", rule_name: "Idle session watchdog", rule_type: "inactivity", message: "Session sess_8f2a has been inactive for 35 minutes", details: '{"session_id":"sess_8f2a","minutes":35,"threshold":30}', acknowledged: 0, created_at: "2026-06-25T17:05:44.000Z", }; /** A serialized alert RULE (config parsed to an object, enabled coerced bool). */ const ALERT_RULE_EXAMPLE = { id: "7c1d8e2a-9b34-4f50-a1c2-6d8e0f3b5a91", name: "Idle session watchdog", rule_type: "inactivity", config: { minutes: 30 }, enabled: true, cooldown_seconds: 300, created_at: "2026-06-10T12:00:00.000Z", updated_at: "2026-06-24T08:30:00.000Z", }; /** A second rule of a different type for the list example. */ const ALERT_RULE_EXAMPLE_2 = { id: "1a2b3c4d-5e6f-7081-9201-aabbccddeeff", name: "Heavy token burn", rule_type: "token_threshold", config: { total_tokens: 5000000 }, enabled: true, cooldown_seconds: 600, created_at: "2026-06-12T14:20:00.000Z", updated_at: "2026-06-12T14:20:00.000Z", }; module.exports = { // No new tags — reuse the base "Pricing" and "Alerts" tags. tags: [], // No new schemas — every $ref below points at the base components. schemas: {}, paths: { // ----------------------------------------------------------------------- // PRICING // ----------------------------------------------------------------------- "/api/pricing": { get: { tags: ["Pricing"], summary: "List pricing rules", operationId: "listPricingRules", description: "Returns every stored pricing rule, wrapped as `{ pricing: [ ... ] }`. " + "Each rule carries per-MTok (per-million-token) rates for input, output, " + "cache reads, and the two cache-write tiers (5-minute and 1-hour " + "ephemeral), plus optional fast-mode input/output rates (0 = not " + "configured). Rules are matched against model ids by treating the SQL " + "`%` wildcard in `model_pattern` as `.*`; when several rules match, the " + "longest (most specific) pattern wins. Rates here feed the cost " + "calculations under `/api/pricing/cost`.", responses: { 200: { description: "Pricing rules", content: { "application/json": { schema: { $ref: "#/components/schemas/PricingListResponse" }, example: { pricing: [PRICING_RULE_EXAMPLE, PRICING_RULE_EXAMPLE_2] }, }, }, }, }, }, put: { tags: ["Pricing"], summary: "Create/update pricing rule", operationId: "upsertPricingRule", description: "Creates a pricing rule or updates the existing one with the same " + "`model_pattern` (upsert keyed on `model_pattern`). `model_pattern` and " + "`display_name` are required; every `*_per_mtok` rate is optional and " + "defaults to 0 when omitted. Use the SQL `%` wildcard in `model_pattern` " + "to match a model family (e.g. `claude-opus-4%`). Set " + "`fast_input_per_mtok` / `fast_output_per_mtok` only if the model bills " + "fast-mode usage at a premium; leave them 0 otherwise. " + "Note the asymmetry with the list endpoint: the response wraps a SINGLE " + "stored rule as `{ pricing: }` (not an array). A missing " + "`model_pattern` or `display_name` returns 400 `INVALID_INPUT`, and so " + "does any `*_per_mtok` rate that is not a non-negative finite number " + "(numeric strings are coerced; NaN and negative rates are rejected " + "before anything is written).", requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/PricingUpsertRequest" }, example: { model_pattern: "claude-opus-4%", display_name: "Claude Opus 4 (family)", input_per_mtok: 15, output_per_mtok: 75, cache_read_per_mtok: 1.5, cache_write_per_mtok: 18.75, cache_write_1h_per_mtok: 30, fast_input_per_mtok: 0, fast_output_per_mtok: 0, }, }, }, }, responses: { 200: { description: "Pricing rule stored", content: { "application/json": { schema: { $ref: "#/components/schemas/PricingUpsertResponse" }, example: { pricing: PRICING_RULE_EXAMPLE }, }, }, }, 400: { description: "Invalid request body", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" }, example: { error: { code: "INVALID_INPUT", message: "model_pattern and display_name are required", }, }, }, }, }, }, }, }, "/api/pricing/{pattern}": { delete: { tags: ["Pricing"], summary: "Delete pricing rule", operationId: "deletePricingRule", description: "Deletes the pricing rule whose `model_pattern` exactly matches the " + "`pattern` path segment. The pattern is URL-ENCODED: the SQL `%` " + "wildcard must be sent as `%25` (so `claude-opus-4%` becomes " + "`claude-opus-4%25`). The server decodes it before lookup. Returns " + "`{ ok: true }` on success, or 404 `NOT_FOUND` if no rule matches.", parameters: [ // Mirrors components.parameters.PatternPath (name/in/required/schema // identical), inlined so a realistic URL-encoded example can be // attached — a bare $ref cannot carry an `example`. { name: "pattern", in: "path", required: true, schema: { type: "string" }, description: "Model pattern (URL-encoded). The SQL `%` wildcard must be escaped " + "as `%25` (e.g. `claude-opus-4%25` for the rule `claude-opus-4%`).", example: "claude-opus-4%25", }, ], responses: { 200: { description: "Rule deleted", content: { "application/json": { schema: { $ref: "#/components/schemas/DeleteOkResponse" }, example: { ok: true }, }, }, }, 404: { description: "Pricing rule not found", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" }, example: { error: { code: "NOT_FOUND", message: "Pricing rule not found" }, }, }, }, }, }, }, }, "/api/pricing/cost": { get: { tags: ["Pricing"], summary: "Get total token cost across all sessions", operationId: "getTotalCost", description: "Computes the aggregate token cost across EVERY session by matching " + "each (model, speed, inference_geo, service_tier) usage bucket against " + "the most specific pricing rule. Returns `total_cost`, a per-bucket " + "`breakdown`, `feature_costs` (web-search surcharge, code-execution " + "container time with the org free-hours allowance applied), " + "`unpriced_models` (usage with no matching rule, contributing $0 so the " + "total stays honest), and `daily_costs` bucketed by local calendar day. " + "Pass `tz_offset` (minutes; the JS `Date.getTimezoneOffset()` value, " + "e.g. 300 for US Eastern, -120 for CEST) so day boundaries align with " + "the viewer's timezone; omitted/invalid offsets fall back to UTC. " + "Honors the `sources` data-scope filter, like the sessions / stats / " + "analytics endpoints, so the reported cost matches the active scope.", parameters: [ { name: "tz_offset", in: "query", required: false, schema: { type: "integer" }, description: "Viewer timezone offset in minutes, as returned by " + "`Date.getTimezoneOffset()` (positive for zones behind UTC, e.g. " + "300 = US Eastern, -120 = CEST). Shifts the `daily_costs` day " + "boundaries; invalid or omitted values default to UTC.", example: 300, }, { name: "sources", in: "query", required: false, schema: { type: "string" }, description: "Comma-separated data-source ids to include (local history is " + "`local`; remote SSH machines use their `remote_sources.id`). Omit " + "for all sources. Narrows the aggregate cost to the given origins.", example: "local", }, ], responses: { 200: { description: "Cost result", content: { "application/json": { schema: { $ref: "#/components/schemas/CostResult" }, example: COST_RESULT_EXAMPLE, }, }, }, }, }, }, "/api/pricing/cost/{sessionId}": { get: { tags: ["Pricing"], summary: "Get token cost for one session", operationId: "getSessionCost", description: "Same cost computation as `/api/pricing/cost`, but scoped to a single " + "session's token usage. Returns the identical `CostResult` shape " + "(`total_cost`, `breakdown`, `feature_costs`, `unpriced_models`, " + "`daily_costs`); `daily_costs` holds at most one entry — the session's " + "start date in the viewer's local day, or an empty array if the session " + "id is unknown. Pass `tz_offset` (minutes, `Date.getTimezoneOffset()`) " + "to place that start date in the viewer's timezone; defaults to UTC.", parameters: [ { name: "sessionId", in: "path", required: true, schema: { type: "string" }, description: "Session ID to price.", example: "sess_8f2a3b1c", }, { name: "tz_offset", in: "query", required: false, schema: { type: "integer" }, description: "Viewer timezone offset in minutes (`Date.getTimezoneOffset()`; " + "300 = US Eastern, -120 = CEST). Places the session start date in " + "the viewer's local day; defaults to UTC when omitted or invalid.", example: 300, }, ], responses: { 200: { description: "Session cost result", content: { "application/json": { schema: { $ref: "#/components/schemas/CostResult" }, example: { ...COST_RESULT_EXAMPLE, total_cost: 8.4127, daily_costs: [{ date: "2026-06-25", cost: 8.4127 }], }, }, }, }, }, }, }, // ----------------------------------------------------------------------- // ALERTS // ----------------------------------------------------------------------- "/api/alerts": { get: { tags: ["Alerts"], summary: "List fired alerts, newest first", operationId: "listAlerts", description: "Returns the fired-alert feed, newest first, as " + "`{ alerts, total, unacked, limit, offset }`. Each alert event carries " + "the originating rule's id/name/type, a human-readable `message`, an " + "`acknowledged` flag (0/1), `created_at`, and `details` — which is a " + "JSON STRING (not an object) that callers must `JSON.parse`. " + "`limit` is clamped to 1–200 (default 50) and negative `offset` is " + "clamped to 0. Set `unacked=true` to return only unacknowledged alerts; " + "`total` then counts only unacked rows, while `unacked` always reports " + "the global unacknowledged count.", parameters: [ { name: "limit", in: "query", required: false, schema: { type: "integer", minimum: 1, maximum: 200, default: 50 }, description: "Page size, clamped to the 1–200 range (default 50). Values " + "outside the range are clamped, not rejected.", example: 50, }, { name: "offset", in: "query", required: false, schema: { type: "integer", minimum: 0 }, description: "Pagination offset; negative values are clamped to 0.", example: 0, }, { name: "unacked", in: "query", required: false, schema: { type: "boolean" }, description: 'When the literal string "true", return only unacknowledged ' + "alerts (and scope `total` to that subset).", example: "true", }, ], responses: { 200: { description: "Paginated alert feed with total and unacked counts", content: { "application/json": { schema: { type: "object", additionalProperties: true, description: "Includes alerts[], total, unacked, limit, offset.", }, example: { alerts: [ ALERT_EVENT_EXAMPLE, { id: 41, rule_id: "1a2b3c4d-5e6f-7081-9201-aabbccddeeff", rule_name: "Heavy token burn", rule_type: "token_threshold", message: "Session sess_3c1d crossed 5,000,000 total tokens", details: '{"session_id":"sess_3c1d","total_tokens":5120000,"threshold":5000000}', acknowledged: 1, created_at: "2026-06-25T16:40:02.000Z", }, ], total: 2, unacked: 1, limit: 50, offset: 0, }, }, }, }, }, }, }, "/api/alerts/rules": { get: { tags: ["Alerts"], summary: "List alert rules", operationId: "listAlertRules", description: "Returns all alert rules as `{ rules: [ ... ] }`. Each rule's `config` " + "is returned as a PARSED object (the column is stored as JSON text), and " + "`enabled` is coerced to a boolean. The `config` shape depends on " + "`rule_type`: `event_pattern` uses event_type / tool_name / " + "summary_contains plus optional count + window_minutes; `inactivity` " + "uses `minutes`; `status_duration` uses `status` + `minutes`; " + "`token_threshold` uses `total_tokens`.", responses: { 200: { description: "All alert rules with parsed config objects", content: { "application/json": { schema: { type: "object", additionalProperties: true }, example: { rules: [ALERT_RULE_EXAMPLE, ALERT_RULE_EXAMPLE_2] }, }, }, }, }, }, post: { tags: ["Alerts"], summary: "Create an alert rule", operationId: "createAlertRule", description: "Creates an alert rule and returns it serialized as `{ rule: { ... } }` " + "with HTTP 201. `name`, `rule_type`, and `config` are required; the " + "`config` shape is validated per `rule_type`:\n" + "- `event_pattern`: `{ event_type?, tool_name?, summary_contains?, " + "count?, window_minutes? }` — fires when matching events accumulate.\n" + "- `inactivity`: `{ minutes }` — fires when a session goes idle.\n" + "- `status_duration`: `{ status, minutes }` — fires when a session " + "holds a status too long.\n" + "- `token_threshold`: `{ total_tokens }` — fires when usage crosses a " + "ceiling.\n" + "`enabled` defaults to true and `cooldown_seconds` defaults to 300 " + "(must be a non-negative integer). A bad name, unknown `rule_type`, " + "invalid `config`, or negative cooldown returns 400 `INVALID_INPUT`.", requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["name", "rule_type", "config"], properties: { name: { type: "string" }, rule_type: { type: "string", enum: ["event_pattern", "inactivity", "status_duration", "token_threshold"], }, config: { type: "object", additionalProperties: true, description: "Type-specific config. event_pattern: event_type/tool_name/summary_contains + optional count/window_minutes. inactivity: minutes. status_duration: status + minutes. token_threshold: total_tokens.", }, enabled: { type: "boolean", default: true }, cooldown_seconds: { type: "integer", default: 300 }, }, }, examples: { inactivity: { summary: "Inactivity rule", value: { name: "Idle session watchdog", rule_type: "inactivity", config: { minutes: 30 }, enabled: true, cooldown_seconds: 300, }, }, event_pattern: { summary: "Event-pattern rule (repeated tool errors)", value: { name: "Repeated Bash failures", rule_type: "event_pattern", config: { event_type: "PostToolUse", tool_name: "Bash", summary_contains: "error", count: 3, window_minutes: 10, }, enabled: true, cooldown_seconds: 600, }, }, status_duration: { summary: "Status-duration rule", value: { name: "Stuck waiting too long", rule_type: "status_duration", config: { status: "waiting", minutes: 15 }, enabled: true, cooldown_seconds: 300, }, }, token_threshold: { summary: "Token-threshold rule", value: { name: "Heavy token burn", rule_type: "token_threshold", config: { total_tokens: 5000000 }, enabled: true, cooldown_seconds: 600, }, }, }, }, }, }, responses: { 201: { description: "Created rule", content: { "application/json": { schema: { type: "object", additionalProperties: true }, example: { rule: ALERT_RULE_EXAMPLE }, }, }, }, 400: { description: "Validation error", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" }, example: { error: { code: "INVALID_INPUT", message: "name is required" }, }, }, }, }, }, }, }, "/api/alerts/rules/{id}": { patch: { tags: ["Alerts"], summary: "Update an alert rule (partial; rule_type is immutable)", operationId: "updateAlertRule", description: "Partially updates an alert rule and returns it serialized as " + "`{ rule: { ... } }`. Only the fields present in the body change; " + "`rule_type` CANNOT be changed and any supplied `config` is validated " + "against the rule's STORED type. `name` (if present) must be a " + "non-empty string and `cooldown_seconds` (if present) must be a " + "non-negative integer. Returns 404 `NOT_FOUND` for an unknown id, or " + "400 `INVALID_INPUT` for a bad name, invalid config, or negative " + "cooldown.", parameters: [ { name: "id", in: "path", required: true, schema: { type: "string" }, description: "Alert rule ID (UUID).", example: "7c1d8e2a-9b34-4f50-a1c2-6d8e0f3b5a91", }, ], requestBody: { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, config: { type: "object", additionalProperties: true }, enabled: { type: "boolean" }, cooldown_seconds: { type: "integer" }, }, }, examples: { disableRule: { summary: "Disable a rule without touching its config", value: { enabled: false }, }, retuneInactivity: { summary: "Re-tune an inactivity rule's threshold + cooldown", value: { config: { minutes: 45 }, cooldown_seconds: 900 }, }, rename: { summary: "Rename a rule", value: { name: "Idle session watchdog (prod)" }, }, }, }, }, }, responses: { 200: { description: "Updated rule", content: { "application/json": { schema: { type: "object", additionalProperties: true }, example: { rule: { ...ALERT_RULE_EXAMPLE, enabled: false, cooldown_seconds: 900 }, }, }, }, }, 400: { description: "Validation error", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" }, example: { error: { code: "INVALID_INPUT", message: "cooldown_seconds must be a non-negative integer", }, }, }, }, }, 404: { description: "Rule not found", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" }, example: { error: { code: "NOT_FOUND", message: "Alert rule not found" }, }, }, }, }, }, }, delete: { tags: ["Alerts"], summary: "Delete an alert rule and its fired-alert history", operationId: "deleteAlertRule", description: "Deletes the alert rule with the given id. Its fired-alert history " + "cascades away with it (the foreign key is ON DELETE CASCADE), so any " + "alerts previously raised by this rule are also removed from the feed. " + "Returns `{ ok: true }` on success or 404 `NOT_FOUND` for an unknown id.", parameters: [ { name: "id", in: "path", required: true, schema: { type: "string" }, description: "Alert rule ID (UUID).", example: "7c1d8e2a-9b34-4f50-a1c2-6d8e0f3b5a91", }, ], responses: { 200: { description: "Deletion confirmation", content: { "application/json": { schema: { type: "object", additionalProperties: true }, example: { ok: true }, }, }, }, 404: { description: "Rule not found", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" }, example: { error: { code: "NOT_FOUND", message: "Alert rule not found" }, }, }, }, }, }, }, }, "/api/alerts/{id}/ack": { post: { tags: ["Alerts"], summary: "Acknowledge one fired alert", operationId: "ackAlert", description: "Marks a single fired alert (by its integer event id) as acknowledged " + "and returns the updated row as `{ alert: { ... } }` (with " + "`acknowledged: 1`). Acknowledging also broadcasts an `alert_updated` " + "WebSocket message so connected dashboards refresh their unacked badge. " + "The id must be numeric; an unknown id returns 404 `NOT_FOUND`.", parameters: [ { name: "id", in: "path", required: true, schema: { type: "integer" }, description: "Alert event ID (numeric).", example: 42, }, ], responses: { 200: { description: "Acknowledged alert row", content: { "application/json": { schema: { type: "object", additionalProperties: true }, example: { alert: { ...ALERT_EVENT_EXAMPLE, acknowledged: 1 } }, }, }, }, 404: { description: "Alert not found", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" }, example: { error: { code: "NOT_FOUND", message: "Alert not found" }, }, }, }, }, }, }, }, "/api/alerts/ack-all": { post: { tags: ["Alerts"], summary: "Acknowledge all unacked alerts", operationId: "ackAllAlerts", description: "Acknowledges every currently unacknowledged alert in one call and " + "returns `{ ok: true, acknowledged: }` where `acknowledged` is " + "the number of rows actually updated. When at least one alert is " + "acknowledged, an `alert_updated` WebSocket message (`{ acked_all: " + "true }`) is broadcast so dashboards clear their unacked badge. Calling " + "this when nothing is unacked returns `acknowledged: 0`.", responses: { 200: { description: "Count of acknowledged alerts", content: { "application/json": { schema: { type: "object", additionalProperties: true }, example: { ok: true, acknowledged: 3 }, }, }, }, }, }, }, }, };