agi.aichat_backend_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. package agi
  2. import (
  3. "io"
  4. "net/http"
  5. "net/http/httptest"
  6. "os"
  7. "path/filepath"
  8. "strings"
  9. "testing"
  10. "github.com/robertkrimen/otto"
  11. "imuslab.com/arozos/mod/agi/static"
  12. user "imuslab.com/arozos/mod/user"
  13. )
  14. /*
  15. Backend script tests for the AI Chat demo app (web/AIChat/backend/*.agi).
  16. These execute the real .agi scripts inside an otto VM with the real llm
  17. library injected (pointed at a mock OpenAI-compatible server), so the demo
  18. app's backend logic is verified without a running arozos server or a real
  19. model endpoint.
  20. */
  21. // runAIChatBackend loads a backend script, injects the llm lib + stubs for
  22. // requirelib/sendJSONResp, sets the given POST params and returns whatever the
  23. // script passed to sendJSONResp.
  24. func runAIChatBackend(t *testing.T, g *Gateway, scriptRelPath string, params map[string]string) string {
  25. t.Helper()
  26. vm := otto.New()
  27. g.injectLLMFunctions(&static.AgiLibInjectionPayload{VM: vm, User: &user.User{Username: "tester"}})
  28. //requirelib is a no-op here: the lib is already injected above.
  29. vm.Set("requirelib", func(call otto.FunctionCall) otto.Value {
  30. v, _ := vm.ToValue(true)
  31. return v
  32. })
  33. var captured string
  34. vm.Set("sendJSONResp", func(call otto.FunctionCall) otto.Value {
  35. captured, _ = call.Argument(0).ToString()
  36. return otto.UndefinedValue()
  37. })
  38. for k, v := range params {
  39. vm.Set(k, v)
  40. }
  41. scriptPath := filepath.Join("..", "..", "web", scriptRelPath)
  42. content, err := os.ReadFile(scriptPath)
  43. if err != nil {
  44. t.Fatalf("cannot read backend script %s: %v", scriptPath, err)
  45. }
  46. if _, err := vm.Run(string(content)); err != nil {
  47. t.Fatalf("backend script %s errored: %v", scriptRelPath, err)
  48. }
  49. return captured
  50. }
  51. func TestAIChatBackend_Chat(t *testing.T) {
  52. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  53. body, _ := io.ReadAll(r.Body)
  54. //The system prompt set via options must reach the endpoint.
  55. if !strings.Contains(string(body), "be a pirate") {
  56. t.Errorf("system prompt was not forwarded; body=%s", string(body))
  57. }
  58. w.Header().Set("Content-Type", "application/json")
  59. io.WriteString(w, `{"model":"test-model",
  60. "choices":[{"message":{"role":"assistant","content":"Arr, hello!"}}],
  61. "usage":{"prompt_tokens":12,"completion_tokens":4,"total_tokens":16}}`)
  62. }))
  63. defer srv.Close()
  64. g := dbGateway(t)
  65. sysdb := g.Option.UserHandler.GetDatabase()
  66. sysdb.Write(llmDBTable, "config", LLMConfig{Endpoint: srv.URL, DefaultModel: "test-model", Currency: "USD"})
  67. out := runAIChatBackend(t, g, "AIChat/backend/chat.agi", map[string]string{
  68. "messages": `[{"role":"user","content":"hi"}]`,
  69. "options": `{"model":"test-model","system":"be a pirate"}`,
  70. })
  71. if !strings.Contains(out, `"ok":true`) {
  72. t.Fatalf("expected ok:true, got: %s", out)
  73. }
  74. if !strings.Contains(out, "Arr, hello!") {
  75. t.Errorf("assistant content missing from response: %s", out)
  76. }
  77. if !strings.Contains(out, `"total_tokens":16`) {
  78. t.Errorf("usage missing from response: %s", out)
  79. }
  80. }
  81. func TestAIChatBackend_ChatSurfacesReasoning(t *testing.T) {
  82. //A reasoning model returns its chain-of-thought in reasoning_content; the
  83. //backend must forward it to the frontend as the "reasoning" field so the
  84. //UI can show it in a collapsible thinking section.
  85. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  86. w.Header().Set("Content-Type", "application/json")
  87. io.WriteString(w, `{"model":"reasoner",
  88. "choices":[{"message":{"role":"assistant","content":"4","reasoning_content":"2 plus 2 is 4."}}],
  89. "usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}`)
  90. }))
  91. defer srv.Close()
  92. g := dbGateway(t)
  93. sysdb := g.Option.UserHandler.GetDatabase()
  94. sysdb.Write(llmDBTable, "config", LLMConfig{Endpoint: srv.URL, DefaultModel: "reasoner", Currency: "USD"})
  95. out := runAIChatBackend(t, g, "AIChat/backend/chat.agi", map[string]string{
  96. "messages": `[{"role":"user","content":"2+2?"}]`,
  97. "options": `{"model":"reasoner"}`,
  98. })
  99. if !strings.Contains(out, `"ok":true`) {
  100. t.Fatalf("expected ok:true, got: %s", out)
  101. }
  102. if !strings.Contains(out, `"reasoning":"2 plus 2 is 4."`) {
  103. t.Errorf("reasoning was not surfaced in the response: %s", out)
  104. }
  105. }
  106. func TestAIChatBackend_ChatNoEndpointReturnsError(t *testing.T) {
  107. g := dbGateway(t) //no config written -> endpoint unset
  108. out := runAIChatBackend(t, g, "AIChat/backend/chat.agi", map[string]string{
  109. "messages": `[{"role":"user","content":"hi"}]`,
  110. "options": `{}`,
  111. })
  112. if !strings.Contains(out, `"ok":false`) {
  113. t.Fatalf("expected ok:false when endpoint missing, got: %s", out)
  114. }
  115. if !strings.Contains(strings.ToLower(out), "endpoint") {
  116. t.Errorf("expected an endpoint-related error message, got: %s", out)
  117. }
  118. }
  119. // runAIChatStreamBackend runs the streaming backend script with the llm lib
  120. // injected and a stubbed websocket object: the first read() yields reqJSON,
  121. // send() captures every outgoing frame, and exit()/close() end the script.
  122. // Returns the ordered list of JSON frames the script sent to the "client".
  123. func runAIChatStreamBackend(t *testing.T, g *Gateway, reqJSON string) []string {
  124. t.Helper()
  125. vm := otto.New()
  126. g.injectLLMFunctions(&static.AgiLibInjectionPayload{VM: vm, User: &user.User{Username: "tester"}})
  127. vm.Set("requirelib", func(call otto.FunctionCall) otto.Value {
  128. v, _ := vm.ToValue(true)
  129. return v
  130. })
  131. vm.Set("sendResp", func(call otto.FunctionCall) otto.Value { return otto.UndefinedValue() })
  132. vm.Set("exit", func(call otto.FunctionCall) otto.Value {
  133. panic(vm.MakeCustomError("AGIExit", "exit"))
  134. })
  135. var frames []string
  136. readCount := 0
  137. vm.Set("_ws_upgrade", func(call otto.FunctionCall) otto.Value { return otto.TrueValue() })
  138. vm.Set("_ws_read", func(call otto.FunctionCall) otto.Value {
  139. readCount++
  140. if readCount == 1 {
  141. v, _ := vm.ToValue(reqJSON)
  142. return v
  143. }
  144. return otto.FalseValue()
  145. })
  146. vm.Set("_ws_send", func(call otto.FunctionCall) otto.Value {
  147. s, _ := call.Argument(0).ToString()
  148. frames = append(frames, s)
  149. return otto.TrueValue()
  150. })
  151. vm.Set("_ws_close", func(call otto.FunctionCall) otto.Value { return otto.TrueValue() })
  152. vm.Run(`var websocket = { upgrade:_ws_upgrade, read:_ws_read, send:_ws_send, close:_ws_close, isClosed:function(){return false;} };`)
  153. scriptPath := filepath.Join("..", "..", "web", "AIChat/backend/chat_stream.agi")
  154. content, err := os.ReadFile(scriptPath)
  155. if err != nil {
  156. t.Fatalf("cannot read chat_stream.agi: %v", err)
  157. }
  158. if _, err := vm.Run(string(content)); err != nil && !strings.Contains(err.Error(), "exit") {
  159. t.Fatalf("chat_stream.agi errored: %v", err)
  160. }
  161. return frames
  162. }
  163. func TestAIChatBackend_Stream(t *testing.T) {
  164. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  165. body, _ := io.ReadAll(r.Body)
  166. if !strings.Contains(string(body), `"stream":true`) {
  167. t.Errorf("streaming backend did not request a stream; body=%s", string(body))
  168. }
  169. w.Header().Set("Content-Type", "text/event-stream")
  170. io.WriteString(w, "data: {\"model\":\"m\",\"choices\":[{\"delta\":{\"reasoning_content\":\"pondering\"}}]}\n\n")
  171. io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"Hi \"}}]}\n\n")
  172. io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"there\"},\"finish_reason\":\"stop\"}]}\n\n")
  173. io.WriteString(w, "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":2,\"total_tokens\":5}}\n\n")
  174. io.WriteString(w, "data: [DONE]\n\n")
  175. }))
  176. defer srv.Close()
  177. g := dbGateway(t)
  178. g.Option.UserHandler.GetDatabase().Write(llmDBTable, "config", LLMConfig{Endpoint: srv.URL, DefaultModel: "m", Currency: "USD"})
  179. frames := runAIChatStreamBackend(t, g, `{"messages":[{"role":"user","content":"hi"}],"options":{"model":"m"}}`)
  180. joined := strings.Join(frames, "\n")
  181. if !strings.Contains(joined, `"type":"start"`) {
  182. t.Errorf("missing start frame; frames=%v", frames)
  183. }
  184. if !strings.Contains(joined, `"type":"reasoning"`) || !strings.Contains(joined, "pondering") {
  185. t.Errorf("reasoning was not streamed; frames=%v", frames)
  186. }
  187. if !strings.Contains(joined, `"type":"delta"`) || !strings.Contains(joined, "there") {
  188. t.Errorf("answer deltas were not streamed; frames=%v", frames)
  189. }
  190. //The terminal "done" frame carries the fully assembled reply + usage.
  191. last := frames[len(frames)-1]
  192. if !strings.Contains(last, `"type":"done"`) {
  193. t.Fatalf("last frame should be done, got: %s", last)
  194. }
  195. if !strings.Contains(last, `"content":"Hi there"`) {
  196. t.Errorf("done frame missing assembled content: %s", last)
  197. }
  198. if !strings.Contains(last, `"total_tokens":5`) {
  199. t.Errorf("done frame missing usage: %s", last)
  200. }
  201. }
  202. func TestAIChatBackend_StreamRecordsUsage(t *testing.T) {
  203. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  204. w.Header().Set("Content-Type", "text/event-stream")
  205. io.WriteString(w, "data: {\"model\":\"m\",\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\n")
  206. io.WriteString(w, "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":8,\"completion_tokens\":4,\"total_tokens\":12}}\n\n")
  207. io.WriteString(w, "data: [DONE]\n\n")
  208. }))
  209. defer srv.Close()
  210. g := dbGateway(t)
  211. g.Option.UserHandler.GetDatabase().Write(llmDBTable, "config", LLMConfig{Endpoint: srv.URL, DefaultModel: "m", Currency: "USD"})
  212. runAIChatStreamBackend(t, g, `{"messages":[{"role":"user","content":"hi"}],"options":{"model":"m"}}`)
  213. //Streaming must feed the same metrics board as the blocking path.
  214. m := g.getLLMMetrics()
  215. if m.TotalRequests != 1 || m.TotalTokens != 12 {
  216. t.Errorf("streaming usage not recorded in metrics: %+v", m)
  217. }
  218. }
  219. func TestAIChatBackend_Models(t *testing.T) {
  220. g := dbGateway(t)
  221. sysdb := g.Option.UserHandler.GetDatabase()
  222. sysdb.Write(llmDBTable, "config", LLMConfig{DefaultModel: "test-model", Currency: "USD"})
  223. sysdb.Write(llmDBTable, "pricing", map[string]LLMPricing{
  224. "test-model": {InputPrice: 1, OutputPrice: 2},
  225. "other": {InputPrice: 3, OutputPrice: 4},
  226. })
  227. out := runAIChatBackend(t, g, "AIChat/backend/models.agi", map[string]string{})
  228. if !strings.Contains(out, `"default":"test-model"`) {
  229. t.Errorf("default model missing: %s", out)
  230. }
  231. if !strings.Contains(out, "test-model") || !strings.Contains(out, "other") {
  232. t.Errorf("configured models missing: %s", out)
  233. }
  234. }