agi.llm.go 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941
  1. package agi
  2. import (
  3. "encoding/base64"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "mime"
  8. "net/http"
  9. "os"
  10. "path/filepath"
  11. "strconv"
  12. "strings"
  13. "sync"
  14. "time"
  15. "unicode/utf8"
  16. "github.com/robertkrimen/otto"
  17. "imuslab.com/arozos/mod/agi/static"
  18. llm "imuslab.com/arozos/mod/aiservers/llm"
  19. "imuslab.com/arozos/mod/filesystem"
  20. user "imuslab.com/arozos/mod/user"
  21. "imuslab.com/arozos/mod/utils"
  22. )
  23. /*
  24. AJGI LLM Library
  25. This library allows AGI scripts to call any OpenAI-compatible or
  26. Anthropic chat completion endpoint. It supports both plain text prompts
  27. and file-based prompts (images for vision models and text documents
  28. inlined into the conversation).
  29. The actual wire-protocol logic (OpenAI / Anthropic request building and
  30. response parsing) lives in the standalone mod/aiservers/llm client
  31. package; this file only owns the ArozOS-specific bits: the admin-
  32. configured connection / pricing / quota (System Settings > AI Integration
  33. > AI Model - still named "AI Model" there; only the requirelib identifier
  34. exposed to scripts changed from "aimodel" to "llm") and the Otto VM
  35. bindings.
  36. The sysdb table name and the System Settings HTTP handler names
  37. deliberately keep their original "aimodel" spelling so previously saved
  38. settings and routes are not affected by this rename.
  39. Author: tobychui (AGI), LLM lib addition
  40. */
  41. const (
  42. //llmDBTable is the system database table used to persist the LLM
  43. //configuration, per-model pricing and aggregated usage metrics. The
  44. //string value is kept as "aimodel" (its original name) so settings
  45. //saved before this library was renamed to "llm" keep working.
  46. llmDBTable = "aimodel"
  47. //llmKeyMask is the sentinel value the frontend submits when the API
  48. //key field was left untouched. When received, the stored key is kept.
  49. llmKeyMask = "********"
  50. //llmRequestTimeout is the maximum time to wait for a completion. Large
  51. //reasoning / agentic models can legitimately take many minutes to reply,
  52. //so this is set generously to 60 minutes to avoid cutting long calls short.
  53. llmRequestTimeout = 60 * time.Minute
  54. )
  55. // llmMetricsMux guards read-modify-write cycles on the metrics record so
  56. // concurrent AGI scripts do not clobber each other's usage updates.
  57. var llmMetricsMux sync.Mutex
  58. // ── Persisted data structures ───────────────────────────────────────────────
  59. // LLMConfig holds the global, admin-configured connection settings.
  60. type LLMConfig struct {
  61. Endpoint string `json:"endpoint"` //Base URL, e.g. https://api.openai.com/v1 or https://api.anthropic.com
  62. APIKey string `json:"apikey"` //API key (Bearer for OpenAI, x-api-key for Anthropic)
  63. DefaultModel string `json:"defaultModel"` //Model used when a script does not specify one
  64. APIFormat string `json:"apiFormat"` //Wire format: "openai" (default) or "anthropic"
  65. Currency string `json:"currency"` //Currency label used by the metrics board (default USD)
  66. }
  67. // LLMQuota defines an optional cap on token / cost consumption so the
  68. // system cannot keep spending once a budget is reached.
  69. type LLMQuota struct {
  70. Enabled bool `json:"enabled"` //When true, requests are blocked once a cap is hit
  71. MaxTokens int64 `json:"maxTokens"` //Total token cap for the period (0 = no token cap)
  72. MaxCost float64 `json:"maxCost"` //Total cost cap for the period (0 = no cost cap)
  73. Period string `json:"period"` //Reset window: "total" (never), "daily" or "monthly"
  74. }
  75. // periodLabel returns a human-friendly label for the quota period.
  76. func (q LLMQuota) periodLabel() string {
  77. switch q.Period {
  78. case "daily":
  79. return "day"
  80. case "monthly":
  81. return "month"
  82. default:
  83. return "total"
  84. }
  85. }
  86. // LLMPricing defines the price per 1,000,000 tokens for a given model.
  87. type LLMPricing struct {
  88. InputPrice float64 `json:"inputPrice"` //Cost per 1M prompt (input) tokens
  89. OutputPrice float64 `json:"outputPrice"` //Cost per 1M completion (output) tokens
  90. }
  91. // LLMUsageRecord is the accumulated usage of a single model.
  92. type LLMUsageRecord struct {
  93. PromptTokens int64 `json:"promptTokens"`
  94. CompletionTokens int64 `json:"completionTokens"`
  95. TotalTokens int64 `json:"totalTokens"`
  96. Cost float64 `json:"cost"`
  97. Requests int64 `json:"requests"`
  98. GenerationMs int64 `json:"generationMs"` //total generation time
  99. //Sum and count of per-request tokens/sec, so the average speed is the mean
  100. //of the per-request speeds (matching what is shown on each reply).
  101. SpeedSum float64 `json:"speedSum"`
  102. SpeedSamples int64 `json:"speedSamples"`
  103. }
  104. // LLMMetrics is the aggregated consumption across every model.
  105. type LLMMetrics struct {
  106. TotalPromptTokens int64 `json:"totalPromptTokens"`
  107. TotalCompletionTokens int64 `json:"totalCompletionTokens"`
  108. TotalTokens int64 `json:"totalTokens"`
  109. TotalCost float64 `json:"totalCost"`
  110. TotalRequests int64 `json:"totalRequests"`
  111. TotalGenerationMs int64 `json:"totalGenerationMs"`
  112. SpeedSum float64 `json:"speedSum"` //sum of per-request tok/s
  113. SpeedSamples int64 `json:"speedSamples"` //count of timed requests
  114. PerModel map[string]*LLMUsageRecord `json:"perModel"`
  115. Currency string `json:"currency"`
  116. UpdatedAt int64 `json:"updatedAt"`
  117. //Windowed usage used for quota enforcement (reset per quota period).
  118. WindowStart int64 `json:"windowStart"` //Unix time the current quota window began
  119. WindowTokens int64 `json:"windowTokens"` //Tokens consumed in the current window
  120. WindowCost float64 `json:"windowCost"` //Cost consumed in the current window
  121. }
  122. // llmCallOptions are the per-call options a script may pass as a JS object.
  123. type llmCallOptions struct {
  124. Model string `json:"model"` //Override the configured default model
  125. System string `json:"system"` //Optional system prompt
  126. Endpoint string `json:"endpoint"` //Override the global endpoint
  127. APIKey string `json:"apikey"` //Override the global API key
  128. APIFormat string `json:"apiFormat"` //Override the wire format ("openai"/"anthropic")
  129. Temperature *float64 `json:"temperature"` //Sampling temperature
  130. MaxTokens *int `json:"max_tokens"` //Maximum tokens to generate
  131. }
  132. // ── Library registration ─────────────────────────────────────────────────────
  133. func (g *Gateway) LLMLibRegister() {
  134. //Make sure the storage table exists before any read / write happens.
  135. sysdb := g.Option.UserHandler.GetDatabase()
  136. if !sysdb.TableExists(llmDBTable) {
  137. sysdb.NewTable(llmDBTable)
  138. }
  139. err := g.RegisterLib("llm", g.injectLLMFunctions)
  140. if err != nil {
  141. agiLogger.PrintAndLog("Agi", fmt.Sprint(err), nil)
  142. os.Exit(1)
  143. }
  144. }
  145. func (g *Gateway) injectLLMFunctions(payload *static.AgiLibInjectionPayload) {
  146. vm := payload.VM
  147. u := payload.User
  148. scriptFsh := payload.ScriptFsh
  149. //llm.chat(prompt, options) => assistant reply text
  150. vm.Set("_llm_chat", func(call otto.FunctionCall) otto.Value {
  151. prompt, _ := call.Argument(0).ToString()
  152. opt := parseLLMCallOptions(getOttoStringArg(call, 1))
  153. messages := []llm.Message{}
  154. if strings.TrimSpace(opt.System) != "" {
  155. messages = append(messages, llm.Message{Role: "system", Content: opt.System})
  156. }
  157. messages = append(messages, llm.Message{Role: "user", Content: prompt})
  158. resp, err := g.llmDoRequest(opt.Model, messages, opt)
  159. if err != nil {
  160. panic(vm.MakeCustomError("LLMError", err.Error()))
  161. }
  162. reply, _ := vm.ToValue(llmExtractContent(resp))
  163. return reply
  164. })
  165. //llm.chatWithFile(prompt, files, options) => assistant reply text
  166. //files may be a single vpath or an array of vpaths. Images are sent as
  167. //vision image_url parts; textual files are inlined as text parts.
  168. vm.Set("_llm_chatWithFile", func(call otto.FunctionCall) otto.Value {
  169. prompt, _ := call.Argument(0).ToString()
  170. filesJSON := getOttoStringArg(call, 1)
  171. opt := parseLLMCallOptions(getOttoStringArg(call, 2))
  172. var vpaths []string
  173. if err := json.Unmarshal([]byte(filesJSON), &vpaths); err != nil || len(vpaths) == 0 {
  174. panic(vm.MakeCustomError("LLMError", "no file path(s) provided"))
  175. }
  176. parts := []llm.ContentPart{}
  177. if strings.TrimSpace(prompt) != "" {
  178. parts = append(parts, llm.ContentPart{Type: "text", Text: prompt})
  179. }
  180. for _, vpath := range vpaths {
  181. fileParts, err := g.llmBuildFileParts(scriptFsh, vm, u, vpath)
  182. if err != nil {
  183. panic(vm.MakeCustomError("LLMError", err.Error()))
  184. }
  185. parts = append(parts, fileParts...)
  186. }
  187. messages := []llm.Message{}
  188. if strings.TrimSpace(opt.System) != "" {
  189. messages = append(messages, llm.Message{Role: "system", Content: opt.System})
  190. }
  191. messages = append(messages, llm.Message{Role: "user", Content: parts})
  192. resp, err := g.llmDoRequest(opt.Model, messages, opt)
  193. if err != nil {
  194. panic(vm.MakeCustomError("LLMError", err.Error()))
  195. }
  196. reply, _ := vm.ToValue(llmExtractContent(resp))
  197. return reply
  198. })
  199. //llm.request(messages, options) => full response object (JSON string)
  200. //Gives advanced scripts access to usage information and finish reason.
  201. vm.Set("_llm_request", func(call otto.FunctionCall) otto.Value {
  202. messagesJSON := getOttoStringArg(call, 0)
  203. opt := parseLLMCallOptions(getOttoStringArg(call, 1))
  204. var messages []llm.Message
  205. if err := json.Unmarshal([]byte(messagesJSON), &messages); err != nil {
  206. panic(vm.MakeCustomError("LLMError", "invalid messages array: "+err.Error()))
  207. }
  208. resp, err := g.llmDoRequest(opt.Model, messages, opt)
  209. if err != nil {
  210. panic(vm.MakeCustomError("LLMError", err.Error()))
  211. }
  212. out, _ := json.Marshal(resp)
  213. reply, _ := vm.ToValue(string(out))
  214. return reply
  215. })
  216. //llm.streamRequest(messages, options, onDelta) => full response object.
  217. //Streams the completion, invoking onDelta({content, reasoning}) for each
  218. //incremental chunk (so a script can relay tokens to the browser over a
  219. //WebSocket in real time), then returns the assembled response with usage.
  220. vm.Set("_llm_streamRequest", func(call otto.FunctionCall) otto.Value {
  221. messagesJSON := getOttoStringArg(call, 0)
  222. opt := parseLLMCallOptions(getOttoStringArg(call, 1))
  223. onDelta := call.Argument(2)
  224. var messages []llm.Message
  225. if err := json.Unmarshal([]byte(messagesJSON), &messages); err != nil {
  226. panic(vm.MakeCustomError("LLMError", "invalid messages array: "+err.Error()))
  227. }
  228. //The callback runs on this same (script) goroutine inside ChatStream,
  229. //so touching the Otto VM from here is safe. Errors thrown by the JS
  230. //callback are ignored so one bad frame cannot abort the stream.
  231. cb := func(d llm.StreamDelta) {
  232. if !onDelta.IsFunction() {
  233. return
  234. }
  235. onDelta.Call(otto.UndefinedValue(), map[string]interface{}{
  236. "content": d.Content,
  237. "reasoning": d.Reasoning,
  238. })
  239. }
  240. resp, err := g.llmDoStreamRequest(opt.Model, messages, opt, cb)
  241. if err != nil {
  242. panic(vm.MakeCustomError("LLMError", err.Error()))
  243. }
  244. out, _ := json.Marshal(resp)
  245. reply, _ := vm.ToValue(string(out))
  246. return reply
  247. })
  248. //llm.usage() => aggregated metrics object (JSON string)
  249. vm.Set("_llm_usage", func(call otto.FunctionCall) otto.Value {
  250. out, _ := json.Marshal(g.getLLMMetrics())
  251. reply, _ := vm.ToValue(string(out))
  252. return reply
  253. })
  254. //llm.models() => { default: "...", models: [...] } (JSON string)
  255. vm.Set("_llm_models", func(call otto.FunctionCall) otto.Value {
  256. cfg := g.getLLMConfig()
  257. pricing := g.getLLMPricing()
  258. models := []string{}
  259. for name := range pricing {
  260. models = append(models, name)
  261. }
  262. out, _ := json.Marshal(map[string]interface{}{
  263. "default": cfg.DefaultModel,
  264. "models": models,
  265. })
  266. reply, _ := vm.ToValue(string(out))
  267. return reply
  268. })
  269. //llm.listModels() => { models: [...] } from the live endpoint (JSON string)
  270. vm.Set("_llm_listModels", func(call otto.FunctionCall) otto.Value {
  271. cfg := g.getLLMConfig()
  272. result := map[string]interface{}{"models": []string{}}
  273. client := llm.NewClient(cfg.Endpoint, cfg.APIKey, cfg.APIFormat, llmRequestTimeout)
  274. models, err := client.ListModels()
  275. if err != nil {
  276. result["error"] = err.Error()
  277. } else {
  278. result["models"] = models
  279. }
  280. out, _ := json.Marshal(result)
  281. reply, _ := vm.ToValue(string(out))
  282. return reply
  283. })
  284. //llm.fileParts(files) => JSON array of OpenAI-style content parts for
  285. //the given virtual file path(s). Images become image_url data URIs, text
  286. //documents are inlined. Scripts can embed these into a message's content.
  287. vm.Set("_llm_fileParts", func(call otto.FunctionCall) otto.Value {
  288. filesJSON := getOttoStringArg(call, 0)
  289. var vpaths []string
  290. if err := json.Unmarshal([]byte(filesJSON), &vpaths); err != nil {
  291. panic(vm.MakeCustomError("LLMError", "invalid files array: "+err.Error()))
  292. }
  293. parts := []llm.ContentPart{}
  294. for _, vp := range vpaths {
  295. fp, err := g.llmBuildFileParts(scriptFsh, vm, u, vp)
  296. if err != nil {
  297. panic(vm.MakeCustomError("LLMError", err.Error()))
  298. }
  299. parts = append(parts, fp...)
  300. }
  301. out, _ := json.Marshal(parts)
  302. reply, _ := vm.ToValue(string(out))
  303. return reply
  304. })
  305. //Wrap the native functions into a clean llm class
  306. vm.Run(`
  307. var llm = {};
  308. llm.chat = function(prompt, options){
  309. return _llm_chat(prompt, JSON.stringify(options || {}));
  310. };
  311. llm.chatWithFile = function(prompt, files, options){
  312. if (typeof files === "string"){ files = [files]; }
  313. return _llm_chatWithFile(prompt, JSON.stringify(files || []), JSON.stringify(options || {}));
  314. };
  315. llm.request = function(messages, options){
  316. return JSON.parse(_llm_request(JSON.stringify(messages || []), JSON.stringify(options || {})));
  317. };
  318. llm.streamRequest = function(messages, options, onDelta){
  319. return JSON.parse(_llm_streamRequest(JSON.stringify(messages || []), JSON.stringify(options || {}), onDelta || null));
  320. };
  321. llm.usage = function(){
  322. return JSON.parse(_llm_usage());
  323. };
  324. llm.models = function(){
  325. return JSON.parse(_llm_models());
  326. };
  327. llm.listModels = function(){
  328. return JSON.parse(_llm_listModels());
  329. };
  330. llm.fileParts = function(files){
  331. if (typeof files === "string"){ files = [files]; }
  332. return JSON.parse(_llm_fileParts(JSON.stringify(files || [])));
  333. };
  334. `)
  335. }
  336. // ── Core request logic ───────────────────────────────────────────────────────
  337. // llmResolveConnection folds the admin config together with any per-call
  338. // overrides, validates it and returns a ready client plus the resolved model.
  339. // Shared by the blocking and streaming request paths.
  340. func (g *Gateway) llmResolveConnection(model string, opt llmCallOptions) (*llm.Client, string, error) {
  341. cfg := g.getLLMConfig()
  342. endpoint := strings.TrimSpace(cfg.Endpoint)
  343. apikey := cfg.APIKey
  344. format := cfg.APIFormat
  345. if strings.TrimSpace(opt.Endpoint) != "" {
  346. endpoint = strings.TrimSpace(opt.Endpoint)
  347. }
  348. if strings.TrimSpace(opt.APIKey) != "" {
  349. apikey = strings.TrimSpace(opt.APIKey)
  350. }
  351. if strings.TrimSpace(opt.APIFormat) != "" {
  352. format = strings.TrimSpace(opt.APIFormat)
  353. }
  354. if format == "" {
  355. format = "openai"
  356. }
  357. if strings.TrimSpace(model) == "" {
  358. model = cfg.DefaultModel
  359. }
  360. if endpoint == "" {
  361. return nil, "", errors.New("AI model endpoint is not configured (System Settings > AI Integration > AI Model)")
  362. }
  363. if strings.TrimSpace(model) == "" {
  364. return nil, "", errors.New("no model specified and no default model configured")
  365. }
  366. return llm.NewClient(endpoint, apikey, format, llmRequestTimeout), model, nil
  367. }
  368. // llmDoRequest resolves the connection settings, enforces any usage quota,
  369. // dispatches the call via mod/aiservers/llm, records the resulting token
  370. // usage / cost and returns the unified response.
  371. func (g *Gateway) llmDoRequest(model string, messages []llm.Message, opt llmCallOptions) (*llm.ChatResponse, error) {
  372. client, model, err := g.llmResolveConnection(model, opt)
  373. if err != nil {
  374. return nil, err
  375. }
  376. //Enforce the usage quota before spending any tokens.
  377. if err := g.llmCheckQuota(); err != nil {
  378. return nil, err
  379. }
  380. resp, err := client.Chat(messages, llm.ChatOptions{Model: model, Temperature: opt.Temperature, MaxTokens: opt.MaxTokens})
  381. if err != nil {
  382. return nil, err
  383. }
  384. g.recordLLMUsageFromResponse(model, resp)
  385. return resp, nil
  386. }
  387. // llmDoStreamRequest is the streaming counterpart of llmDoRequest: it streams
  388. // the completion, forwarding each delta to cb, and records usage once done.
  389. func (g *Gateway) llmDoStreamRequest(model string, messages []llm.Message, opt llmCallOptions, cb llm.StreamCallback) (*llm.ChatResponse, error) {
  390. client, model, err := g.llmResolveConnection(model, opt)
  391. if err != nil {
  392. return nil, err
  393. }
  394. if err := g.llmCheckQuota(); err != nil {
  395. return nil, err
  396. }
  397. resp, err := client.ChatStream(messages, llm.ChatOptions{Model: model, Temperature: opt.Temperature, MaxTokens: opt.MaxTokens}, cb)
  398. if err != nil {
  399. return nil, err
  400. }
  401. g.recordLLMUsageFromResponse(model, resp)
  402. return resp, nil
  403. }
  404. // recordLLMUsageFromResponse records the token usage of a completed response,
  405. // preferring the model name echoed back by the server.
  406. func (g *Gateway) recordLLMUsageFromResponse(model string, resp *llm.ChatResponse) {
  407. usedModel := model
  408. if resp != nil && strings.TrimSpace(resp.Model) != "" {
  409. usedModel = resp.Model
  410. }
  411. if resp != nil {
  412. g.recordLLMUsage(usedModel, resp.Usage.PromptTokens, resp.Usage.CompletionTokens, resp.Usage.GenerationMs)
  413. }
  414. }
  415. // llmBuildFileParts reads a file from the user's virtual file system and
  416. // converts it into one or more OpenAI-compatible content parts. Images become
  417. // base64 data-URI image_url parts (for vision models); textual files are
  418. // inlined as a labelled text part.
  419. func (g *Gateway) llmBuildFileParts(scriptFsh *filesystem.FileSystemHandler, vm *otto.Otto, u *user.User, vpath string) ([]llm.ContentPart, error) {
  420. //Resolve relative paths against the script's directory
  421. vpath = static.RelativeVpathRewrite(scriptFsh, vpath, vm, u)
  422. if !u.CanRead(vpath) {
  423. return nil, errors.New("permission denied: " + vpath)
  424. }
  425. fsh, rpath, err := static.VirtualPathToRealPath(vpath, u)
  426. if err != nil {
  427. return nil, err
  428. }
  429. if !fsh.FileSystemAbstraction.FileExists(rpath) {
  430. return nil, errors.New("file not found: " + vpath)
  431. }
  432. content, err := fsh.FileSystemAbstraction.ReadFile(rpath)
  433. if err != nil {
  434. return nil, err
  435. }
  436. ext := strings.ToLower(filepath.Ext(rpath))
  437. filename := filepath.Base(rpath)
  438. if llmIsImageExt(ext) {
  439. mimeType := mime.TypeByExtension(ext)
  440. if mimeType == "" {
  441. mimeType = "image/" + strings.TrimPrefix(ext, ".")
  442. }
  443. dataURI := "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(content)
  444. return []llm.ContentPart{{Type: "image_url", ImageURL: &llm.ImageURL{URL: dataURI}}}, nil
  445. }
  446. //Treat anything that is valid UTF-8 (or has a known text extension) as text.
  447. if llmIsTextExt(ext) || utf8.Valid(content) {
  448. text := "[Attached file: " + filename + "]\n" + string(content)
  449. return []llm.ContentPart{{Type: "text", Text: text}}, nil
  450. }
  451. return nil, errors.New("unsupported file type for file-based chat: " + filename + " (only images and text documents are supported)")
  452. }
  453. // ── Persistence helpers ──────────────────────────────────────────────────────
  454. func (g *Gateway) getLLMConfig() LLMConfig {
  455. cfg := LLMConfig{Currency: "USD", APIFormat: "openai"}
  456. sysdb := g.Option.UserHandler.GetDatabase()
  457. if sysdb.KeyExists(llmDBTable, "config") {
  458. sysdb.Read(llmDBTable, "config", &cfg)
  459. if strings.TrimSpace(cfg.Currency) == "" {
  460. cfg.Currency = "USD"
  461. }
  462. if strings.TrimSpace(cfg.APIFormat) == "" {
  463. cfg.APIFormat = "openai"
  464. }
  465. }
  466. return cfg
  467. }
  468. func (g *Gateway) getLLMQuota() LLMQuota {
  469. q := LLMQuota{Period: "total"}
  470. sysdb := g.Option.UserHandler.GetDatabase()
  471. if sysdb.KeyExists(llmDBTable, "quota") {
  472. sysdb.Read(llmDBTable, "quota", &q)
  473. if strings.TrimSpace(q.Period) == "" {
  474. q.Period = "total"
  475. }
  476. }
  477. return q
  478. }
  479. // llmWindowExpired reports whether a quota window that started at startUnix
  480. // has rolled over for the given period as of now.
  481. func llmWindowExpired(startUnix int64, period string, now time.Time) bool {
  482. if startUnix <= 0 {
  483. return true
  484. }
  485. start := time.Unix(startUnix, 0).UTC()
  486. n := now.UTC()
  487. switch period {
  488. case "daily":
  489. return start.YearDay() != n.YearDay() || start.Year() != n.Year()
  490. case "monthly":
  491. return start.Month() != n.Month() || start.Year() != n.Year()
  492. default: //"total" never expires
  493. return false
  494. }
  495. }
  496. // llmCurrentWindowUsage returns the effective token / cost usage for the
  497. // active quota window (zero if the window has rolled over).
  498. func (g *Gateway) llmCurrentWindowUsage() (int64, float64) {
  499. q := g.getLLMQuota()
  500. m := g.getLLMMetrics()
  501. if llmWindowExpired(m.WindowStart, q.Period, time.Now()) {
  502. return 0, 0
  503. }
  504. return m.WindowTokens, m.WindowCost
  505. }
  506. // llmCheckQuota returns an error when a configured quota has been reached.
  507. func (g *Gateway) llmCheckQuota() error {
  508. q := g.getLLMQuota()
  509. if !q.Enabled {
  510. return nil
  511. }
  512. usedTokens, usedCost := g.llmCurrentWindowUsage()
  513. if q.MaxTokens > 0 && usedTokens >= q.MaxTokens {
  514. return fmt.Errorf("AI usage quota reached: %d / %d tokens used this %s — new requests are blocked until the quota resets or is raised", usedTokens, q.MaxTokens, q.periodLabel())
  515. }
  516. if q.MaxCost > 0 && usedCost >= q.MaxCost {
  517. return fmt.Errorf("AI cost quota reached: %.4f / %.4f used this %s — new requests are blocked until the quota resets or is raised", usedCost, q.MaxCost, q.periodLabel())
  518. }
  519. return nil
  520. }
  521. func (g *Gateway) getLLMPricing() map[string]LLMPricing {
  522. pricing := map[string]LLMPricing{}
  523. sysdb := g.Option.UserHandler.GetDatabase()
  524. if sysdb.KeyExists(llmDBTable, "pricing") {
  525. sysdb.Read(llmDBTable, "pricing", &pricing)
  526. }
  527. return pricing
  528. }
  529. func (g *Gateway) getLLMMetrics() *LLMMetrics {
  530. metrics := &LLMMetrics{PerModel: map[string]*LLMUsageRecord{}}
  531. sysdb := g.Option.UserHandler.GetDatabase()
  532. if sysdb.KeyExists(llmDBTable, "metrics") {
  533. sysdb.Read(llmDBTable, "metrics", metrics)
  534. if metrics.PerModel == nil {
  535. metrics.PerModel = map[string]*LLMUsageRecord{}
  536. }
  537. }
  538. //Keep currency label in sync with the current config.
  539. metrics.Currency = g.getLLMConfig().Currency
  540. return metrics
  541. }
  542. // recordLLMUsage atomically adds the given token counts (and their computed
  543. // cost from the configured pricing) into the persisted metrics. The optional
  544. // genMs argument is the request's generation time, accumulated so the
  545. // metrics board can report an average tokens-per-second.
  546. func (g *Gateway) recordLLMUsage(model string, promptTokens int64, completionTokens int64, genMs ...int64) {
  547. var generationMs int64
  548. if len(genMs) > 0 {
  549. generationMs = genMs[0]
  550. }
  551. llmMetricsMux.Lock()
  552. defer llmMetricsMux.Unlock()
  553. sysdb := g.Option.UserHandler.GetDatabase()
  554. metrics := &LLMMetrics{PerModel: map[string]*LLMUsageRecord{}}
  555. if sysdb.KeyExists(llmDBTable, "metrics") {
  556. sysdb.Read(llmDBTable, "metrics", metrics)
  557. if metrics.PerModel == nil {
  558. metrics.PerModel = map[string]*LLMUsageRecord{}
  559. }
  560. }
  561. pricing := g.getLLMPricing()
  562. p := pricing[model]
  563. cost := float64(promptTokens)/1000000.0*p.InputPrice + float64(completionTokens)/1000000.0*p.OutputPrice
  564. rec := metrics.PerModel[model]
  565. if rec == nil {
  566. rec = &LLMUsageRecord{}
  567. metrics.PerModel[model] = rec
  568. }
  569. rec.PromptTokens += promptTokens
  570. rec.CompletionTokens += completionTokens
  571. rec.TotalTokens += promptTokens + completionTokens
  572. rec.Cost += cost
  573. rec.Requests++
  574. rec.GenerationMs += generationMs
  575. metrics.TotalPromptTokens += promptTokens
  576. metrics.TotalCompletionTokens += completionTokens
  577. metrics.TotalTokens += promptTokens + completionTokens
  578. metrics.TotalCost += cost
  579. metrics.TotalRequests++
  580. metrics.TotalGenerationMs += generationMs
  581. metrics.UpdatedAt = time.Now().Unix()
  582. //Accumulate this request's speed (tokens/sec) so the reported average is the
  583. //mean of per-request speeds, consistent with the per-reply figures.
  584. if completionTokens > 0 && generationMs > 0 {
  585. speed := float64(completionTokens) / (float64(generationMs) / 1000.0)
  586. rec.SpeedSum += speed
  587. rec.SpeedSamples++
  588. metrics.SpeedSum += speed
  589. metrics.SpeedSamples++
  590. }
  591. //Maintain the windowed usage used for quota enforcement. Reset the window
  592. //first if it has rolled over for the configured quota period.
  593. now := time.Now()
  594. period := g.getLLMQuota().Period
  595. if metrics.WindowStart == 0 || llmWindowExpired(metrics.WindowStart, period, now) {
  596. metrics.WindowStart = now.Unix()
  597. metrics.WindowTokens = 0
  598. metrics.WindowCost = 0
  599. }
  600. metrics.WindowTokens += promptTokens + completionTokens
  601. metrics.WindowCost += cost
  602. if err := sysdb.Write(llmDBTable, "metrics", metrics); err != nil {
  603. agiLogger.PrintAndLog("Agi", "[AGI] Failed to persist LLM usage metrics: "+err.Error(), nil)
  604. }
  605. }
  606. // ── HTTP handlers (System Settings) ──────────────────────────────────────────
  607. // These serve the "AI Model" tab in System Settings > AI Integration and keep
  608. // their original Handle* names / routes; only the requirelib identifier used
  609. // by AGI scripts ("llm") changed.
  610. // HandleAIModelConfig serves GET (masked config) and POST (save config).
  611. // GET /system/aimodel/config
  612. // POST /system/aimodel/config (endpoint, defaultModel, currency, apikey, clearkey)
  613. func (g *Gateway) HandleAIModelConfig(w http.ResponseWriter, r *http.Request) {
  614. if r.Method == http.MethodGet {
  615. cfg := g.getLLMConfig()
  616. js, _ := json.Marshal(map[string]interface{}{
  617. "endpoint": cfg.Endpoint,
  618. "defaultModel": cfg.DefaultModel,
  619. "apiFormat": cfg.APIFormat,
  620. "currency": cfg.Currency,
  621. "hasKey": cfg.APIKey != "",
  622. "keyHint": llmMaskKey(cfg.APIKey),
  623. })
  624. utils.SendJSONResponse(w, string(js))
  625. return
  626. }
  627. //POST - save. Read raw form values so empty strings are allowed for
  628. //endpoint / defaultModel (e.g. when intentionally clearing a field).
  629. r.ParseForm()
  630. cfg := g.getLLMConfig()
  631. cfg.Endpoint = strings.TrimSpace(r.Form.Get("endpoint"))
  632. cfg.DefaultModel = strings.TrimSpace(r.Form.Get("defaultModel"))
  633. if format := strings.TrimSpace(r.Form.Get("apiFormat")); format == "anthropic" || format == "openai" {
  634. cfg.APIFormat = format
  635. }
  636. if cfg.APIFormat == "" {
  637. cfg.APIFormat = "openai"
  638. }
  639. if currency := strings.TrimSpace(r.Form.Get("currency")); currency != "" {
  640. cfg.Currency = currency
  641. }
  642. //API key: only overwrite when a new, non-sentinel value is supplied.
  643. if clear, _ := utils.PostBool(r, "clearkey"); clear {
  644. cfg.APIKey = ""
  645. } else if apikey := r.Form.Get("apikey"); apikey != "" && apikey != llmKeyMask {
  646. cfg.APIKey = apikey
  647. }
  648. sysdb := g.Option.UserHandler.GetDatabase()
  649. if err := sysdb.Write(llmDBTable, "config", cfg); err != nil {
  650. utils.SendErrorResponse(w, "failed to save config: "+err.Error())
  651. return
  652. }
  653. utils.SendOK(w)
  654. }
  655. // HandleAIModelPricing serves GET (pricing map) and POST (save pricing map).
  656. // GET /system/aimodel/pricing
  657. // POST /system/aimodel/pricing (pricing = JSON of {model:{inputPrice,outputPrice}})
  658. func (g *Gateway) HandleAIModelPricing(w http.ResponseWriter, r *http.Request) {
  659. if r.Method == http.MethodGet {
  660. js, _ := json.Marshal(g.getLLMPricing())
  661. utils.SendJSONResponse(w, string(js))
  662. return
  663. }
  664. raw, err := utils.PostPara(r, "pricing")
  665. if err != nil {
  666. utils.SendErrorResponse(w, "missing pricing data")
  667. return
  668. }
  669. pricing := map[string]LLMPricing{}
  670. if err := json.Unmarshal([]byte(raw), &pricing); err != nil {
  671. utils.SendErrorResponse(w, "invalid pricing JSON: "+err.Error())
  672. return
  673. }
  674. sysdb := g.Option.UserHandler.GetDatabase()
  675. if err := sysdb.Write(llmDBTable, "pricing", pricing); err != nil {
  676. utils.SendErrorResponse(w, "failed to save pricing: "+err.Error())
  677. return
  678. }
  679. utils.SendOK(w)
  680. }
  681. // HandleAIModelMetrics returns the aggregated usage metrics.
  682. // GET /system/aimodel/metrics
  683. func (g *Gateway) HandleAIModelMetrics(w http.ResponseWriter, r *http.Request) {
  684. js, _ := json.Marshal(g.getLLMMetrics())
  685. utils.SendJSONResponse(w, string(js))
  686. }
  687. // HandleAIModelMetricsReset clears the aggregated usage metrics.
  688. // POST /system/aimodel/metrics/reset
  689. func (g *Gateway) HandleAIModelMetricsReset(w http.ResponseWriter, r *http.Request) {
  690. llmMetricsMux.Lock()
  691. defer llmMetricsMux.Unlock()
  692. metrics := &LLMMetrics{
  693. PerModel: map[string]*LLMUsageRecord{},
  694. UpdatedAt: time.Now().Unix(),
  695. }
  696. sysdb := g.Option.UserHandler.GetDatabase()
  697. if err := sysdb.Write(llmDBTable, "metrics", metrics); err != nil {
  698. utils.SendErrorResponse(w, "failed to reset metrics: "+err.Error())
  699. return
  700. }
  701. utils.SendOK(w)
  702. }
  703. // HandleAIModelTest performs a lightweight connectivity check by listing the
  704. // models exposed by the endpoint. It does not consume any tokens.
  705. // POST /system/aimodel/test (optional: endpoint, apikey, apiFormat to test unsaved values)
  706. func (g *Gateway) HandleAIModelTest(w http.ResponseWriter, r *http.Request) {
  707. cfg := g.getLLMConfig()
  708. endpoint := cfg.Endpoint
  709. apikey := cfg.APIKey
  710. format := cfg.APIFormat
  711. if ep := strings.TrimSpace(r.FormValue("endpoint")); ep != "" {
  712. endpoint = ep
  713. }
  714. if k := r.FormValue("apikey"); k != "" && k != llmKeyMask {
  715. apikey = k
  716. }
  717. if f := strings.TrimSpace(r.FormValue("apiFormat")); f == "openai" || f == "anthropic" {
  718. format = f
  719. }
  720. if strings.TrimSpace(endpoint) == "" {
  721. utils.SendErrorResponse(w, "endpoint not configured")
  722. return
  723. }
  724. client := llm.NewClient(endpoint, apikey, format, llmRequestTimeout)
  725. models, err := client.ListModels()
  726. if err != nil {
  727. utils.SendErrorResponse(w, err.Error())
  728. return
  729. }
  730. out, _ := json.Marshal(map[string]interface{}{
  731. "ok": true,
  732. "modelCount": len(models),
  733. "models": models,
  734. })
  735. utils.SendJSONResponse(w, string(out))
  736. }
  737. // HandleAIModelQuota serves GET (quota + current window usage) and POST (save).
  738. // GET /system/aimodel/quota
  739. // POST /system/aimodel/quota (enabled, maxTokens, maxCost, period)
  740. func (g *Gateway) HandleAIModelQuota(w http.ResponseWriter, r *http.Request) {
  741. if r.Method == http.MethodGet {
  742. q := g.getLLMQuota()
  743. usedTokens, usedCost := g.llmCurrentWindowUsage()
  744. js, _ := json.Marshal(map[string]interface{}{
  745. "enabled": q.Enabled,
  746. "maxTokens": q.MaxTokens,
  747. "maxCost": q.MaxCost,
  748. "period": q.Period,
  749. "usedTokens": usedTokens,
  750. "usedCost": usedCost,
  751. "currency": g.getLLMConfig().Currency,
  752. })
  753. utils.SendJSONResponse(w, string(js))
  754. return
  755. }
  756. r.ParseForm()
  757. q := g.getLLMQuota()
  758. q.Enabled, _ = utils.PostBool(r, "enabled")
  759. q.MaxTokens = 0
  760. if n, err := strconv.ParseInt(strings.TrimSpace(r.Form.Get("maxTokens")), 10, 64); err == nil && n >= 0 {
  761. q.MaxTokens = n
  762. }
  763. q.MaxCost = 0
  764. if f, err := strconv.ParseFloat(strings.TrimSpace(r.Form.Get("maxCost")), 64); err == nil && f >= 0 {
  765. q.MaxCost = f
  766. }
  767. if p := strings.TrimSpace(r.Form.Get("period")); p == "total" || p == "daily" || p == "monthly" {
  768. q.Period = p
  769. }
  770. sysdb := g.Option.UserHandler.GetDatabase()
  771. if err := sysdb.Write(llmDBTable, "quota", q); err != nil {
  772. utils.SendErrorResponse(w, "failed to save quota: "+err.Error())
  773. return
  774. }
  775. utils.SendOK(w)
  776. }
  777. // ── Small helpers ────────────────────────────────────────────────────────────
  778. func llmExtractContent(resp *llm.ChatResponse) string {
  779. if resp == nil || len(resp.Choices) == 0 {
  780. return ""
  781. }
  782. return resp.Choices[0].Message.Content
  783. }
  784. func parseLLMCallOptions(s string) llmCallOptions {
  785. opt := llmCallOptions{}
  786. s = strings.TrimSpace(s)
  787. if s == "" || s == "undefined" || s == "null" {
  788. return opt
  789. }
  790. json.Unmarshal([]byte(s), &opt)
  791. return opt
  792. }
  793. func llmMaskKey(key string) string {
  794. if key == "" {
  795. return ""
  796. }
  797. if len(key) <= 4 {
  798. return strings.Repeat("•", len(key))
  799. }
  800. return "••••" + key[len(key)-4:]
  801. }
  802. func llmIsImageExt(ext string) bool {
  803. switch ext {
  804. case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp":
  805. return true
  806. }
  807. return false
  808. }
  809. func llmIsTextExt(ext string) bool {
  810. switch ext {
  811. case ".txt", ".md", ".markdown", ".csv", ".tsv", ".json", ".xml", ".yaml", ".yml",
  812. ".html", ".htm", ".js", ".ts", ".go", ".py", ".java", ".c", ".cpp", ".h", ".hpp",
  813. ".css", ".log", ".ini", ".conf", ".sh", ".bat", ".sql", ".php", ".rb", ".rs",
  814. ".toml", ".env", ".srt", ".vtt":
  815. return true
  816. }
  817. return false
  818. }
  819. // getOttoStringArg safely reads the nth argument of a call as a string,
  820. // returning an empty string when the argument is absent or undefined. Shared
  821. // by every AGI lib file in this package (llm, cnn, ...).
  822. func getOttoStringArg(call otto.FunctionCall, idx int) string {
  823. arg := call.Argument(idx)
  824. if arg.IsUndefined() || arg.IsNull() {
  825. return ""
  826. }
  827. s, err := arg.ToString()
  828. if err != nil {
  829. return ""
  830. }
  831. return s
  832. }