agi.cnn.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  1. package agi
  2. import (
  3. "encoding/json"
  4. "errors"
  5. "fmt"
  6. "mime"
  7. "net/http"
  8. "os"
  9. "path/filepath"
  10. "strconv"
  11. "strings"
  12. "time"
  13. "github.com/robertkrimen/otto"
  14. "imuslab.com/arozos/mod/agi/static"
  15. cnn "imuslab.com/arozos/mod/aiservers/cnn"
  16. "imuslab.com/arozos/mod/filesystem"
  17. user "imuslab.com/arozos/mod/user"
  18. "imuslab.com/arozos/mod/utils"
  19. )
  20. /*
  21. AJGI CNN Inference Library
  22. This library lets AGI scripts run image classification, object detection,
  23. segmentation, pose, oriented (OBB) detection and face analysis (detection,
  24. landmarks, embedding, comparison, attributes) against an external CXNNAIO
  25. vision-inference server. The transport/wire-format logic lives in the
  26. standalone mod/aiservers/cnn client package; this file only owns the
  27. ArozOS-specific bits: admin-configured connection settings (System
  28. Settings > AI Integration > CNN Inference) and the Otto VM bindings.
  29. Author: tobychui (AGI), CNN Inference lib addition
  30. */
  31. const (
  32. //cnnDBTable is the system database table used to persist the CNN server
  33. //connection settings.
  34. cnnDBTable = "cnnserver"
  35. //cnnTokenMask is the sentinel value the frontend submits when the token
  36. //field was left untouched. When received, the stored token is kept.
  37. cnnTokenMask = "********"
  38. //cnnDefaultTimeoutSeconds is used when no timeout has been configured.
  39. cnnDefaultTimeoutSeconds = 60
  40. )
  41. // CNNServerConfig holds the admin-configured connection settings for the
  42. // external CXNNAIO vision-inference server.
  43. type CNNServerConfig struct {
  44. Endpoint string `json:"endpoint"` //Base URL, e.g. http://localhost:8080
  45. Token string `json:"token"` //Bearer token; empty for a server running in no_auth mode
  46. TimeoutSeconds int `json:"timeoutSeconds"` //Per-request client timeout
  47. }
  48. // ── Library registration ─────────────────────────────────────────────────────
  49. func (g *Gateway) CNNLibRegister() {
  50. //Make sure the storage table exists before any read / write happens.
  51. sysdb := g.Option.UserHandler.GetDatabase()
  52. if !sysdb.TableExists(cnnDBTable) {
  53. sysdb.NewTable(cnnDBTable)
  54. }
  55. err := g.RegisterLib("cnn", g.injectCNNFunctions)
  56. if err != nil {
  57. agiLogger.PrintAndLog("Agi", fmt.Sprint(err), nil)
  58. os.Exit(1)
  59. }
  60. }
  61. func (g *Gateway) injectCNNFunctions(payload *static.AgiLibInjectionPayload) {
  62. vm := payload.VM
  63. u := payload.User
  64. scriptFsh := payload.ScriptFsh
  65. //cnn.classify(file, options) => image.classification envelope
  66. vm.Set("_cnn_classify", func(call otto.FunctionCall) otto.Value {
  67. data, mimeType, err := g.cnnReadImage(scriptFsh, vm, u, getOttoStringArg(call, 0))
  68. if err != nil {
  69. panic(vm.MakeCustomError("CNNError", err.Error()))
  70. }
  71. opt := parseCNNOptions(getOttoStringArg(call, 1))
  72. client, err := g.cnnClient()
  73. if err != nil {
  74. panic(vm.MakeCustomError("CNNError", err.Error()))
  75. }
  76. result, job, err := client.Classify(data, mimeType, opt)
  77. return cnnRespond(vm, result, job, err)
  78. })
  79. //cnn.detect(file, options) => image.detection envelope
  80. vm.Set("_cnn_detect", func(call otto.FunctionCall) otto.Value {
  81. data, mimeType, err := g.cnnReadImage(scriptFsh, vm, u, getOttoStringArg(call, 0))
  82. if err != nil {
  83. panic(vm.MakeCustomError("CNNError", err.Error()))
  84. }
  85. opt := parseCNNOptions(getOttoStringArg(call, 1))
  86. client, err := g.cnnClient()
  87. if err != nil {
  88. panic(vm.MakeCustomError("CNNError", err.Error()))
  89. }
  90. result, job, err := client.Detect(data, mimeType, opt)
  91. return cnnRespond(vm, result, job, err)
  92. })
  93. //cnn.segment(file, options) => image.segmentation envelope
  94. vm.Set("_cnn_segment", func(call otto.FunctionCall) otto.Value {
  95. data, mimeType, err := g.cnnReadImage(scriptFsh, vm, u, getOttoStringArg(call, 0))
  96. if err != nil {
  97. panic(vm.MakeCustomError("CNNError", err.Error()))
  98. }
  99. opt := parseCNNOptions(getOttoStringArg(call, 1))
  100. client, err := g.cnnClient()
  101. if err != nil {
  102. panic(vm.MakeCustomError("CNNError", err.Error()))
  103. }
  104. result, job, err := client.Segment(data, mimeType, opt)
  105. return cnnRespond(vm, result, job, err)
  106. })
  107. //cnn.pose(file, options) => image.pose envelope
  108. vm.Set("_cnn_pose", func(call otto.FunctionCall) otto.Value {
  109. data, mimeType, err := g.cnnReadImage(scriptFsh, vm, u, getOttoStringArg(call, 0))
  110. if err != nil {
  111. panic(vm.MakeCustomError("CNNError", err.Error()))
  112. }
  113. opt := parseCNNOptions(getOttoStringArg(call, 1))
  114. client, err := g.cnnClient()
  115. if err != nil {
  116. panic(vm.MakeCustomError("CNNError", err.Error()))
  117. }
  118. result, job, err := client.Pose(data, mimeType, opt)
  119. return cnnRespond(vm, result, job, err)
  120. })
  121. //cnn.oriented(file, options) => image.oriented envelope
  122. vm.Set("_cnn_oriented", func(call otto.FunctionCall) otto.Value {
  123. data, mimeType, err := g.cnnReadImage(scriptFsh, vm, u, getOttoStringArg(call, 0))
  124. if err != nil {
  125. panic(vm.MakeCustomError("CNNError", err.Error()))
  126. }
  127. opt := parseCNNOptions(getOttoStringArg(call, 1))
  128. client, err := g.cnnClient()
  129. if err != nil {
  130. panic(vm.MakeCustomError("CNNError", err.Error()))
  131. }
  132. result, job, err := client.Oriented(data, mimeType, opt)
  133. return cnnRespond(vm, result, job, err)
  134. })
  135. //cnn.faceDetect(file, options) => face.detection envelope
  136. vm.Set("_cnn_faceDetect", func(call otto.FunctionCall) otto.Value {
  137. data, mimeType, err := g.cnnReadImage(scriptFsh, vm, u, getOttoStringArg(call, 0))
  138. if err != nil {
  139. panic(vm.MakeCustomError("CNNError", err.Error()))
  140. }
  141. opt := parseCNNOptions(getOttoStringArg(call, 1))
  142. client, err := g.cnnClient()
  143. if err != nil {
  144. panic(vm.MakeCustomError("CNNError", err.Error()))
  145. }
  146. result, job, err := client.FaceDetect(data, mimeType, opt)
  147. return cnnRespond(vm, result, job, err)
  148. })
  149. //cnn.faceLandmarks(file, options) => face.landmarks envelope
  150. vm.Set("_cnn_faceLandmarks", func(call otto.FunctionCall) otto.Value {
  151. data, mimeType, err := g.cnnReadImage(scriptFsh, vm, u, getOttoStringArg(call, 0))
  152. if err != nil {
  153. panic(vm.MakeCustomError("CNNError", err.Error()))
  154. }
  155. opt := parseCNNOptions(getOttoStringArg(call, 1))
  156. client, err := g.cnnClient()
  157. if err != nil {
  158. panic(vm.MakeCustomError("CNNError", err.Error()))
  159. }
  160. result, job, err := client.FaceLandmarks(data, mimeType, opt)
  161. return cnnRespond(vm, result, job, err)
  162. })
  163. //cnn.faceEmbedding(file, options) => face.embedding envelope
  164. vm.Set("_cnn_faceEmbedding", func(call otto.FunctionCall) otto.Value {
  165. data, mimeType, err := g.cnnReadImage(scriptFsh, vm, u, getOttoStringArg(call, 0))
  166. if err != nil {
  167. panic(vm.MakeCustomError("CNNError", err.Error()))
  168. }
  169. opt := parseCNNOptions(getOttoStringArg(call, 1))
  170. client, err := g.cnnClient()
  171. if err != nil {
  172. panic(vm.MakeCustomError("CNNError", err.Error()))
  173. }
  174. result, job, err := client.FaceEmbedding(data, mimeType, opt)
  175. return cnnRespond(vm, result, job, err)
  176. })
  177. //cnn.faceAttributes(file, options) => face.gender envelope (see FaceAttributes doc)
  178. vm.Set("_cnn_faceAttributes", func(call otto.FunctionCall) otto.Value {
  179. data, mimeType, err := g.cnnReadImage(scriptFsh, vm, u, getOttoStringArg(call, 0))
  180. if err != nil {
  181. panic(vm.MakeCustomError("CNNError", err.Error()))
  182. }
  183. opt := parseCNNOptions(getOttoStringArg(call, 1))
  184. client, err := g.cnnClient()
  185. if err != nil {
  186. panic(vm.MakeCustomError("CNNError", err.Error()))
  187. }
  188. result, job, err := client.FaceAttributes(data, mimeType, opt)
  189. return cnnRespond(vm, result, job, err)
  190. })
  191. //cnn.faceCompare(fileA, fileB, options) => face.comparison object (no async support)
  192. vm.Set("_cnn_faceCompare", func(call otto.FunctionCall) otto.Value {
  193. dataA, mimeA, err := g.cnnReadImage(scriptFsh, vm, u, getOttoStringArg(call, 0))
  194. if err != nil {
  195. panic(vm.MakeCustomError("CNNError", err.Error()))
  196. }
  197. dataB, mimeB, err := g.cnnReadImage(scriptFsh, vm, u, getOttoStringArg(call, 1))
  198. if err != nil {
  199. panic(vm.MakeCustomError("CNNError", err.Error()))
  200. }
  201. opt := parseCNNComparisonOptions(getOttoStringArg(call, 2))
  202. client, err := g.cnnClient()
  203. if err != nil {
  204. panic(vm.MakeCustomError("CNNError", err.Error()))
  205. }
  206. result, err := client.FaceCompare(dataA, dataB, mimeA, mimeB, opt)
  207. return cnnRespond(vm, result, nil, err)
  208. })
  209. //cnn.analyze(file, tasks, options) => vision.analysis envelope
  210. //options may carry top-level "render"/"async" flags plus a per-task
  211. //options block keyed by task name (e.g. { detect: {...}, render: true }).
  212. vm.Set("_cnn_analyze", func(call otto.FunctionCall) otto.Value {
  213. data, mimeType, err := g.cnnReadImage(scriptFsh, vm, u, getOttoStringArg(call, 0))
  214. if err != nil {
  215. panic(vm.MakeCustomError("CNNError", err.Error()))
  216. }
  217. var tasks []string
  218. if err := json.Unmarshal([]byte(getOttoStringArg(call, 1)), &tasks); err != nil || len(tasks) == 0 {
  219. panic(vm.MakeCustomError("CNNError", "no tasks specified"))
  220. }
  221. raw := map[string]json.RawMessage{}
  222. json.Unmarshal([]byte(getOttoStringArg(call, 2)), &raw)
  223. opt := cnn.AnalyzeOptions{Tasks: tasks, Options: map[string]json.RawMessage{}}
  224. for k, v := range raw {
  225. switch k {
  226. case "render":
  227. json.Unmarshal(v, &opt.Render)
  228. case "async":
  229. json.Unmarshal(v, &opt.Async)
  230. default:
  231. opt.Options[k] = v
  232. }
  233. }
  234. client, err := g.cnnClient()
  235. if err != nil {
  236. panic(vm.MakeCustomError("CNNError", err.Error()))
  237. }
  238. result, job, err := client.Analyze(data, mimeType, opt)
  239. return cnnRespond(vm, result, job, err)
  240. })
  241. //cnn.job(id) => poll an async job submitted with options.async = true
  242. vm.Set("_cnn_job", func(call otto.FunctionCall) otto.Value {
  243. id, _ := call.Argument(0).ToString()
  244. client, err := g.cnnClient()
  245. if err != nil {
  246. panic(vm.MakeCustomError("CNNError", err.Error()))
  247. }
  248. job, err := client.GetJob(id)
  249. return cnnRespond(vm, job, nil, err)
  250. })
  251. //cnn.models() => live model registry from the configured server
  252. vm.Set("_cnn_models", func(call otto.FunctionCall) otto.Value {
  253. client, err := g.cnnClient()
  254. if err != nil {
  255. panic(vm.MakeCustomError("CNNError", err.Error()))
  256. }
  257. models, err := client.ListModels()
  258. return cnnRespond(vm, models, nil, err)
  259. })
  260. //cnn.health() => live health/status from the configured server
  261. vm.Set("_cnn_health", func(call otto.FunctionCall) otto.Value {
  262. client, err := g.cnnClient()
  263. if err != nil {
  264. panic(vm.MakeCustomError("CNNError", err.Error()))
  265. }
  266. health, err := client.Health()
  267. return cnnRespond(vm, health, nil, err)
  268. })
  269. //Wrap the native functions into a clean cnn class
  270. vm.Run(`
  271. var cnn = {};
  272. cnn.classify = function(file, options){
  273. return JSON.parse(_cnn_classify(file, JSON.stringify(options || {})));
  274. };
  275. cnn.detect = function(file, options){
  276. return JSON.parse(_cnn_detect(file, JSON.stringify(options || {})));
  277. };
  278. cnn.segment = function(file, options){
  279. return JSON.parse(_cnn_segment(file, JSON.stringify(options || {})));
  280. };
  281. cnn.pose = function(file, options){
  282. return JSON.parse(_cnn_pose(file, JSON.stringify(options || {})));
  283. };
  284. cnn.oriented = function(file, options){
  285. return JSON.parse(_cnn_oriented(file, JSON.stringify(options || {})));
  286. };
  287. cnn.faceDetect = function(file, options){
  288. return JSON.parse(_cnn_faceDetect(file, JSON.stringify(options || {})));
  289. };
  290. cnn.faceLandmarks = function(file, options){
  291. return JSON.parse(_cnn_faceLandmarks(file, JSON.stringify(options || {})));
  292. };
  293. cnn.faceEmbedding = function(file, options){
  294. return JSON.parse(_cnn_faceEmbedding(file, JSON.stringify(options || {})));
  295. };
  296. cnn.faceAttributes = function(file, options){
  297. return JSON.parse(_cnn_faceAttributes(file, JSON.stringify(options || {})));
  298. };
  299. cnn.faceCompare = function(fileA, fileB, options){
  300. return JSON.parse(_cnn_faceCompare(fileA, fileB, JSON.stringify(options || {})));
  301. };
  302. cnn.analyze = function(file, tasks, options){
  303. return JSON.parse(_cnn_analyze(file, JSON.stringify(tasks || []), JSON.stringify(options || {})));
  304. };
  305. cnn.job = function(id){
  306. return JSON.parse(_cnn_job(id));
  307. };
  308. cnn.models = function(){
  309. return JSON.parse(_cnn_models());
  310. };
  311. cnn.health = function(){
  312. return JSON.parse(_cnn_health());
  313. };
  314. `)
  315. }
  316. // ── Core helpers ──────────────────────────────────────────────────────────────
  317. // cnnClient builds a cnn.Client from the persisted configuration.
  318. func (g *Gateway) cnnClient() (*cnn.Client, error) {
  319. cfg := g.getCNNConfig()
  320. if strings.TrimSpace(cfg.Endpoint) == "" {
  321. return nil, errors.New("CNN inference server is not configured (System Settings > AI Integration > CNN Inference)")
  322. }
  323. return cnn.NewClient(cfg.Endpoint, cfg.Token, time.Duration(cfg.TimeoutSeconds)*time.Second), nil
  324. }
  325. // cnnReadImage resolves a script vpath to its raw bytes and a best-effort
  326. // mime type, enforcing the calling user's read permission.
  327. func (g *Gateway) cnnReadImage(scriptFsh *filesystem.FileSystemHandler, vm *otto.Otto, u *user.User, vpath string) ([]byte, string, error) {
  328. //Resolve relative paths against the script's directory
  329. vpath = static.RelativeVpathRewrite(scriptFsh, vpath, vm, u)
  330. if !u.CanRead(vpath) {
  331. return nil, "", errors.New("permission denied: " + vpath)
  332. }
  333. fsh, rpath, err := static.VirtualPathToRealPath(vpath, u)
  334. if err != nil {
  335. return nil, "", err
  336. }
  337. if !fsh.FileSystemAbstraction.FileExists(rpath) {
  338. return nil, "", errors.New("file not found: " + vpath)
  339. }
  340. content, err := fsh.FileSystemAbstraction.ReadFile(rpath)
  341. if err != nil {
  342. return nil, "", err
  343. }
  344. ext := strings.ToLower(filepath.Ext(rpath))
  345. if !cnnIsImageExt(ext) {
  346. return nil, "", errors.New("unsupported file type for CNN inference: " + filepath.Base(rpath) + " (expected an image)")
  347. }
  348. mimeType := mime.TypeByExtension(ext)
  349. if mimeType == "" {
  350. mimeType = "image/" + strings.TrimPrefix(ext, ".")
  351. }
  352. return content, mimeType, nil
  353. }
  354. // cnnRespond converts a client call's (result, job, err) trio into the otto
  355. // value returned to the script: an error panics, an async submission returns
  356. // the job object, otherwise the typed result is marshalled back as-is so the
  357. // script receives the exact server envelope shape.
  358. func cnnRespond(vm *otto.Otto, result interface{}, job *cnn.Job, err error) otto.Value {
  359. if err != nil {
  360. panic(vm.MakeCustomError("CNNError", err.Error()))
  361. }
  362. var out []byte
  363. if job != nil {
  364. out, _ = json.Marshal(job)
  365. } else {
  366. out, _ = json.Marshal(result)
  367. }
  368. reply, _ := vm.ToValue(string(out))
  369. return reply
  370. }
  371. func cnnIsImageExt(ext string) bool {
  372. switch ext {
  373. case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp":
  374. return true
  375. }
  376. return false
  377. }
  378. func parseCNNOptions(s string) cnn.RequestOptions {
  379. opt := cnn.RequestOptions{}
  380. s = strings.TrimSpace(s)
  381. if s == "" || s == "undefined" || s == "null" {
  382. return opt
  383. }
  384. json.Unmarshal([]byte(s), &opt)
  385. return opt
  386. }
  387. func parseCNNComparisonOptions(s string) cnn.ComparisonOptions {
  388. opt := cnn.ComparisonOptions{}
  389. s = strings.TrimSpace(s)
  390. if s == "" || s == "undefined" || s == "null" {
  391. return opt
  392. }
  393. json.Unmarshal([]byte(s), &opt)
  394. return opt
  395. }
  396. // ── Persistence helpers ───────────────────────────────────────────────────────
  397. func (g *Gateway) getCNNConfig() CNNServerConfig {
  398. cfg := CNNServerConfig{TimeoutSeconds: cnnDefaultTimeoutSeconds}
  399. sysdb := g.Option.UserHandler.GetDatabase()
  400. if sysdb.KeyExists(cnnDBTable, "config") {
  401. sysdb.Read(cnnDBTable, "config", &cfg)
  402. if cfg.TimeoutSeconds <= 0 {
  403. cfg.TimeoutSeconds = cnnDefaultTimeoutSeconds
  404. }
  405. }
  406. return cfg
  407. }
  408. func cnnMaskToken(token string) string {
  409. if token == "" {
  410. return ""
  411. }
  412. if len(token) <= 4 {
  413. return strings.Repeat("•", len(token))
  414. }
  415. return "••••" + token[len(token)-4:]
  416. }
  417. // ── HTTP handlers (System Settings) ──────────────────────────────────────────
  418. // HandleCNNConfig serves GET (masked config) and POST (save config).
  419. // GET /system/cnn/config
  420. // POST /system/cnn/config (endpoint, timeoutSeconds, token, cleartoken)
  421. func (g *Gateway) HandleCNNConfig(w http.ResponseWriter, r *http.Request) {
  422. if r.Method == http.MethodGet {
  423. cfg := g.getCNNConfig()
  424. js, _ := json.Marshal(map[string]interface{}{
  425. "endpoint": cfg.Endpoint,
  426. "timeoutSeconds": cfg.TimeoutSeconds,
  427. "hasToken": cfg.Token != "",
  428. "tokenHint": cnnMaskToken(cfg.Token),
  429. })
  430. utils.SendJSONResponse(w, string(js))
  431. return
  432. }
  433. //POST - save. Read raw form values so an empty endpoint can intentionally
  434. //clear the configuration.
  435. r.ParseForm()
  436. cfg := g.getCNNConfig()
  437. cfg.Endpoint = strings.TrimSpace(r.Form.Get("endpoint"))
  438. if t, err := strconv.Atoi(strings.TrimSpace(r.Form.Get("timeoutSeconds"))); err == nil && t > 0 {
  439. cfg.TimeoutSeconds = t
  440. }
  441. //Token: only overwrite when a new, non-sentinel value is supplied.
  442. if clear, _ := utils.PostBool(r, "cleartoken"); clear {
  443. cfg.Token = ""
  444. } else if token := r.Form.Get("token"); token != "" && token != cnnTokenMask {
  445. cfg.Token = token
  446. }
  447. sysdb := g.Option.UserHandler.GetDatabase()
  448. if err := sysdb.Write(cnnDBTable, "config", cfg); err != nil {
  449. utils.SendErrorResponse(w, "failed to save config: "+err.Error())
  450. return
  451. }
  452. utils.SendOK(w)
  453. }
  454. // HandleCNNTest performs a connectivity check against the CXNNAIO server:
  455. // health status plus the live model registry. Accepts optional unsaved
  456. // endpoint/token overrides so the admin can test before saving.
  457. // POST /system/cnn/test
  458. func (g *Gateway) HandleCNNTest(w http.ResponseWriter, r *http.Request) {
  459. cfg := g.getCNNConfig()
  460. endpoint := cfg.Endpoint
  461. token := cfg.Token
  462. timeoutSeconds := cfg.TimeoutSeconds
  463. if ep := strings.TrimSpace(r.FormValue("endpoint")); ep != "" {
  464. endpoint = ep
  465. }
  466. if tk := r.FormValue("token"); tk != "" && tk != cnnTokenMask {
  467. token = tk
  468. }
  469. if strings.TrimSpace(endpoint) == "" {
  470. utils.SendErrorResponse(w, "endpoint not configured")
  471. return
  472. }
  473. client := cnn.NewClient(endpoint, token, time.Duration(timeoutSeconds)*time.Second)
  474. health, err := client.Health()
  475. if err != nil {
  476. utils.SendErrorResponse(w, err.Error())
  477. return
  478. }
  479. models, err := client.ListModels()
  480. if err != nil {
  481. utils.SendErrorResponse(w, err.Error())
  482. return
  483. }
  484. out, _ := json.Marshal(map[string]interface{}{
  485. "ok": true,
  486. "status": health.Status,
  487. "version": health.Version,
  488. "modelsLoaded": health.ModelsLoaded,
  489. "sessions": health.Sessions,
  490. "uptimeS": health.UptimeS,
  491. "modelCount": len(models.Data),
  492. "models": models.Data,
  493. })
  494. utils.SendJSONResponse(w, string(out))
  495. }