agi.llm_test.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. package agi
  2. import (
  3. "encoding/json"
  4. "io"
  5. "net/http"
  6. "net/http/httptest"
  7. "net/url"
  8. "path/filepath"
  9. "strings"
  10. "testing"
  11. "time"
  12. "github.com/robertkrimen/otto"
  13. "imuslab.com/arozos/mod/agi/static"
  14. llm "imuslab.com/arozos/mod/aiservers/llm"
  15. database "imuslab.com/arozos/mod/database"
  16. user "imuslab.com/arozos/mod/user"
  17. )
  18. // dbGateway returns a Gateway backed by a throwaway bolt database so the
  19. // config / pricing / metrics persistence paths can be exercised in tests.
  20. // Shared by every *_test.go file in this package (llm, cnn, ...).
  21. func dbGateway(t *testing.T) *Gateway {
  22. t.Helper()
  23. dbfile := filepath.Join(t.TempDir(), "test.db")
  24. sysdb, err := database.NewDatabase(dbfile, false)
  25. if err != nil {
  26. t.Fatalf("failed to create test database: %v", err)
  27. }
  28. t.Cleanup(func() { sysdb.Close() })
  29. uh, err := user.NewUserHandler(sysdb, nil, nil, nil, nil)
  30. if err != nil {
  31. t.Fatalf("failed to create user handler: %v", err)
  32. }
  33. g := minimalGateway()
  34. g.Option.UserHandler = uh
  35. sysdb.NewTable(llmDBTable)
  36. return g
  37. }
  38. // ─── pure helpers ─────────────────────────────────────────────────────────────
  39. func TestParseLLMCallOptions(t *testing.T) {
  40. if opt := parseLLMCallOptions(""); opt.Model != "" {
  41. t.Errorf("empty string should yield zero options")
  42. }
  43. if opt := parseLLMCallOptions("undefined"); opt.Model != "" {
  44. t.Errorf("'undefined' should yield zero options")
  45. }
  46. if opt := parseLLMCallOptions("null"); opt.Model != "" {
  47. t.Errorf("'null' should yield zero options")
  48. }
  49. opt := parseLLMCallOptions(`{"model":"gpt-4o","system":"be brief","temperature":0.5,"max_tokens":42}`)
  50. if opt.Model != "gpt-4o" || opt.System != "be brief" {
  51. t.Errorf("unexpected parse: %+v", opt)
  52. }
  53. if opt.Temperature == nil || *opt.Temperature != 0.5 {
  54. t.Errorf("temperature not parsed")
  55. }
  56. if opt.MaxTokens == nil || *opt.MaxTokens != 42 {
  57. t.Errorf("max_tokens not parsed")
  58. }
  59. }
  60. func TestLLMMaskKey(t *testing.T) {
  61. cases := map[string]string{
  62. "": "",
  63. "abc": "•••",
  64. "sk-1234567890": "••••7890",
  65. }
  66. for in, want := range cases {
  67. if got := llmMaskKey(in); got != want {
  68. t.Errorf("maskKey(%q) = %q, want %q", in, got, want)
  69. }
  70. }
  71. }
  72. func TestLLMExtClassification(t *testing.T) {
  73. if !llmIsImageExt(".png") || !llmIsImageExt(".jpeg") {
  74. t.Error("expected image extensions to be detected")
  75. }
  76. if llmIsImageExt(".txt") {
  77. t.Error(".txt should not be an image")
  78. }
  79. if !llmIsTextExt(".md") || !llmIsTextExt(".go") {
  80. t.Error("expected text extensions to be detected")
  81. }
  82. if llmIsTextExt(".png") {
  83. t.Error(".png should not be classified as text")
  84. }
  85. }
  86. // ─── persistence ──────────────────────────────────────────────────────────────
  87. func TestRecordLLMUsageAccumulatesAndCosts(t *testing.T) {
  88. g := dbGateway(t)
  89. sysdb := g.Option.UserHandler.GetDatabase()
  90. //Pricing: $2.50 / 1M input, $10.00 / 1M output
  91. sysdb.Write(llmDBTable, "pricing", map[string]LLMPricing{
  92. "test-model": {InputPrice: 2.5, OutputPrice: 10.0},
  93. })
  94. g.recordLLMUsage("test-model", 1000, 500)
  95. g.recordLLMUsage("test-model", 1000, 500)
  96. m := g.getLLMMetrics()
  97. if m.TotalRequests != 2 {
  98. t.Errorf("expected 2 requests, got %d", m.TotalRequests)
  99. }
  100. if m.TotalPromptTokens != 2000 || m.TotalCompletionTokens != 1000 || m.TotalTokens != 3000 {
  101. t.Errorf("unexpected token totals: %+v", m)
  102. }
  103. //Each call: 1000/1e6*2.5 + 500/1e6*10 = 0.0075 ; two calls => 0.015
  104. if got := m.TotalCost; got < 0.01499 || got > 0.01501 {
  105. t.Errorf("expected total cost ~0.015, got %v", got)
  106. }
  107. rec := m.PerModel["test-model"]
  108. if rec == nil || rec.Requests != 2 || rec.TotalTokens != 3000 {
  109. t.Errorf("per-model record incorrect: %+v", rec)
  110. }
  111. }
  112. func TestGetLLMConfigDefaultsCurrency(t *testing.T) {
  113. g := dbGateway(t)
  114. cfg := g.getLLMConfig()
  115. if cfg.Currency != "USD" {
  116. t.Errorf("expected default currency USD, got %q", cfg.Currency)
  117. }
  118. }
  119. // ─── orchestration (config resolution + metrics recording) ──────────────────
  120. // Wire-protocol mechanics (request shape, auth headers, response decoding)
  121. // are covered by mod/aiservers/llm's own tests; these only verify that the
  122. // AGI-layer orchestrator wires the client and the persisted config/metrics
  123. // together correctly.
  124. func TestLLMDoRequestFlow(t *testing.T) {
  125. var gotModel string
  126. var sawUserMessage bool
  127. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  128. body, _ := io.ReadAll(r.Body)
  129. var req struct {
  130. Model string `json:"model"`
  131. Messages []struct {
  132. Role string `json:"role"`
  133. } `json:"messages"`
  134. }
  135. json.Unmarshal(body, &req)
  136. gotModel = req.Model
  137. for _, msg := range req.Messages {
  138. if msg.Role == "user" {
  139. sawUserMessage = true
  140. }
  141. }
  142. w.Header().Set("Content-Type", "application/json")
  143. io.WriteString(w, `{"model":"test-model",
  144. "choices":[{"index":0,"message":{"role":"assistant","content":"Hello from mock"},"finish_reason":"stop"}],
  145. "usage":{"prompt_tokens":1000,"completion_tokens":500,"total_tokens":1500}}`)
  146. }))
  147. defer srv.Close()
  148. g := dbGateway(t)
  149. sysdb := g.Option.UserHandler.GetDatabase()
  150. sysdb.Write(llmDBTable, "config", LLMConfig{
  151. Endpoint: srv.URL,
  152. APIKey: "test-key",
  153. DefaultModel: "test-model",
  154. Currency: "USD",
  155. })
  156. sysdb.Write(llmDBTable, "pricing", map[string]LLMPricing{
  157. "test-model": {InputPrice: 2.5, OutputPrice: 10.0},
  158. })
  159. resp, err := g.llmDoRequest("", []llm.Message{{Role: "user", Content: "hi"}}, llmCallOptions{})
  160. if err != nil {
  161. t.Fatalf("llmDoRequest returned error: %v", err)
  162. }
  163. if content := llmExtractContent(resp); content != "Hello from mock" {
  164. t.Errorf("unexpected content: %q", content)
  165. }
  166. if gotModel != "test-model" {
  167. t.Errorf("expected default model to be used, got %q", gotModel)
  168. }
  169. if !sawUserMessage {
  170. t.Error("server did not receive a user message")
  171. }
  172. //Metrics should have been recorded from the usage block
  173. m := g.getLLMMetrics()
  174. if m.TotalRequests != 1 || m.TotalTokens != 1500 {
  175. t.Errorf("metrics not recorded after request: %+v", m)
  176. }
  177. }
  178. func TestLLMDoStreamRequestFlow(t *testing.T) {
  179. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  180. w.Header().Set("Content-Type", "text/event-stream")
  181. io.WriteString(w, "data: {\"model\":\"m\",\"choices\":[{\"delta\":{\"reasoning_content\":\"hmm\"}}]}\n\n")
  182. io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\n")
  183. io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"!\"},\"finish_reason\":\"stop\"}]}\n\n")
  184. io.WriteString(w, "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":4,\"completion_tokens\":2,\"total_tokens\":6}}\n\n")
  185. io.WriteString(w, "data: [DONE]\n\n")
  186. }))
  187. defer srv.Close()
  188. g := dbGateway(t)
  189. g.Option.UserHandler.GetDatabase().Write(llmDBTable, "config", LLMConfig{Endpoint: srv.URL, DefaultModel: "m", APIFormat: "openai", Currency: "USD"})
  190. var content, reasoning strings.Builder
  191. resp, err := g.llmDoStreamRequest("", []llm.Message{{Role: "user", Content: "hi"}}, llmCallOptions{}, func(d llm.StreamDelta) {
  192. content.WriteString(d.Content)
  193. reasoning.WriteString(d.Reasoning)
  194. })
  195. if err != nil {
  196. t.Fatalf("llmDoStreamRequest error: %v", err)
  197. }
  198. if content.String() != "Hi!" {
  199. t.Errorf("streamed content = %q, want Hi!", content.String())
  200. }
  201. if reasoning.String() != "hmm" {
  202. t.Errorf("streamed reasoning = %q, want hmm", reasoning.String())
  203. }
  204. if llmExtractContent(resp) != "Hi!" {
  205. t.Errorf("assembled content wrong: %q", llmExtractContent(resp))
  206. }
  207. //Usage from the streamed final chunk must be recorded like a blocking call.
  208. m := g.getLLMMetrics()
  209. if m.TotalRequests != 1 || m.TotalTokens != 6 {
  210. t.Errorf("metrics not recorded after stream: %+v", m)
  211. }
  212. }
  213. func TestLLMDoStreamRequestNoEndpoint(t *testing.T) {
  214. g := dbGateway(t)
  215. _, err := g.llmDoStreamRequest("m", []llm.Message{{Role: "user", Content: "hi"}}, llmCallOptions{}, nil)
  216. if err == nil {
  217. t.Error("expected error when endpoint is not configured")
  218. }
  219. }
  220. func TestLLMDoRequestNoEndpoint(t *testing.T) {
  221. g := dbGateway(t)
  222. _, err := g.llmDoRequest("m", []llm.Message{{Role: "user", Content: "hi"}}, llmCallOptions{})
  223. if err == nil {
  224. t.Error("expected error when endpoint is not configured")
  225. }
  226. }
  227. func TestLLMDoRequestAnthropicFlow(t *testing.T) {
  228. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  229. w.Header().Set("Content-Type", "application/json")
  230. io.WriteString(w, `{"model":"claude-x",
  231. "content":[{"type":"text","text":"Hi from Claude"}],
  232. "usage":{"input_tokens":30,"output_tokens":12},
  233. "stop_reason":"end_turn"}`)
  234. }))
  235. defer srv.Close()
  236. g := dbGateway(t)
  237. sysdb := g.Option.UserHandler.GetDatabase()
  238. sysdb.Write(llmDBTable, "config", LLMConfig{
  239. Endpoint: srv.URL, APIKey: "anthropic-key", DefaultModel: "claude-x", APIFormat: "anthropic", Currency: "USD",
  240. })
  241. //A system message in the unified array must be lifted to the top-level field
  242. //(verified directly in mod/aiservers/llm); here we only check the result
  243. //that reaches the AGI layer and that usage gets recorded.
  244. msgs := []llm.Message{
  245. {Role: "system", Content: "be brief"},
  246. {Role: "user", Content: "hello"},
  247. }
  248. resp, err := g.llmDoRequest("", msgs, llmCallOptions{})
  249. if err != nil {
  250. t.Fatalf("anthropic request errored: %v", err)
  251. }
  252. if content := llmExtractContent(resp); content != "Hi from Claude" {
  253. t.Errorf("unexpected content: %q", content)
  254. }
  255. //Usage mapping: input->prompt, output->completion.
  256. m := g.getLLMMetrics()
  257. if m.TotalPromptTokens != 30 || m.TotalCompletionTokens != 12 || m.TotalTokens != 42 {
  258. t.Errorf("usage not mapped/recorded correctly: %+v", m)
  259. }
  260. }
  261. func TestLLMDoRequestRecordsTokensPerSecond(t *testing.T) {
  262. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  263. time.Sleep(25 * time.Millisecond) //ensure a measurable generation time
  264. w.Header().Set("Content-Type", "application/json")
  265. io.WriteString(w, `{"model":"m","choices":[{"message":{"role":"assistant","content":"hello world"}}],
  266. "usage":{"prompt_tokens":5,"completion_tokens":20,"total_tokens":25}}`)
  267. }))
  268. defer srv.Close()
  269. g := dbGateway(t)
  270. g.Option.UserHandler.GetDatabase().Write(llmDBTable, "config", LLMConfig{Endpoint: srv.URL, DefaultModel: "m", APIFormat: "openai"})
  271. resp, err := g.llmDoRequest("", []llm.Message{{Role: "user", Content: "hi"}}, llmCallOptions{})
  272. if err != nil {
  273. t.Fatalf("request errored: %v", err)
  274. }
  275. if resp.Usage.GenerationMs <= 0 {
  276. t.Errorf("expected generation_ms > 0, got %d", resp.Usage.GenerationMs)
  277. }
  278. if resp.Usage.TokensPerSecond <= 0 {
  279. t.Errorf("expected tokens_per_second > 0, got %v", resp.Usage.TokensPerSecond)
  280. }
  281. m := g.getLLMMetrics()
  282. if m.TotalGenerationMs <= 0 {
  283. t.Errorf("expected total generation ms recorded, got %d", m.TotalGenerationMs)
  284. }
  285. if m.SpeedSamples != 1 || m.SpeedSum <= 0 {
  286. t.Errorf("expected one speed sample recorded, got samples=%d sum=%v", m.SpeedSamples, m.SpeedSum)
  287. }
  288. if rec := m.PerModel["m"]; rec == nil || rec.GenerationMs <= 0 || rec.SpeedSamples != 1 {
  289. t.Errorf("per-model speed sample not recorded: %+v", rec)
  290. }
  291. }
  292. // The average speed must be the mean of per-request speeds, not total tokens
  293. // over total time (which is token-weighted and skews toward large requests).
  294. func TestLLMAverageSpeedIsMeanOfRequests(t *testing.T) {
  295. g := dbGateway(t)
  296. //Request A: 10 tokens in 1000ms -> 10 tok/s
  297. g.recordLLMUsage("m", 0, 10, 1000)
  298. //Request B: 1000 tokens in 10000ms -> 100 tok/s
  299. g.recordLLMUsage("m", 0, 1000, 10000)
  300. m := g.getLLMMetrics()
  301. if m.SpeedSamples != 2 {
  302. t.Fatalf("expected 2 speed samples, got %d", m.SpeedSamples)
  303. }
  304. avg := m.SpeedSum / float64(m.SpeedSamples)
  305. //Mean of speeds = (10 + 100) / 2 = 55 (NOT throughput 1010/11 ≈ 91.8).
  306. if avg < 54.9 || avg > 55.1 {
  307. t.Errorf("expected average speed ~55 tok/s, got %v", avg)
  308. }
  309. }
  310. // ─── quota enforcement ──────────────────────────────────────────────────────
  311. func TestLLMQuotaEnforcement(t *testing.T) {
  312. g := dbGateway(t)
  313. sysdb := g.Option.UserHandler.GetDatabase()
  314. sysdb.Write(llmDBTable, "quota", LLMQuota{Enabled: true, MaxTokens: 100, Period: "total"})
  315. //Under the cap -> allowed.
  316. if err := g.llmCheckQuota(); err != nil {
  317. t.Fatalf("expected no error under quota, got %v", err)
  318. }
  319. //Consume past the cap.
  320. g.recordLLMUsage("m", 80, 40) // 120 tokens > 100
  321. if err := g.llmCheckQuota(); err == nil {
  322. t.Error("expected quota error after exceeding token cap")
  323. } else if !strings.Contains(err.Error(), "quota") {
  324. t.Errorf("expected a quota error, got %v", err)
  325. }
  326. //Disabling the quota lifts the block.
  327. sysdb.Write(llmDBTable, "quota", LLMQuota{Enabled: false, MaxTokens: 100, Period: "total"})
  328. if err := g.llmCheckQuota(); err != nil {
  329. t.Errorf("disabled quota should not block, got %v", err)
  330. }
  331. }
  332. func TestLLMDoRequestBlockedByQuota(t *testing.T) {
  333. g := dbGateway(t)
  334. sysdb := g.Option.UserHandler.GetDatabase()
  335. sysdb.Write(llmDBTable, "config", LLMConfig{Endpoint: "http://127.0.0.1:0", DefaultModel: "m", APIFormat: "openai"})
  336. sysdb.Write(llmDBTable, "quota", LLMQuota{Enabled: true, MaxTokens: 10, Period: "total"})
  337. g.recordLLMUsage("m", 20, 0) // exceed
  338. _, err := g.llmDoRequest("m", []llm.Message{{Role: "user", Content: "hi"}}, llmCallOptions{})
  339. if err == nil || !strings.Contains(err.Error(), "quota") {
  340. t.Errorf("expected request to be blocked by quota, got %v", err)
  341. }
  342. }
  343. func TestLLMWindowExpired(t *testing.T) {
  344. now := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
  345. if !llmWindowExpired(0, "daily", now) {
  346. t.Error("zero start should be considered expired")
  347. }
  348. yesterday := now.AddDate(0, 0, -1).Unix()
  349. if !llmWindowExpired(yesterday, "daily", now) {
  350. t.Error("yesterday should be expired for daily period")
  351. }
  352. if llmWindowExpired(now.Add(-1*time.Hour).Unix(), "daily", now) {
  353. t.Error("same day should not be expired for daily period")
  354. }
  355. lastMonth := now.AddDate(0, -1, 0).Unix()
  356. if !llmWindowExpired(lastMonth, "monthly", now) {
  357. t.Error("last month should be expired for monthly period")
  358. }
  359. if llmWindowExpired(now.AddDate(0, -1, 0).Unix(), "total", now) {
  360. t.Error("total period should never expire")
  361. }
  362. }
  363. // ─── config handler masking ─────────────────────────────────────────────────
  364. // HandleAIModelConfig keeps its original name/route - only the requirelib
  365. // identifier exposed to AGI scripts changed.
  366. func TestHandleAIModelConfigMaskingAndKeyRetention(t *testing.T) {
  367. g := dbGateway(t)
  368. sysdb := g.Option.UserHandler.GetDatabase()
  369. sysdb.Write(llmDBTable, "config", LLMConfig{
  370. Endpoint: "https://api.example.com/v1", APIKey: "sk-supersecret9999", DefaultModel: "m", Currency: "USD",
  371. })
  372. //GET should mask the key
  373. rec := httptest.NewRecorder()
  374. g.HandleAIModelConfig(rec, httptest.NewRequest("GET", "/system/aimodel/config", nil))
  375. var got map[string]interface{}
  376. json.Unmarshal(rec.Body.Bytes(), &got)
  377. if got["hasKey"] != true {
  378. t.Errorf("expected hasKey true, got %v", got["hasKey"])
  379. }
  380. if hint, _ := got["keyHint"].(string); !strings.HasSuffix(hint, "9999") || strings.Contains(hint, "supersecret") {
  381. t.Errorf("key not properly masked: %v", got["keyHint"])
  382. }
  383. //POST without apikey should retain the saved key, but update endpoint
  384. form := url.Values{}
  385. form.Set("endpoint", "https://new.example.com/v1")
  386. form.Set("defaultModel", "m2")
  387. form.Set("currency", "EUR")
  388. req := httptest.NewRequest("POST", "/system/aimodel/config", strings.NewReader(form.Encode()))
  389. req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  390. g.HandleAIModelConfig(httptest.NewRecorder(), req)
  391. cfg := g.getLLMConfig()
  392. if cfg.APIKey != "sk-supersecret9999" {
  393. t.Errorf("API key should have been retained, got %q", cfg.APIKey)
  394. }
  395. if cfg.Endpoint != "https://new.example.com/v1" || cfg.DefaultModel != "m2" || cfg.Currency != "EUR" {
  396. t.Errorf("config not updated correctly: %+v", cfg)
  397. }
  398. }
  399. // ─── JS object exposure ─────────────────────────────────────────────────────
  400. func TestInjectLLMLib_JSObjectExposed(t *testing.T) {
  401. g := minimalGateway()
  402. vm := otto.New()
  403. payload := &static.AgiLibInjectionPayload{VM: vm, User: &user.User{Username: "alice"}}
  404. g.injectLLMFunctions(payload)
  405. for _, method := range []string{"chat", "chatWithFile", "request", "streamRequest", "usage", "models"} {
  406. val, err := vm.Run(`typeof llm.` + method)
  407. if err != nil {
  408. t.Fatalf("evaluating llm.%s: %v", method, err)
  409. }
  410. s, _ := val.ToString()
  411. if s != "function" {
  412. t.Errorf("llm.%s should be a function, got %q", method, s)
  413. }
  414. }
  415. }