- internal/aiapm/types.go: AICallRecord, filter, summary, and stats types - internal/aiapm/pricing.go: vendor pricing tables (Anthropic, OpenAI, Google, Mistral, DeepSeek, Groq) - internal/aiapm/store.go: PostgreSQL storage with batch insert, filtered queries, aggregations, timeseries - internal/aiapm/collector.go: async collector with buffered channel and background batch writer - internal/api/aiapm_handlers.go: Fiber route handlers for ingest, summary, models, vendors, costs, calls, pricing - cmd/server/main.go: register AI APM routes and create ai_calls table at startup
143 lines
3.8 KiB
Go
143 lines
3.8 KiB
Go
package api
|
|
|
|
import (
|
|
"database/sql"
|
|
"time"
|
|
|
|
"github.com/bigtux/ophion/internal/aiapm"
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
// AIAPMHandlers holds dependencies for AI APM route handlers
|
|
type AIAPMHandlers struct {
|
|
db *sql.DB
|
|
collector *aiapm.Collector
|
|
}
|
|
|
|
// RegisterAIAPMRoutes registers all AI APM routes on the given router
|
|
func RegisterAIAPMRoutes(api fiber.Router, db *sql.DB) *aiapm.Collector {
|
|
collector := aiapm.NewCollector(db, 5000)
|
|
h := &AIAPMHandlers{db: db, collector: collector}
|
|
|
|
g := api.Group("/ai-apm")
|
|
g.Post("/ingest", h.Ingest)
|
|
g.Get("/summary", h.Summary)
|
|
g.Get("/models", h.Models)
|
|
g.Get("/vendors", h.Vendors)
|
|
g.Get("/costs", h.Costs)
|
|
g.Get("/calls", h.Calls)
|
|
g.Get("/pricing", h.Pricing)
|
|
|
|
return collector
|
|
}
|
|
|
|
// Ingest receives AI call records (single or batch)
|
|
func (h *AIAPMHandlers) Ingest(c *fiber.Ctx) error {
|
|
var req aiapm.IngestRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "invalid request body: " + err.Error()})
|
|
}
|
|
|
|
count := 0
|
|
if req.Call != nil {
|
|
h.collector.Collect(*req.Call)
|
|
count = 1
|
|
}
|
|
if len(req.Calls) > 0 {
|
|
h.collector.CollectBatch(req.Calls)
|
|
count += len(req.Calls)
|
|
}
|
|
|
|
if count == 0 {
|
|
return c.Status(400).JSON(fiber.Map{"error": "no call records provided; use 'call' or 'calls' field"})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"status": "accepted", "count": count})
|
|
}
|
|
|
|
// parseFilter extracts common filter parameters from query string
|
|
func parseFilter(c *fiber.Ctx) aiapm.AICallFilter {
|
|
f := aiapm.AICallFilter{
|
|
ServiceName: c.Query("service"),
|
|
ProjectID: c.Query("project"),
|
|
Vendor: c.Query("vendor"),
|
|
Model: c.Query("model"),
|
|
Status: c.Query("status"),
|
|
}
|
|
if from := c.Query("from"); from != "" {
|
|
if t, err := time.Parse(time.RFC3339, from); err == nil {
|
|
f.From = t
|
|
}
|
|
}
|
|
if to := c.Query("to"); to != "" {
|
|
if t, err := time.Parse(time.RFC3339, to); err == nil {
|
|
f.To = t
|
|
}
|
|
}
|
|
if f.From.IsZero() {
|
|
f.From = time.Now().Add(-24 * time.Hour)
|
|
}
|
|
if f.To.IsZero() {
|
|
f.To = time.Now()
|
|
}
|
|
f.Limit = c.QueryInt("limit", 100)
|
|
f.Offset = c.QueryInt("offset", 0)
|
|
return f
|
|
}
|
|
|
|
// Summary returns aggregated usage statistics
|
|
func (h *AIAPMHandlers) Summary(c *fiber.Ctx) error {
|
|
filter := parseFilter(c)
|
|
summary, err := aiapm.GetUsageSummary(h.db, filter)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(summary)
|
|
}
|
|
|
|
// Models returns per-model statistics
|
|
func (h *AIAPMHandlers) Models(c *fiber.Ctx) error {
|
|
filter := parseFilter(c)
|
|
stats, err := aiapm.GetModelStats(h.db, filter)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"models": stats})
|
|
}
|
|
|
|
// Vendors returns per-vendor statistics
|
|
func (h *AIAPMHandlers) Vendors(c *fiber.Ctx) error {
|
|
filter := parseFilter(c)
|
|
stats, err := aiapm.GetVendorStats(h.db, filter)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"vendors": stats})
|
|
}
|
|
|
|
// Costs returns cost timeseries data
|
|
func (h *AIAPMHandlers) Costs(c *fiber.Ctx) error {
|
|
filter := parseFilter(c)
|
|
interval := c.Query("interval", "1d")
|
|
points, err := aiapm.GetCostTimeseries(h.db, filter, interval)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"timeseries": points, "interval": interval})
|
|
}
|
|
|
|
// Calls returns recent AI call records (paginated)
|
|
func (h *AIAPMHandlers) Calls(c *fiber.Ctx) error {
|
|
filter := parseFilter(c)
|
|
calls, err := aiapm.QueryCalls(h.db, filter)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"calls": calls, "count": len(calls)})
|
|
}
|
|
|
|
// Pricing returns the current pricing table
|
|
func (h *AIAPMHandlers) Pricing(c *fiber.Ctx) error {
|
|
return c.JSON(fiber.Map{"pricing": aiapm.GetPricingTable()})
|
|
}
|