model.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. import { and, asc, eq, inArray, or } from "drizzle-orm"
  2. import { Effect, Layer } from "effect"
  3. import * as Context from "effect/Context"
  4. import { DatabaseError, DrizzleClient } from "../database"
  5. import { modelStat } from "../database/schema"
  6. import { RETIRED_STAT_MODELS, RETIRED_STAT_PROVIDERS } from "./model-normalization"
  7. import {
  8. chunks,
  9. collapseRows,
  10. inserted,
  11. rankBy,
  12. statPeriodKey,
  13. statRowScope,
  14. synthesizeAllTierRows,
  15. toStatBaseRow,
  16. UPSERT_CHUNK_SIZE,
  17. type StatBaseAggregate,
  18. } from "./stat"
  19. export type ModelStatRow = typeof modelStat.$inferInsert
  20. export type ModelStatAggregate = StatBaseAggregate & { provider: string; model: string; provider_model: string }
  21. export type ModelStatMetric = {
  22. periodKey: string
  23. updatedAt: Date
  24. tier: string
  25. provider: string
  26. model: string
  27. sessions: number
  28. inputTokens: number
  29. outputTokens: number
  30. reasoningTokens: number
  31. cacheReadTokens: number
  32. totalTokens: number
  33. inputCostMicrocents: number
  34. outputCostMicrocents: number
  35. totalCostMicrocents: number
  36. }
  37. export declare namespace ModelStatRepo {
  38. export interface Service {
  39. readonly listDaily: () => Effect.Effect<ModelStatMetric[], DatabaseError>
  40. readonly upsert: (rows: ModelStatRow[]) => Effect.Effect<void, DatabaseError>
  41. readonly deleteRetiredDimensions: (rows: ModelStatRow[]) => Effect.Effect<void, DatabaseError>
  42. }
  43. }
  44. export class ModelStatRepo extends Context.Service<ModelStatRepo, ModelStatRepo.Service>()(
  45. "@opencode/stats/ModelStatRepo",
  46. ) {
  47. static readonly layer: Layer.Layer<ModelStatRepo, never, DrizzleClient> = Layer.effect(
  48. ModelStatRepo,
  49. Effect.gen(function* () {
  50. const db = yield* DrizzleClient
  51. const listDaily = Effect.fn("ModelStatRepo.listDaily")(function* () {
  52. return yield* Effect.tryPromise({
  53. try: () =>
  54. db
  55. .select({
  56. periodKey: modelStat.period_key,
  57. updatedAt: modelStat.updated_at,
  58. tier: modelStat.tier,
  59. provider: modelStat.provider,
  60. model: modelStat.model,
  61. sessions: modelStat.sessions,
  62. inputTokens: modelStat.input_tokens,
  63. outputTokens: modelStat.output_tokens,
  64. reasoningTokens: modelStat.reasoning_tokens,
  65. cacheReadTokens: modelStat.cache_read_tokens,
  66. totalTokens: modelStat.total_tokens,
  67. inputCostMicrocents: modelStat.input_cost_microcents,
  68. outputCostMicrocents: modelStat.output_cost_microcents,
  69. totalCostMicrocents: modelStat.total_cost_microcents,
  70. })
  71. .from(modelStat)
  72. .where(and(eq(modelStat.grain, "day"), eq(modelStat.client, "all"), eq(modelStat.source, "all")))
  73. .orderBy(asc(modelStat.period_key)),
  74. catch: (cause) => DatabaseError.make({ cause }),
  75. })
  76. })
  77. const upsert = Effect.fn("ModelStatRepo.upsert")(function* (rows: ModelStatRow[]) {
  78. yield* Effect.forEach(
  79. chunks(rows, UPSERT_CHUNK_SIZE),
  80. (chunk) =>
  81. Effect.tryPromise({
  82. try: () =>
  83. db
  84. .insert(modelStat)
  85. .values(chunk)
  86. .onDuplicateKeyUpdate({
  87. set: {
  88. provider_model: inserted("provider_model"),
  89. sessions: inserted("sessions"),
  90. requests: inserted("requests"),
  91. input_tokens: inserted("input_tokens"),
  92. output_tokens: inserted("output_tokens"),
  93. reasoning_tokens: inserted("reasoning_tokens"),
  94. cache_read_tokens: inserted("cache_read_tokens"),
  95. total_tokens: inserted("total_tokens"),
  96. input_cost_microcents: inserted("input_cost_microcents"),
  97. output_cost_microcents: inserted("output_cost_microcents"),
  98. total_cost_microcents: inserted("total_cost_microcents"),
  99. avg_duration_ms: inserted("avg_duration_ms"),
  100. p50_duration_ms: inserted("p50_duration_ms"),
  101. p95_duration_ms: inserted("p95_duration_ms"),
  102. avg_ttfb_ms: inserted("avg_ttfb_ms"),
  103. p50_ttfb_ms: inserted("p50_ttfb_ms"),
  104. p95_ttfb_ms: inserted("p95_ttfb_ms"),
  105. avg_output_tps: inserted("avg_output_tps"),
  106. success_count: inserted("success_count"),
  107. error_count: inserted("error_count"),
  108. sample_count: inserted("sample_count"),
  109. rank_by_tokens: inserted("rank_by_tokens"),
  110. rank_by_requests: inserted("rank_by_requests"),
  111. rank_by_cost: inserted("rank_by_cost"),
  112. },
  113. }),
  114. catch: (cause) => DatabaseError.make({ cause }),
  115. }),
  116. { discard: true },
  117. )
  118. })
  119. const deleteRetiredDimensions = Effect.fn("ModelStatRepo.deleteRetiredDimensions")(function* (
  120. rows: ModelStatRow[],
  121. ) {
  122. const scope = statRowScope(rows)
  123. if (!scope) return
  124. yield* Effect.tryPromise({
  125. try: () =>
  126. db
  127. .delete(modelStat)
  128. .where(
  129. and(
  130. inArray(modelStat.grain, scope.grains),
  131. inArray(modelStat.period_key, scope.periodKeys),
  132. inArray(modelStat.dataset, scope.datasets),
  133. inArray(modelStat.client, scope.clients),
  134. inArray(modelStat.source, scope.sources),
  135. or(
  136. inArray(modelStat.provider, RETIRED_STAT_PROVIDERS),
  137. inArray(modelStat.model, RETIRED_STAT_MODELS),
  138. ),
  139. ),
  140. ),
  141. catch: (cause) => DatabaseError.make({ cause }),
  142. })
  143. })
  144. return ModelStatRepo.of({ listDaily, upsert, deleteRetiredDimensions })
  145. }),
  146. )
  147. }
  148. export function rowsFromAggregates(aggregates: ModelStatAggregate[]) {
  149. return rankRows([
  150. ...synthesizeAllTierRows(
  151. collapseRows(aggregates.filter((item) => item.grain === "week").map(toRow), dimensionKey),
  152. dimensionKey,
  153. ),
  154. ...synthesizeAllTierRows(
  155. collapseRows(aggregates.filter((item) => item.grain === "day").map(toRow), dimensionKey),
  156. dimensionKey,
  157. ),
  158. ])
  159. }
  160. function toRow(data: ModelStatAggregate): ModelStatRow {
  161. return {
  162. ...toStatBaseRow(data),
  163. provider: data.provider,
  164. model: data.model,
  165. provider_model: data.provider_model,
  166. }
  167. }
  168. function rankRows(rows: ModelStatRow[]) {
  169. return Object.values(
  170. rows.reduce<Record<string, ModelStatRow[]>>((result, row) => {
  171. const key = statPeriodKey(row)
  172. result[key] = [...(result[key] ?? []), row]
  173. return result
  174. }, {}),
  175. ).flatMap((group) => {
  176. const tokenRanks = rankBy(group, (row) => row.total_tokens ?? 0)
  177. const requestRanks = rankBy(group, (row) => row.requests ?? 0)
  178. const costRanks = rankBy(group, (row) => row.total_cost_microcents ?? 0)
  179. return group.map((row) => ({
  180. ...row,
  181. rank_by_tokens: tokenRanks.get(row) ?? null,
  182. rank_by_requests: requestRanks.get(row) ?? null,
  183. rank_by_cost: costRanks.get(row) ?? null,
  184. }))
  185. })
  186. }
  187. function dimensionKey(row: ModelStatRow) {
  188. return [row.provider, row.model].join("\u0000")
  189. }