Browse Source

Rename aimodel to llm and add LLM server pkg

Replace the legacy "aimodel" library with a new "llm" library and introduce a dedicated aiservers/llm package. Key changes:

- Rename library identifier from "aimodel" to "llm" across docs and backend scripts (README, AGI scripts, web backend examples).
- Remove the old agi.aimodel implementation and add agi.llm (new AGI lib) plus agi.llm_test.go.
- Add mod/aiservers/llm package with client, OpenAI/Anthropic adapters and types, and tests (client.go, openai.go, anthropic.go, types.go, and tests).
- Update tests and module wiring to use LLMConfig/LLMPricing/llmDBTable and injectLLMFunctions where appropriate.
- Minor updates to web demo backend/init scripts and API docs to reflect the new requirelib name.

Notes: System Settings UI and routes retaining the original "AI Model" naming; only the requirelib identifier and internal Go types/DB table references were changed. This restructures wire-protocol logic into the new aiservers/llm package for clearer separation of concerns and easier protocol-specific handling.
Toby Chui 6 days ago
parent
commit
8bcf24fc6e

+ 36 - 32
src/mod/agi/README.md

@@ -271,7 +271,7 @@ Registered library IDs:
 - `sysinfo`
 - `ziplib` (includes 7z support via `ziplib.extract7zFile`, `ziplib.list7zFileContents`, etc.)
 - `sqlite` (SQLite database access — not available on linux/mipsle or windows/arm/386)
-- `aimodel` (OpenAI / Anthropic LLM chat: text & file based, with pricing & quota)
+- `llm` (OpenAI / Anthropic LLM chat: text & file based, with pricing & quota)
 - `cnn` (CXNNAIO vision inference: classification, detection, segmentation, pose, oriented detection, face analysis)
 - `ffmpeg` (only when ffmpeg exists on host)
 
@@ -881,60 +881,64 @@ sendJSONResp(pending);
 db.close();
 ```
 
-## aimodel API
+## llm API
 
 Load:
 
 ```javascript
-requirelib("aimodel");
+requirelib("llm");
 ```
 
-The `aimodel` library connects to any OpenAI-compatible or Anthropic endpoint
-configured by an admin in **System Settings > AI Integration > AI Model**.
-Per-model pricing and an optional token/cost quota are also defined there.
+The `llm` library connects to any OpenAI-compatible or Anthropic endpoint
+configured by an admin in **System Settings > AI Integration > AI Model**
+(the settings tab and its `/system/aimodel/...` routes kept their original
+"AI Model" name; only the requirelib identifier changed from `aimodel` to
+`llm`). Per-model pricing and an optional token/cost quota are also defined
+there. The wire-protocol logic (OpenAI / Anthropic request building and
+response parsing) lives in the standalone `mod/aiservers/llm` Go package.
 
-### `aimodel.chat(prompt, options)` → string
+### `llm.chat(prompt, options)` → string
 Sends a single-turn text prompt and returns the assistant's reply.
 `options` is an optional object (see Options below).
 
 ```javascript
-requirelib("aimodel");
-var reply = aimodel.chat("What is the capital of France?");
+requirelib("llm");
+var reply = llm.chat("What is the capital of France?");
 sendResp(reply);
 ```
 
 With a system prompt and model override:
 
 ```javascript
-requirelib("aimodel");
-var reply = aimodel.chat("Summarise this in one sentence.", {
+requirelib("llm");
+var reply = llm.chat("Summarise this in one sentence.", {
     system: "You are a concise summariser.",
     model:  "gpt-4o-mini"
 });
 sendResp(reply);
 ```
 
-### `aimodel.chatWithFile(prompt, files, options)` → string
-Like `aimodel.chat()` but attaches one or more virtual-path files to the message.
+### `llm.chatWithFile(prompt, files, options)` → string
+Like `llm.chat()` but attaches one or more virtual-path files to the message.
 Images are sent as base64 vision parts; text files are inlined as labelled text.
 `files` may be a single vpath string or an array.
 
 ```javascript
-requirelib("aimodel");
-var reply = aimodel.chatWithFile(
+requirelib("llm");
+var reply = llm.chatWithFile(
     "Describe what you see in this image.",
     "user:/Photos/holiday.jpg"
 );
 sendResp(reply);
 ```
 
-### `aimodel.request(messages, options)` → object
+### `llm.request(messages, options)` → object
 Low-level call. Accepts the full OpenAI-style messages array and returns the
 raw response object (including `usage` and `choices`).
 
 ```javascript
-requirelib("aimodel");
-var resp = aimodel.request([
+requirelib("llm");
+var resp = llm.request([
     { role: "system",    content: "You are helpful." },
     { role: "user",      content: "Hi!" },
     { role: "assistant", content: "Hello! How can I help?" },
@@ -943,45 +947,45 @@ var resp = aimodel.request([
 sendResp(resp.choices[0].message.content);
 ```
 
-### `aimodel.usage()` → object
+### `llm.usage()` → object
 Returns accumulated token / cost metrics across all models.
 
 ```javascript
-requirelib("aimodel");
-var u = aimodel.usage();
+requirelib("llm");
+var u = llm.usage();
 sendJSONResp(u);
 // { totalTokens, totalCost, totalRequests, perModel: { ... }, currency, ... }
 ```
 
-### `aimodel.models()` → object
+### `llm.models()` → object
 Returns the configured default model name and a list of models that have
 pricing entries defined in System Settings.
 
 ```javascript
-requirelib("aimodel");
-var m = aimodel.models();
+requirelib("llm");
+var m = llm.models();
 sendJSONResp(m);
 // { default: "gpt-4o", models: ["gpt-4o", "gpt-4o-mini", ...] }
 ```
 
-### `aimodel.listModels()` → object
+### `llm.listModels()` → object
 Queries the live endpoint for available models (does not consume tokens).
 
 ```javascript
-requirelib("aimodel");
-var m = aimodel.listModels();
+requirelib("llm");
+var m = llm.listModels();
 sendJSONResp(m.models);
 ```
 
-### `aimodel.fileParts(files)` → object[]
+### `llm.fileParts(files)` → object[]
 Converts virtual-path file(s) into OpenAI-style content parts that can be
-embedded in a `messages` array for `aimodel.request()`.
+embedded in a `messages` array for `llm.request()`.
 Images become `image_url` data-URI parts; text files become `text` parts.
 
 ```javascript
-requirelib("aimodel");
-var parts = aimodel.fileParts(["user:/report.txt"]);
-var resp  = aimodel.request([
+requirelib("llm");
+var parts = llm.fileParts(["user:/report.txt"]);
+var resp  = llm.request([
     { role: "user", content: parts }
 ]);
 sendResp(resp.choices[0].message.content);

+ 6 - 6
src/mod/agi/agi.aichat_backend_test.go

@@ -17,19 +17,19 @@ import (
 /*
 	Backend script tests for the AI Chat demo app (web/AIChat/backend/*.agi).
 
-	These execute the real .agi scripts inside an otto VM with the real aimodel
+	These execute the real .agi scripts inside an otto VM with the real llm
 	library injected (pointed at a mock OpenAI-compatible server), so the demo
 	app's backend logic is verified without a running arozos server or a real
 	model endpoint.
 */
 
-// runAIChatBackend loads a backend script, injects the aimodel lib + stubs for
+// runAIChatBackend loads a backend script, injects the llm lib + stubs for
 // requirelib/sendJSONResp, sets the given POST params and returns whatever the
 // script passed to sendJSONResp.
 func runAIChatBackend(t *testing.T, g *Gateway, scriptRelPath string, params map[string]string) string {
 	t.Helper()
 	vm := otto.New()
-	g.injectAIModelFunctions(&static.AgiLibInjectionPayload{VM: vm, User: &user.User{Username: "tester"}})
+	g.injectLLMFunctions(&static.AgiLibInjectionPayload{VM: vm, User: &user.User{Username: "tester"}})
 
 	//requirelib is a no-op here: the lib is already injected above.
 	vm.Set("requirelib", func(call otto.FunctionCall) otto.Value {
@@ -74,7 +74,7 @@ func TestAIChatBackend_Chat(t *testing.T) {
 
 	g := dbGateway(t)
 	sysdb := g.Option.UserHandler.GetDatabase()
-	sysdb.Write(aiModelDBTable, "config", AIModelConfig{Endpoint: srv.URL, DefaultModel: "test-model", Currency: "USD"})
+	sysdb.Write(llmDBTable, "config", LLMConfig{Endpoint: srv.URL, DefaultModel: "test-model", Currency: "USD"})
 
 	out := runAIChatBackend(t, g, "AIChat/backend/chat.agi", map[string]string{
 		"messages": `[{"role":"user","content":"hi"}]`,
@@ -109,8 +109,8 @@ func TestAIChatBackend_ChatNoEndpointReturnsError(t *testing.T) {
 func TestAIChatBackend_Models(t *testing.T) {
 	g := dbGateway(t)
 	sysdb := g.Option.UserHandler.GetDatabase()
-	sysdb.Write(aiModelDBTable, "config", AIModelConfig{DefaultModel: "test-model", Currency: "USD"})
-	sysdb.Write(aiModelDBTable, "pricing", map[string]AIModelPricing{
+	sysdb.Write(llmDBTable, "config", LLMConfig{DefaultModel: "test-model", Currency: "USD"})
+	sysdb.Write(llmDBTable, "pricing", map[string]LLMPricing{
 		"test-model": {InputPrice: 1, OutputPrice: 2},
 		"other":      {InputPrice: 3, OutputPrice: 4},
 	})

+ 0 - 1254
src/mod/agi/agi.aimodel.go

@@ -1,1254 +0,0 @@
-package agi
-
-import (
-	"bytes"
-	"encoding/base64"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"io"
-	"mime"
-	"net/http"
-	"os"
-	"path/filepath"
-	"strconv"
-	"strings"
-	"sync"
-	"time"
-	"unicode/utf8"
-
-	"github.com/robertkrimen/otto"
-
-	"imuslab.com/arozos/mod/agi/static"
-	"imuslab.com/arozos/mod/filesystem"
-	user "imuslab.com/arozos/mod/user"
-	"imuslab.com/arozos/mod/utils"
-)
-
-/*
-	AJGI AI Model Library
-
-	This library allows AGI scripts to call any OpenAI-compatible chat
-	completion endpoint (OpenAI, Azure OpenAI, OpenRouter, Ollama, LM Studio,
-	llama.cpp server, vLLM ...). It supports both plain text prompts and
-	file-based prompts (images for vision models and text documents inlined
-	into the conversation).
-
-	The global endpoint / API key / default model are configured by an admin
-	in System Settings > Developer Options > AI Model. Per-model pricing is
-	also defined there so the system can keep a running tally of how many
-	tokens have been consumed and how much it has cost.
-
-	Author: tobychui (AGI), AI Model lib addition
-*/
-
-const (
-	//aiModelDBTable is the system database table used to persist the AI model
-	//configuration, per-model pricing and aggregated usage metrics.
-	aiModelDBTable = "aimodel"
-
-	//aiModelKeyMask is the sentinel value the frontend submits when the API
-	//key field was left untouched. When received, the stored key is kept.
-	aiModelKeyMask = "********"
-
-	//aiModelRequestTimeout is the maximum time to wait for a completion.
-	aiModelRequestTimeout = 120 * time.Second
-
-	//aiModelAnthropicVersion is the API version header sent to the Anthropic API.
-	aiModelAnthropicVersion = "2023-06-01"
-
-	//aiModelAnthropicDefaultMaxTokens is used when a script does not specify
-	//max_tokens; the Anthropic Messages API requires this field.
-	aiModelAnthropicDefaultMaxTokens = 4096
-)
-
-// aiModelMetricsMux guards read-modify-write cycles on the metrics record so
-// concurrent AGI scripts do not clobber each other's usage updates.
-var aiModelMetricsMux sync.Mutex
-
-// ── Persisted data structures ───────────────────────────────────────────────
-
-// AIModelConfig holds the global, admin-configured connection settings.
-type AIModelConfig struct {
-	Endpoint     string `json:"endpoint"`     //Base URL, e.g. https://api.openai.com/v1 or https://api.anthropic.com
-	APIKey       string `json:"apikey"`       //API key (Bearer for OpenAI, x-api-key for Anthropic)
-	DefaultModel string `json:"defaultModel"` //Model used when a script does not specify one
-	APIFormat    string `json:"apiFormat"`    //Wire format: "openai" (default) or "anthropic"
-	Currency     string `json:"currency"`     //Currency label used by the metrics board (default USD)
-}
-
-// AIModelQuota defines an optional cap on token / cost consumption so the
-// system cannot keep spending once a budget is reached.
-type AIModelQuota struct {
-	Enabled   bool    `json:"enabled"`   //When true, requests are blocked once a cap is hit
-	MaxTokens int64   `json:"maxTokens"` //Total token cap for the period (0 = no token cap)
-	MaxCost   float64 `json:"maxCost"`   //Total cost cap for the period (0 = no cost cap)
-	Period    string  `json:"period"`    //Reset window: "total" (never), "daily" or "monthly"
-}
-
-// periodLabel returns a human-friendly label for the quota period.
-func (q AIModelQuota) periodLabel() string {
-	switch q.Period {
-	case "daily":
-		return "day"
-	case "monthly":
-		return "month"
-	default:
-		return "total"
-	}
-}
-
-// AIModelPricing defines the price per 1,000,000 tokens for a given model.
-type AIModelPricing struct {
-	InputPrice  float64 `json:"inputPrice"`  //Cost per 1M prompt (input) tokens
-	OutputPrice float64 `json:"outputPrice"` //Cost per 1M completion (output) tokens
-}
-
-// AIModelUsageRecord is the accumulated usage of a single model.
-type AIModelUsageRecord struct {
-	PromptTokens     int64   `json:"promptTokens"`
-	CompletionTokens int64   `json:"completionTokens"`
-	TotalTokens      int64   `json:"totalTokens"`
-	Cost             float64 `json:"cost"`
-	Requests         int64   `json:"requests"`
-	GenerationMs     int64   `json:"generationMs"` //total generation time
-	//Sum and count of per-request tokens/sec, so the average speed is the mean
-	//of the per-request speeds (matching what is shown on each reply).
-	SpeedSum     float64 `json:"speedSum"`
-	SpeedSamples int64   `json:"speedSamples"`
-}
-
-// AIModelMetrics is the aggregated consumption across every model.
-type AIModelMetrics struct {
-	TotalPromptTokens     int64                          `json:"totalPromptTokens"`
-	TotalCompletionTokens int64                          `json:"totalCompletionTokens"`
-	TotalTokens           int64                          `json:"totalTokens"`
-	TotalCost             float64                        `json:"totalCost"`
-	TotalRequests         int64                          `json:"totalRequests"`
-	TotalGenerationMs     int64                          `json:"totalGenerationMs"`
-	SpeedSum              float64                        `json:"speedSum"`     //sum of per-request tok/s
-	SpeedSamples          int64                          `json:"speedSamples"` //count of timed requests
-	PerModel              map[string]*AIModelUsageRecord `json:"perModel"`
-	Currency              string                         `json:"currency"`
-	UpdatedAt             int64                          `json:"updatedAt"`
-
-	//Windowed usage used for quota enforcement (reset per quota period).
-	WindowStart  int64   `json:"windowStart"`  //Unix time the current quota window began
-	WindowTokens int64   `json:"windowTokens"` //Tokens consumed in the current window
-	WindowCost   float64 `json:"windowCost"`   //Cost consumed in the current window
-}
-
-// ── OpenAI-compatible wire structures ────────────────────────────────────────
-
-type aiContentImageURL struct {
-	URL string `json:"url"`
-}
-
-type aiContentPart struct {
-	Type     string             `json:"type"`
-	Text     string             `json:"text,omitempty"`
-	ImageURL *aiContentImageURL `json:"image_url,omitempty"`
-}
-
-type aiChatMessage struct {
-	Role    string      `json:"role"`
-	Content interface{} `json:"content"` //string for text, []aiContentPart for multimodal
-}
-
-type aiChatRequest struct {
-	Model       string          `json:"model"`
-	Messages    []aiChatMessage `json:"messages"`
-	Temperature *float64        `json:"temperature,omitempty"`
-	MaxTokens   *int            `json:"max_tokens,omitempty"`
-	Stream      bool            `json:"stream"`
-}
-
-type aiChatResponse struct {
-	Model   string `json:"model"`
-	Choices []struct {
-		Index   int `json:"index"`
-		Message struct {
-			Role    string `json:"role"`
-			Content string `json:"content"`
-		} `json:"message"`
-		FinishReason string `json:"finish_reason"`
-	} `json:"choices"`
-	Usage struct {
-		PromptTokens     int64   `json:"prompt_tokens"`
-		CompletionTokens int64   `json:"completion_tokens"`
-		TotalTokens      int64   `json:"total_tokens"`
-		TokensPerSecond  float64 `json:"tokens_per_second"` //completion tokens / generation time
-		GenerationMs     int64   `json:"generation_ms"`     //wall-clock duration of the request
-	} `json:"usage"`
-	Error *struct {
-		Message string `json:"message"`
-		Type    string `json:"type"`
-	} `json:"error,omitempty"`
-}
-
-// aiChatOptions are the per-call options a script may pass as a JS object.
-type aiChatOptions struct {
-	Model       string   `json:"model"`       //Override the configured default model
-	System      string   `json:"system"`      //Optional system prompt
-	Endpoint    string   `json:"endpoint"`    //Override the global endpoint
-	APIKey      string   `json:"apikey"`      //Override the global API key
-	APIFormat   string   `json:"apiFormat"`   //Override the wire format ("openai"/"anthropic")
-	Temperature *float64 `json:"temperature"` //Sampling temperature
-	MaxTokens   *int     `json:"max_tokens"`  //Maximum tokens to generate
-}
-
-// ── Anthropic-compatible wire structures ─────────────────────────────────────
-
-type anthropicImageSource struct {
-	Type      string `json:"type"`                 //"base64" or "url"
-	MediaType string `json:"media_type,omitempty"` //e.g. image/png (base64 only)
-	Data      string `json:"data,omitempty"`       //base64 payload (base64 only)
-	URL       string `json:"url,omitempty"`        //remote URL (url source only)
-}
-
-type anthropicContentBlock struct {
-	Type   string                `json:"type"` //"text" or "image"
-	Text   string                `json:"text,omitempty"`
-	Source *anthropicImageSource `json:"source,omitempty"`
-}
-
-type anthropicMessage struct {
-	Role    string      `json:"role"`    //"user" or "assistant"
-	Content interface{} `json:"content"` //string or []anthropicContentBlock
-}
-
-type anthropicRequest struct {
-	Model       string             `json:"model"`
-	MaxTokens   int                `json:"max_tokens"`
-	System      string             `json:"system,omitempty"`
-	Messages    []anthropicMessage `json:"messages"`
-	Temperature *float64           `json:"temperature,omitempty"`
-	Stream      bool               `json:"stream"`
-}
-
-type anthropicResponse struct {
-	Model   string `json:"model"`
-	Content []struct {
-		Type string `json:"type"`
-		Text string `json:"text"`
-	} `json:"content"`
-	Usage struct {
-		InputTokens  int64 `json:"input_tokens"`
-		OutputTokens int64 `json:"output_tokens"`
-	} `json:"usage"`
-	StopReason string `json:"stop_reason"`
-	Error      *struct {
-		Type    string `json:"type"`
-		Message string `json:"message"`
-	} `json:"error,omitempty"`
-}
-
-// ── Library registration ─────────────────────────────────────────────────────
-
-func (g *Gateway) AIModelLibRegister() {
-	//Make sure the storage table exists before any read / write happens.
-	sysdb := g.Option.UserHandler.GetDatabase()
-	if !sysdb.TableExists(aiModelDBTable) {
-		sysdb.NewTable(aiModelDBTable)
-	}
-
-	err := g.RegisterLib("aimodel", g.injectAIModelFunctions)
-	if err != nil {
-		agiLogger.PrintAndLog("Agi", fmt.Sprint(err), nil)
-		os.Exit(1)
-	}
-}
-
-func (g *Gateway) injectAIModelFunctions(payload *static.AgiLibInjectionPayload) {
-	vm := payload.VM
-	u := payload.User
-	scriptFsh := payload.ScriptFsh
-
-	//aimodel.chat(prompt, options) => assistant reply text
-	vm.Set("_aimodel_chat", func(call otto.FunctionCall) otto.Value {
-		prompt, _ := call.Argument(0).ToString()
-		opt := parseAIModelOptions(getOttoStringArg(call, 1))
-
-		messages := []aiChatMessage{}
-		if strings.TrimSpace(opt.System) != "" {
-			messages = append(messages, aiChatMessage{Role: "system", Content: opt.System})
-		}
-		messages = append(messages, aiChatMessage{Role: "user", Content: prompt})
-
-		resp, err := g.aiModelDoRequest(opt.Model, messages, opt)
-		if err != nil {
-			panic(vm.MakeCustomError("AIModelError", err.Error()))
-		}
-		reply, _ := vm.ToValue(aiModelExtractContent(resp))
-		return reply
-	})
-
-	//aimodel.chatWithFile(prompt, files, options) => assistant reply text
-	//files may be a single vpath or an array of vpaths. Images are sent as
-	//vision image_url parts; textual files are inlined as text parts.
-	vm.Set("_aimodel_chatWithFile", func(call otto.FunctionCall) otto.Value {
-		prompt, _ := call.Argument(0).ToString()
-		filesJSON := getOttoStringArg(call, 1)
-		opt := parseAIModelOptions(getOttoStringArg(call, 2))
-
-		var vpaths []string
-		if err := json.Unmarshal([]byte(filesJSON), &vpaths); err != nil || len(vpaths) == 0 {
-			panic(vm.MakeCustomError("AIModelError", "no file path(s) provided"))
-		}
-
-		parts := []aiContentPart{}
-		if strings.TrimSpace(prompt) != "" {
-			parts = append(parts, aiContentPart{Type: "text", Text: prompt})
-		}
-		for _, vpath := range vpaths {
-			fileParts, err := g.aiModelBuildFileParts(scriptFsh, vm, u, vpath)
-			if err != nil {
-				panic(vm.MakeCustomError("AIModelError", err.Error()))
-			}
-			parts = append(parts, fileParts...)
-		}
-
-		messages := []aiChatMessage{}
-		if strings.TrimSpace(opt.System) != "" {
-			messages = append(messages, aiChatMessage{Role: "system", Content: opt.System})
-		}
-		messages = append(messages, aiChatMessage{Role: "user", Content: parts})
-
-		resp, err := g.aiModelDoRequest(opt.Model, messages, opt)
-		if err != nil {
-			panic(vm.MakeCustomError("AIModelError", err.Error()))
-		}
-		reply, _ := vm.ToValue(aiModelExtractContent(resp))
-		return reply
-	})
-
-	//aimodel.request(messages, options) => full response object (JSON string)
-	//Gives advanced scripts access to usage information and finish reason.
-	vm.Set("_aimodel_request", func(call otto.FunctionCall) otto.Value {
-		messagesJSON := getOttoStringArg(call, 0)
-		opt := parseAIModelOptions(getOttoStringArg(call, 1))
-
-		var messages []aiChatMessage
-		if err := json.Unmarshal([]byte(messagesJSON), &messages); err != nil {
-			panic(vm.MakeCustomError("AIModelError", "invalid messages array: "+err.Error()))
-		}
-
-		resp, err := g.aiModelDoRequest(opt.Model, messages, opt)
-		if err != nil {
-			panic(vm.MakeCustomError("AIModelError", err.Error()))
-		}
-		out, _ := json.Marshal(resp)
-		reply, _ := vm.ToValue(string(out))
-		return reply
-	})
-
-	//aimodel.usage() => aggregated metrics object (JSON string)
-	vm.Set("_aimodel_usage", func(call otto.FunctionCall) otto.Value {
-		out, _ := json.Marshal(g.getAIModelMetrics())
-		reply, _ := vm.ToValue(string(out))
-		return reply
-	})
-
-	//aimodel.models() => { default: "...", models: [...] } (JSON string)
-	vm.Set("_aimodel_models", func(call otto.FunctionCall) otto.Value {
-		cfg := g.getAIModelConfig()
-		pricing := g.getAIModelPricing()
-		models := []string{}
-		for name := range pricing {
-			models = append(models, name)
-		}
-		out, _ := json.Marshal(map[string]interface{}{
-			"default": cfg.DefaultModel,
-			"models":  models,
-		})
-		reply, _ := vm.ToValue(string(out))
-		return reply
-	})
-
-	//aimodel.listModels() => { models: [...] } from the live endpoint (JSON string)
-	vm.Set("_aimodel_listModels", func(call otto.FunctionCall) otto.Value {
-		cfg := g.getAIModelConfig()
-		result := map[string]interface{}{"models": []string{}}
-		models, err := g.aiModelListEndpointModels(cfg.Endpoint, cfg.APIKey, cfg.APIFormat)
-		if err != nil {
-			result["error"] = err.Error()
-		} else {
-			result["models"] = models
-		}
-		out, _ := json.Marshal(result)
-		reply, _ := vm.ToValue(string(out))
-		return reply
-	})
-
-	//aimodel.fileParts(files) => JSON array of OpenAI-style content parts for
-	//the given virtual file path(s). Images become image_url data URIs, text
-	//documents are inlined. Scripts can embed these into a message's content.
-	vm.Set("_aimodel_fileParts", func(call otto.FunctionCall) otto.Value {
-		filesJSON := getOttoStringArg(call, 0)
-		var vpaths []string
-		if err := json.Unmarshal([]byte(filesJSON), &vpaths); err != nil {
-			panic(vm.MakeCustomError("AIModelError", "invalid files array: "+err.Error()))
-		}
-		parts := []aiContentPart{}
-		for _, vp := range vpaths {
-			fp, err := g.aiModelBuildFileParts(scriptFsh, vm, u, vp)
-			if err != nil {
-				panic(vm.MakeCustomError("AIModelError", err.Error()))
-			}
-			parts = append(parts, fp...)
-		}
-		out, _ := json.Marshal(parts)
-		reply, _ := vm.ToValue(string(out))
-		return reply
-	})
-
-	//Wrap the native functions into a clean aimodel class
-	vm.Run(`
-		var aimodel = {};
-		aimodel.chat = function(prompt, options){
-			return _aimodel_chat(prompt, JSON.stringify(options || {}));
-		};
-		aimodel.chatWithFile = function(prompt, files, options){
-			if (typeof files === "string"){ files = [files]; }
-			return _aimodel_chatWithFile(prompt, JSON.stringify(files || []), JSON.stringify(options || {}));
-		};
-		aimodel.request = function(messages, options){
-			return JSON.parse(_aimodel_request(JSON.stringify(messages || []), JSON.stringify(options || {})));
-		};
-		aimodel.usage = function(){
-			return JSON.parse(_aimodel_usage());
-		};
-		aimodel.models = function(){
-			return JSON.parse(_aimodel_models());
-		};
-		aimodel.listModels = function(){
-			return JSON.parse(_aimodel_listModels());
-		};
-		aimodel.fileParts = function(files){
-			if (typeof files === "string"){ files = [files]; }
-			return JSON.parse(_aimodel_fileParts(JSON.stringify(files || [])));
-		};
-	`)
-}
-
-// ── Core request logic ───────────────────────────────────────────────────────
-
-// aiModelDoRequest resolves the connection settings, enforces any usage quota,
-// dispatches to the configured wire format (OpenAI or Anthropic), records the
-// resulting token usage / cost and returns a unified response.
-func (g *Gateway) aiModelDoRequest(model string, messages []aiChatMessage, opt aiChatOptions) (*aiChatResponse, error) {
-	cfg := g.getAIModelConfig()
-
-	endpoint := strings.TrimSpace(cfg.Endpoint)
-	apikey := cfg.APIKey
-	format := cfg.APIFormat
-	if strings.TrimSpace(opt.Endpoint) != "" {
-		endpoint = strings.TrimSpace(opt.Endpoint)
-	}
-	if strings.TrimSpace(opt.APIKey) != "" {
-		apikey = strings.TrimSpace(opt.APIKey)
-	}
-	if strings.TrimSpace(opt.APIFormat) != "" {
-		format = strings.TrimSpace(opt.APIFormat)
-	}
-	if format == "" {
-		format = "openai"
-	}
-	if strings.TrimSpace(model) == "" {
-		model = cfg.DefaultModel
-	}
-
-	if endpoint == "" {
-		return nil, errors.New("AI model endpoint is not configured (System Settings > AI Integration > AI Model)")
-	}
-	if strings.TrimSpace(model) == "" {
-		return nil, errors.New("no model specified and no default model configured")
-	}
-
-	//Enforce the usage quota before spending any tokens.
-	if err := g.aiModelCheckQuota(); err != nil {
-		return nil, err
-	}
-
-	var parsed *aiChatResponse
-	var err error
-	start := time.Now()
-	if format == "anthropic" {
-		parsed, err = g.aiModelDoRequestAnthropic(endpoint, apikey, model, messages, opt)
-	} else {
-		parsed, err = g.aiModelDoRequestOpenAI(endpoint, apikey, model, messages, opt)
-	}
-	if err != nil {
-		return nil, err
-	}
-	elapsed := time.Since(start)
-
-	//Compute generation speed (tokens per second) from completion tokens.
-	parsed.Usage.GenerationMs = elapsed.Milliseconds()
-	if parsed.Usage.CompletionTokens > 0 && elapsed.Seconds() > 0 {
-		parsed.Usage.TokensPerSecond = float64(parsed.Usage.CompletionTokens) / elapsed.Seconds()
-	}
-
-	//Record usage. Prefer the model echoed back by the server.
-	usedModel := model
-	if strings.TrimSpace(parsed.Model) != "" {
-		usedModel = parsed.Model
-	}
-	g.recordAIModelUsage(usedModel, parsed.Usage.PromptTokens, parsed.Usage.CompletionTokens, elapsed.Milliseconds())
-
-	return parsed, nil
-}
-
-// aiModelDoRequestOpenAI performs an OpenAI-compatible chat completion call.
-func (g *Gateway) aiModelDoRequestOpenAI(endpoint, apikey, model string, messages []aiChatMessage, opt aiChatOptions) (*aiChatResponse, error) {
-	reqStruct := aiChatRequest{
-		Model:       model,
-		Messages:    messages,
-		Temperature: opt.Temperature,
-		MaxTokens:   opt.MaxTokens,
-		Stream:      false,
-	}
-	body, err := json.Marshal(reqStruct)
-	if err != nil {
-		return nil, err
-	}
-
-	requestURL := strings.TrimRight(endpoint, "/")
-	if !strings.HasSuffix(requestURL, "/chat/completions") {
-		requestURL += "/chat/completions"
-	}
-	req, err := http.NewRequest("POST", requestURL, bytes.NewReader(body))
-	if err != nil {
-		return nil, err
-	}
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("User-Agent", "arozos-aimodel-client/1.0")
-	if apikey != "" {
-		req.Header.Set("Authorization", "Bearer "+apikey)
-	}
-
-	client := &http.Client{Timeout: aiModelRequestTimeout}
-	resp, err := client.Do(req)
-	if err != nil {
-		return nil, errors.New("request to AI endpoint failed: " + err.Error())
-	}
-	defer resp.Body.Close()
-
-	respBody, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, err
-	}
-
-	parsed := &aiChatResponse{}
-	if err := json.Unmarshal(respBody, parsed); err != nil {
-		return nil, fmt.Errorf("unexpected response (HTTP %d): %s", resp.StatusCode, aiModelTruncate(string(respBody), 300))
-	}
-	if parsed.Error != nil && parsed.Error.Message != "" {
-		return nil, errors.New("AI endpoint error: " + parsed.Error.Message)
-	}
-	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
-		return nil, fmt.Errorf("AI endpoint returned HTTP %d: %s", resp.StatusCode, aiModelTruncate(string(respBody), 300))
-	}
-	return parsed, nil
-}
-
-// aiModelDoRequestAnthropic performs an Anthropic Messages API call and maps
-// the result back into the unified aiChatResponse shape.
-func (g *Gateway) aiModelDoRequestAnthropic(endpoint, apikey, model string, messages []aiChatMessage, opt aiChatOptions) (*aiChatResponse, error) {
-	//Anthropic takes the system prompt as a top-level field, not a message.
-	system := strings.TrimSpace(opt.System)
-	amsgs := []anthropicMessage{}
-	for _, m := range messages {
-		if m.Role == "system" {
-			if s, ok := m.Content.(string); ok {
-				if system != "" {
-					system += "\n\n"
-				}
-				system += s
-			}
-			continue
-		}
-		amsgs = append(amsgs, anthropicMessage{Role: m.Role, Content: toAnthropicContent(m.Content)})
-	}
-
-	maxTokens := aiModelAnthropicDefaultMaxTokens
-	if opt.MaxTokens != nil && *opt.MaxTokens > 0 {
-		maxTokens = *opt.MaxTokens
-	}
-
-	reqStruct := anthropicRequest{
-		Model:       model,
-		MaxTokens:   maxTokens,
-		System:      system,
-		Messages:    amsgs,
-		Temperature: opt.Temperature,
-		Stream:      false,
-	}
-	body, err := json.Marshal(reqStruct)
-	if err != nil {
-		return nil, err
-	}
-
-	req, err := http.NewRequest("POST", aiModelAnthropicURL(endpoint), bytes.NewReader(body))
-	if err != nil {
-		return nil, err
-	}
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("User-Agent", "arozos-aimodel-client/1.0")
-	req.Header.Set("anthropic-version", aiModelAnthropicVersion)
-	if apikey != "" {
-		req.Header.Set("x-api-key", apikey)
-	}
-
-	client := &http.Client{Timeout: aiModelRequestTimeout}
-	resp, err := client.Do(req)
-	if err != nil {
-		return nil, errors.New("request to AI endpoint failed: " + err.Error())
-	}
-	defer resp.Body.Close()
-
-	respBody, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, err
-	}
-
-	parsed := &anthropicResponse{}
-	if err := json.Unmarshal(respBody, parsed); err != nil {
-		return nil, fmt.Errorf("unexpected response (HTTP %d): %s", resp.StatusCode, aiModelTruncate(string(respBody), 300))
-	}
-	if parsed.Error != nil && parsed.Error.Message != "" {
-		return nil, errors.New("AI endpoint error: " + parsed.Error.Message)
-	}
-	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
-		return nil, fmt.Errorf("AI endpoint returned HTTP %d: %s", resp.StatusCode, aiModelTruncate(string(respBody), 300))
-	}
-
-	//Map the Anthropic response onto the unified aiChatResponse.
-	var text strings.Builder
-	for _, block := range parsed.Content {
-		if block.Type == "text" {
-			text.WriteString(block.Text)
-		}
-	}
-	unified := &aiChatResponse{Model: parsed.Model}
-	unified.Choices = append(unified.Choices, struct {
-		Index   int `json:"index"`
-		Message struct {
-			Role    string `json:"role"`
-			Content string `json:"content"`
-		} `json:"message"`
-		FinishReason string `json:"finish_reason"`
-	}{})
-	unified.Choices[0].Message.Role = "assistant"
-	unified.Choices[0].Message.Content = text.String()
-	unified.Choices[0].FinishReason = parsed.StopReason
-	unified.Usage.PromptTokens = parsed.Usage.InputTokens
-	unified.Usage.CompletionTokens = parsed.Usage.OutputTokens
-	unified.Usage.TotalTokens = parsed.Usage.InputTokens + parsed.Usage.OutputTokens
-	return unified, nil
-}
-
-// aiModelAnthropicURL builds the Messages endpoint URL from a base URL,
-// tolerating bases with or without a trailing /v1 or /messages.
-func aiModelAnthropicURL(endpoint string) string {
-	base := strings.TrimRight(endpoint, "/")
-	if strings.HasSuffix(base, "/messages") {
-		return base
-	}
-	if strings.HasSuffix(base, "/v1") {
-		return base + "/messages"
-	}
-	return base + "/v1/messages"
-}
-
-// toAnthropicContent converts unified message content (a plain string or an
-// array of OpenAI-style content parts) into Anthropic content blocks.
-func toAnthropicContent(content interface{}) interface{} {
-	switch v := content.(type) {
-	case string:
-		return v
-	case []aiContentPart:
-		blocks := make([]anthropicContentBlock, 0, len(v))
-		for _, p := range v {
-			if p.Type == "text" {
-				blocks = append(blocks, anthropicContentBlock{Type: "text", Text: p.Text})
-			} else if p.Type == "image_url" && p.ImageURL != nil {
-				blocks = append(blocks, anthropicImageBlock(p.ImageURL.URL))
-			}
-		}
-		return blocks
-	case []interface{}:
-		blocks := make([]anthropicContentBlock, 0, len(v))
-		for _, raw := range v {
-			m, ok := raw.(map[string]interface{})
-			if !ok {
-				continue
-			}
-			t, _ := m["type"].(string)
-			if t == "text" {
-				txt, _ := m["text"].(string)
-				blocks = append(blocks, anthropicContentBlock{Type: "text", Text: txt})
-			} else if t == "image_url" {
-				if iu, ok := m["image_url"].(map[string]interface{}); ok {
-					url, _ := iu["url"].(string)
-					blocks = append(blocks, anthropicImageBlock(url))
-				}
-			}
-		}
-		return blocks
-	default:
-		b, _ := json.Marshal(v)
-		return string(b)
-	}
-}
-
-// anthropicImageBlock converts an image URL (data URI or remote) into an
-// Anthropic image content block.
-func anthropicImageBlock(url string) anthropicContentBlock {
-	if strings.HasPrefix(url, "data:") {
-		meta := url[len("data:"):]
-		if comma := strings.Index(meta, ","); comma >= 0 {
-			head := meta[:comma]
-			data := meta[comma+1:]
-			mediaType := strings.TrimSuffix(head, ";base64")
-			if mediaType == "" {
-				mediaType = "image/png"
-			}
-			return anthropicContentBlock{Type: "image", Source: &anthropicImageSource{Type: "base64", MediaType: mediaType, Data: data}}
-		}
-	}
-	return anthropicContentBlock{Type: "image", Source: &anthropicImageSource{Type: "url", URL: url}}
-}
-
-// aiModelListEndpointModels lists model IDs exposed by the endpoint, branching
-// by wire format. Used by the connectivity test and by scripts.
-func (g *Gateway) aiModelListEndpointModels(endpoint, apikey, format string) ([]string, error) {
-	base := strings.TrimRight(endpoint, "/")
-	var requestURL string
-	if format == "anthropic" {
-		if strings.HasSuffix(base, "/models") {
-			requestURL = base
-		} else if strings.HasSuffix(base, "/v1") {
-			requestURL = base + "/models"
-		} else {
-			requestURL = base + "/v1/models"
-		}
-	} else {
-		if strings.HasSuffix(base, "/models") {
-			requestURL = base
-		} else {
-			requestURL = base + "/models"
-		}
-	}
-
-	req, err := http.NewRequest("GET", requestURL, nil)
-	if err != nil {
-		return nil, err
-	}
-	if apikey != "" {
-		if format == "anthropic" {
-			req.Header.Set("x-api-key", apikey)
-			req.Header.Set("anthropic-version", aiModelAnthropicVersion)
-		} else {
-			req.Header.Set("Authorization", "Bearer "+apikey)
-		}
-	}
-
-	client := &http.Client{Timeout: 20 * time.Second}
-	resp, err := client.Do(req)
-	if err != nil {
-		return nil, errors.New("connection failed: " + err.Error())
-	}
-	defer resp.Body.Close()
-
-	respBody, _ := io.ReadAll(resp.Body)
-	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
-		return nil, fmt.Errorf("endpoint returned HTTP %d: %s", resp.StatusCode, aiModelTruncate(string(respBody), 200))
-	}
-
-	//Both OpenAI and Anthropic return a {"data":[{"id":...}]} shape.
-	var modelList struct {
-		Data []struct {
-			ID string `json:"id"`
-		} `json:"data"`
-	}
-	json.Unmarshal(respBody, &modelList)
-	models := []string{}
-	for _, m := range modelList.Data {
-		if strings.TrimSpace(m.ID) != "" {
-			models = append(models, m.ID)
-		}
-	}
-	return models, nil
-}
-
-// aiModelBuildFileParts reads a file from the user's virtual file system and
-// converts it into one or more OpenAI-compatible content parts. Images become
-// base64 data-URI image_url parts (for vision models); textual files are
-// inlined as a labelled text part.
-func (g *Gateway) aiModelBuildFileParts(scriptFsh *filesystem.FileSystemHandler, vm *otto.Otto, u *user.User, vpath string) ([]aiContentPart, error) {
-	//Resolve relative paths against the script's directory
-	vpath = static.RelativeVpathRewrite(scriptFsh, vpath, vm, u)
-
-	if !u.CanRead(vpath) {
-		return nil, errors.New("permission denied: " + vpath)
-	}
-
-	fsh, rpath, err := static.VirtualPathToRealPath(vpath, u)
-	if err != nil {
-		return nil, err
-	}
-	if !fsh.FileSystemAbstraction.FileExists(rpath) {
-		return nil, errors.New("file not found: " + vpath)
-	}
-
-	content, err := fsh.FileSystemAbstraction.ReadFile(rpath)
-	if err != nil {
-		return nil, err
-	}
-
-	ext := strings.ToLower(filepath.Ext(rpath))
-	filename := filepath.Base(rpath)
-
-	if aiModelIsImageExt(ext) {
-		mimeType := mime.TypeByExtension(ext)
-		if mimeType == "" {
-			mimeType = "image/" + strings.TrimPrefix(ext, ".")
-		}
-		dataURI := "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(content)
-		return []aiContentPart{{Type: "image_url", ImageURL: &aiContentImageURL{URL: dataURI}}}, nil
-	}
-
-	//Treat anything that is valid UTF-8 (or has a known text extension) as text.
-	if aiModelIsTextExt(ext) || utf8.Valid(content) {
-		text := "[Attached file: " + filename + "]\n" + string(content)
-		return []aiContentPart{{Type: "text", Text: text}}, nil
-	}
-
-	return nil, errors.New("unsupported file type for file-based chat: " + filename + " (only images and text documents are supported)")
-}
-
-// ── Persistence helpers ──────────────────────────────────────────────────────
-
-func (g *Gateway) getAIModelConfig() AIModelConfig {
-	cfg := AIModelConfig{Currency: "USD", APIFormat: "openai"}
-	sysdb := g.Option.UserHandler.GetDatabase()
-	if sysdb.KeyExists(aiModelDBTable, "config") {
-		sysdb.Read(aiModelDBTable, "config", &cfg)
-		if strings.TrimSpace(cfg.Currency) == "" {
-			cfg.Currency = "USD"
-		}
-		if strings.TrimSpace(cfg.APIFormat) == "" {
-			cfg.APIFormat = "openai"
-		}
-	}
-	return cfg
-}
-
-func (g *Gateway) getAIModelQuota() AIModelQuota {
-	q := AIModelQuota{Period: "total"}
-	sysdb := g.Option.UserHandler.GetDatabase()
-	if sysdb.KeyExists(aiModelDBTable, "quota") {
-		sysdb.Read(aiModelDBTable, "quota", &q)
-		if strings.TrimSpace(q.Period) == "" {
-			q.Period = "total"
-		}
-	}
-	return q
-}
-
-// aiModelWindowExpired reports whether a quota window that started at startUnix
-// has rolled over for the given period as of now.
-func aiModelWindowExpired(startUnix int64, period string, now time.Time) bool {
-	if startUnix <= 0 {
-		return true
-	}
-	start := time.Unix(startUnix, 0).UTC()
-	n := now.UTC()
-	switch period {
-	case "daily":
-		return start.YearDay() != n.YearDay() || start.Year() != n.Year()
-	case "monthly":
-		return start.Month() != n.Month() || start.Year() != n.Year()
-	default: //"total" never expires
-		return false
-	}
-}
-
-// aiModelCurrentWindowUsage returns the effective token / cost usage for the
-// active quota window (zero if the window has rolled over).
-func (g *Gateway) aiModelCurrentWindowUsage() (int64, float64) {
-	q := g.getAIModelQuota()
-	m := g.getAIModelMetrics()
-	if aiModelWindowExpired(m.WindowStart, q.Period, time.Now()) {
-		return 0, 0
-	}
-	return m.WindowTokens, m.WindowCost
-}
-
-// aiModelCheckQuota returns an error when a configured quota has been reached.
-func (g *Gateway) aiModelCheckQuota() error {
-	q := g.getAIModelQuota()
-	if !q.Enabled {
-		return nil
-	}
-	usedTokens, usedCost := g.aiModelCurrentWindowUsage()
-	if q.MaxTokens > 0 && usedTokens >= q.MaxTokens {
-		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())
-	}
-	if q.MaxCost > 0 && usedCost >= q.MaxCost {
-		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())
-	}
-	return nil
-}
-
-func (g *Gateway) getAIModelPricing() map[string]AIModelPricing {
-	pricing := map[string]AIModelPricing{}
-	sysdb := g.Option.UserHandler.GetDatabase()
-	if sysdb.KeyExists(aiModelDBTable, "pricing") {
-		sysdb.Read(aiModelDBTable, "pricing", &pricing)
-	}
-	return pricing
-}
-
-func (g *Gateway) getAIModelMetrics() *AIModelMetrics {
-	metrics := &AIModelMetrics{PerModel: map[string]*AIModelUsageRecord{}}
-	sysdb := g.Option.UserHandler.GetDatabase()
-	if sysdb.KeyExists(aiModelDBTable, "metrics") {
-		sysdb.Read(aiModelDBTable, "metrics", metrics)
-		if metrics.PerModel == nil {
-			metrics.PerModel = map[string]*AIModelUsageRecord{}
-		}
-	}
-	//Keep currency label in sync with the current config.
-	metrics.Currency = g.getAIModelConfig().Currency
-	return metrics
-}
-
-// recordAIModelUsage atomically adds the given token counts (and their
-// computed cost from the configured pricing) into the persisted metrics. The
-// optional genMs argument is the request's generation time, accumulated so the
-// metrics board can report an average tokens-per-second.
-func (g *Gateway) recordAIModelUsage(model string, promptTokens int64, completionTokens int64, genMs ...int64) {
-	var generationMs int64
-	if len(genMs) > 0 {
-		generationMs = genMs[0]
-	}
-
-	aiModelMetricsMux.Lock()
-	defer aiModelMetricsMux.Unlock()
-
-	sysdb := g.Option.UserHandler.GetDatabase()
-	metrics := &AIModelMetrics{PerModel: map[string]*AIModelUsageRecord{}}
-	if sysdb.KeyExists(aiModelDBTable, "metrics") {
-		sysdb.Read(aiModelDBTable, "metrics", metrics)
-		if metrics.PerModel == nil {
-			metrics.PerModel = map[string]*AIModelUsageRecord{}
-		}
-	}
-
-	pricing := g.getAIModelPricing()
-	p := pricing[model]
-	cost := float64(promptTokens)/1000000.0*p.InputPrice + float64(completionTokens)/1000000.0*p.OutputPrice
-
-	rec := metrics.PerModel[model]
-	if rec == nil {
-		rec = &AIModelUsageRecord{}
-		metrics.PerModel[model] = rec
-	}
-	rec.PromptTokens += promptTokens
-	rec.CompletionTokens += completionTokens
-	rec.TotalTokens += promptTokens + completionTokens
-	rec.Cost += cost
-	rec.Requests++
-	rec.GenerationMs += generationMs
-
-	metrics.TotalPromptTokens += promptTokens
-	metrics.TotalCompletionTokens += completionTokens
-	metrics.TotalTokens += promptTokens + completionTokens
-	metrics.TotalCost += cost
-	metrics.TotalRequests++
-	metrics.TotalGenerationMs += generationMs
-	metrics.UpdatedAt = time.Now().Unix()
-
-	//Accumulate this request's speed (tokens/sec) so the reported average is the
-	//mean of per-request speeds, consistent with the per-reply figures.
-	if completionTokens > 0 && generationMs > 0 {
-		speed := float64(completionTokens) / (float64(generationMs) / 1000.0)
-		rec.SpeedSum += speed
-		rec.SpeedSamples++
-		metrics.SpeedSum += speed
-		metrics.SpeedSamples++
-	}
-
-	//Maintain the windowed usage used for quota enforcement. Reset the window
-	//first if it has rolled over for the configured quota period.
-	now := time.Now()
-	period := g.getAIModelQuota().Period
-	if metrics.WindowStart == 0 || aiModelWindowExpired(metrics.WindowStart, period, now) {
-		metrics.WindowStart = now.Unix()
-		metrics.WindowTokens = 0
-		metrics.WindowCost = 0
-	}
-	metrics.WindowTokens += promptTokens + completionTokens
-	metrics.WindowCost += cost
-
-	if err := sysdb.Write(aiModelDBTable, "metrics", metrics); err != nil {
-		agiLogger.PrintAndLog("Agi", "[AGI] Failed to persist AI model metrics: "+err.Error(), nil)
-	}
-}
-
-// ── HTTP handlers (System Settings) ──────────────────────────────────────────
-
-// HandleAIModelConfig serves GET (masked config) and POST (save config).
-// GET  /system/aimodel/config
-// POST /system/aimodel/config  (endpoint, defaultModel, currency, apikey, clearkey)
-func (g *Gateway) HandleAIModelConfig(w http.ResponseWriter, r *http.Request) {
-	if r.Method == http.MethodGet {
-		cfg := g.getAIModelConfig()
-		js, _ := json.Marshal(map[string]interface{}{
-			"endpoint":     cfg.Endpoint,
-			"defaultModel": cfg.DefaultModel,
-			"apiFormat":    cfg.APIFormat,
-			"currency":     cfg.Currency,
-			"hasKey":       cfg.APIKey != "",
-			"keyHint":      aiModelMaskKey(cfg.APIKey),
-		})
-		utils.SendJSONResponse(w, string(js))
-		return
-	}
-
-	//POST - save. Read raw form values so empty strings are allowed for
-	//endpoint / defaultModel (e.g. when intentionally clearing a field).
-	r.ParseForm()
-	cfg := g.getAIModelConfig()
-	cfg.Endpoint = strings.TrimSpace(r.Form.Get("endpoint"))
-	cfg.DefaultModel = strings.TrimSpace(r.Form.Get("defaultModel"))
-	if format := strings.TrimSpace(r.Form.Get("apiFormat")); format == "anthropic" || format == "openai" {
-		cfg.APIFormat = format
-	}
-	if cfg.APIFormat == "" {
-		cfg.APIFormat = "openai"
-	}
-	if currency := strings.TrimSpace(r.Form.Get("currency")); currency != "" {
-		cfg.Currency = currency
-	}
-
-	//API key: only overwrite when a new, non-sentinel value is supplied.
-	if clear, _ := utils.PostBool(r, "clearkey"); clear {
-		cfg.APIKey = ""
-	} else if apikey := r.Form.Get("apikey"); apikey != "" && apikey != aiModelKeyMask {
-		cfg.APIKey = apikey
-	}
-
-	sysdb := g.Option.UserHandler.GetDatabase()
-	if err := sysdb.Write(aiModelDBTable, "config", cfg); err != nil {
-		utils.SendErrorResponse(w, "failed to save config: "+err.Error())
-		return
-	}
-	utils.SendOK(w)
-}
-
-// HandleAIModelPricing serves GET (pricing map) and POST (save pricing map).
-// GET  /system/aimodel/pricing
-// POST /system/aimodel/pricing  (pricing = JSON of {model:{inputPrice,outputPrice}})
-func (g *Gateway) HandleAIModelPricing(w http.ResponseWriter, r *http.Request) {
-	if r.Method == http.MethodGet {
-		js, _ := json.Marshal(g.getAIModelPricing())
-		utils.SendJSONResponse(w, string(js))
-		return
-	}
-
-	raw, err := utils.PostPara(r, "pricing")
-	if err != nil {
-		utils.SendErrorResponse(w, "missing pricing data")
-		return
-	}
-	pricing := map[string]AIModelPricing{}
-	if err := json.Unmarshal([]byte(raw), &pricing); err != nil {
-		utils.SendErrorResponse(w, "invalid pricing JSON: "+err.Error())
-		return
-	}
-	sysdb := g.Option.UserHandler.GetDatabase()
-	if err := sysdb.Write(aiModelDBTable, "pricing", pricing); err != nil {
-		utils.SendErrorResponse(w, "failed to save pricing: "+err.Error())
-		return
-	}
-	utils.SendOK(w)
-}
-
-// HandleAIModelMetrics returns the aggregated usage metrics.
-// GET /system/aimodel/metrics
-func (g *Gateway) HandleAIModelMetrics(w http.ResponseWriter, r *http.Request) {
-	js, _ := json.Marshal(g.getAIModelMetrics())
-	utils.SendJSONResponse(w, string(js))
-}
-
-// HandleAIModelMetricsReset clears the aggregated usage metrics.
-// POST /system/aimodel/metrics/reset
-func (g *Gateway) HandleAIModelMetricsReset(w http.ResponseWriter, r *http.Request) {
-	aiModelMetricsMux.Lock()
-	defer aiModelMetricsMux.Unlock()
-
-	metrics := &AIModelMetrics{
-		PerModel:  map[string]*AIModelUsageRecord{},
-		UpdatedAt: time.Now().Unix(),
-	}
-	sysdb := g.Option.UserHandler.GetDatabase()
-	if err := sysdb.Write(aiModelDBTable, "metrics", metrics); err != nil {
-		utils.SendErrorResponse(w, "failed to reset metrics: "+err.Error())
-		return
-	}
-	utils.SendOK(w)
-}
-
-// HandleAIModelTest performs a lightweight connectivity check by listing the
-// models exposed by the endpoint. It does not consume any tokens.
-// POST /system/aimodel/test  (optional: endpoint, apikey, apiFormat to test unsaved values)
-func (g *Gateway) HandleAIModelTest(w http.ResponseWriter, r *http.Request) {
-	cfg := g.getAIModelConfig()
-	endpoint := cfg.Endpoint
-	apikey := cfg.APIKey
-	format := cfg.APIFormat
-	if ep := strings.TrimSpace(r.FormValue("endpoint")); ep != "" {
-		endpoint = ep
-	}
-	if k := r.FormValue("apikey"); k != "" && k != aiModelKeyMask {
-		apikey = k
-	}
-	if f := strings.TrimSpace(r.FormValue("apiFormat")); f == "openai" || f == "anthropic" {
-		format = f
-	}
-
-	if strings.TrimSpace(endpoint) == "" {
-		utils.SendErrorResponse(w, "endpoint not configured")
-		return
-	}
-
-	models, err := g.aiModelListEndpointModels(endpoint, apikey, format)
-	if err != nil {
-		utils.SendErrorResponse(w, err.Error())
-		return
-	}
-
-	out, _ := json.Marshal(map[string]interface{}{
-		"ok":         true,
-		"modelCount": len(models),
-		"models":     models,
-	})
-	utils.SendJSONResponse(w, string(out))
-}
-
-// HandleAIModelQuota serves GET (quota + current window usage) and POST (save).
-// GET  /system/aimodel/quota
-// POST /system/aimodel/quota  (enabled, maxTokens, maxCost, period)
-func (g *Gateway) HandleAIModelQuota(w http.ResponseWriter, r *http.Request) {
-	if r.Method == http.MethodGet {
-		q := g.getAIModelQuota()
-		usedTokens, usedCost := g.aiModelCurrentWindowUsage()
-		js, _ := json.Marshal(map[string]interface{}{
-			"enabled":    q.Enabled,
-			"maxTokens":  q.MaxTokens,
-			"maxCost":    q.MaxCost,
-			"period":     q.Period,
-			"usedTokens": usedTokens,
-			"usedCost":   usedCost,
-			"currency":   g.getAIModelConfig().Currency,
-		})
-		utils.SendJSONResponse(w, string(js))
-		return
-	}
-
-	r.ParseForm()
-	q := g.getAIModelQuota()
-	q.Enabled, _ = utils.PostBool(r, "enabled")
-
-	q.MaxTokens = 0
-	if n, err := strconv.ParseInt(strings.TrimSpace(r.Form.Get("maxTokens")), 10, 64); err == nil && n >= 0 {
-		q.MaxTokens = n
-	}
-	q.MaxCost = 0
-	if f, err := strconv.ParseFloat(strings.TrimSpace(r.Form.Get("maxCost")), 64); err == nil && f >= 0 {
-		q.MaxCost = f
-	}
-	if p := strings.TrimSpace(r.Form.Get("period")); p == "total" || p == "daily" || p == "monthly" {
-		q.Period = p
-	}
-
-	sysdb := g.Option.UserHandler.GetDatabase()
-	if err := sysdb.Write(aiModelDBTable, "quota", q); err != nil {
-		utils.SendErrorResponse(w, "failed to save quota: "+err.Error())
-		return
-	}
-	utils.SendOK(w)
-}
-
-// ── Small helpers ────────────────────────────────────────────────────────────
-
-func aiModelExtractContent(resp *aiChatResponse) string {
-	if resp == nil || len(resp.Choices) == 0 {
-		return ""
-	}
-	return resp.Choices[0].Message.Content
-}
-
-func parseAIModelOptions(s string) aiChatOptions {
-	opt := aiChatOptions{}
-	s = strings.TrimSpace(s)
-	if s == "" || s == "undefined" || s == "null" {
-		return opt
-	}
-	json.Unmarshal([]byte(s), &opt)
-	return opt
-}
-
-// getOttoStringArg safely reads the nth argument of a call as a string,
-// returning an empty string when the argument is absent or undefined.
-func getOttoStringArg(call otto.FunctionCall, idx int) string {
-	arg := call.Argument(idx)
-	if arg.IsUndefined() || arg.IsNull() {
-		return ""
-	}
-	s, err := arg.ToString()
-	if err != nil {
-		return ""
-	}
-	return s
-}
-
-func aiModelMaskKey(key string) string {
-	if key == "" {
-		return ""
-	}
-	if len(key) <= 4 {
-		return strings.Repeat("•", len(key))
-	}
-	return "••••" + key[len(key)-4:]
-}
-
-func aiModelTruncate(s string, max int) string {
-	s = strings.TrimSpace(s)
-	if len(s) <= max {
-		return s
-	}
-	return s[:max] + "…"
-}
-
-func aiModelIsImageExt(ext string) bool {
-	switch ext {
-	case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp":
-		return true
-	}
-	return false
-}
-
-func aiModelIsTextExt(ext string) bool {
-	switch ext {
-	case ".txt", ".md", ".markdown", ".csv", ".tsv", ".json", ".xml", ".yaml", ".yml",
-		".html", ".htm", ".js", ".ts", ".go", ".py", ".java", ".c", ".cpp", ".h", ".hpp",
-		".css", ".log", ".ini", ".conf", ".sh", ".bat", ".sql", ".php", ".rb", ".rs",
-		".toml", ".env", ".srt", ".vtt":
-		return true
-	}
-	return false
-}

+ 0 - 222
src/mod/agi/agi.aimodel_anthropic_test.go

@@ -1,222 +0,0 @@
-package agi
-
-import (
-	"encoding/json"
-	"io"
-	"net/http"
-	"net/http/httptest"
-	"strings"
-	"testing"
-	"time"
-)
-
-// ─── Anthropic request flow ─────────────────────────────────────────────────
-
-func TestAIModelDoRequestAnthropicFlow(t *testing.T) {
-	var gotPath, gotKey, gotVersion string
-	var captured anthropicRequest
-
-	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		gotPath = r.URL.Path
-		gotKey = r.Header.Get("x-api-key")
-		gotVersion = r.Header.Get("anthropic-version")
-		body, _ := io.ReadAll(r.Body)
-		json.Unmarshal(body, &captured)
-		w.Header().Set("Content-Type", "application/json")
-		io.WriteString(w, `{"model":"claude-x",
-			"content":[{"type":"text","text":"Hi from Claude"}],
-			"usage":{"input_tokens":30,"output_tokens":12},
-			"stop_reason":"end_turn"}`)
-	}))
-	defer srv.Close()
-
-	g := dbGateway(t)
-	sysdb := g.Option.UserHandler.GetDatabase()
-	sysdb.Write(aiModelDBTable, "config", AIModelConfig{
-		Endpoint: srv.URL, APIKey: "anthropic-key", DefaultModel: "claude-x", APIFormat: "anthropic", Currency: "USD",
-	})
-
-	//A system message in the unified array must be lifted to the top-level field.
-	msgs := []aiChatMessage{
-		{Role: "system", Content: "be brief"},
-		{Role: "user", Content: "hello"},
-	}
-	resp, err := g.aiModelDoRequest("", msgs, aiChatOptions{})
-	if err != nil {
-		t.Fatalf("anthropic request errored: %v", err)
-	}
-	if content := aiModelExtractContent(resp); content != "Hi from Claude" {
-		t.Errorf("unexpected content: %q", content)
-	}
-	if gotPath != "/v1/messages" {
-		t.Errorf("expected /v1/messages, got %q", gotPath)
-	}
-	if gotKey != "anthropic-key" {
-		t.Errorf("expected x-api-key header, got %q", gotKey)
-	}
-	if gotVersion == "" {
-		t.Errorf("expected anthropic-version header to be set")
-	}
-	if captured.System != "be brief" {
-		t.Errorf("system prompt not lifted to top-level: %q", captured.System)
-	}
-	if captured.MaxTokens <= 0 {
-		t.Errorf("anthropic requires max_tokens > 0, got %d", captured.MaxTokens)
-	}
-	for _, m := range captured.Messages {
-		if m.Role == "system" {
-			t.Errorf("system role must not appear in messages array")
-		}
-	}
-	//Usage mapping: input->prompt, output->completion.
-	m := g.getAIModelMetrics()
-	if m.TotalPromptTokens != 30 || m.TotalCompletionTokens != 12 || m.TotalTokens != 42 {
-		t.Errorf("usage not mapped/recorded correctly: %+v", m)
-	}
-}
-
-func TestAnthropicImageBlock(t *testing.T) {
-	b := anthropicImageBlock("data:image/png;base64,AAAA")
-	if b.Type != "image" || b.Source == nil || b.Source.Type != "base64" ||
-		b.Source.MediaType != "image/png" || b.Source.Data != "AAAA" {
-		t.Errorf("data URI not parsed into base64 source: %+v", b.Source)
-	}
-	u := anthropicImageBlock("https://example.com/cat.png")
-	if u.Source == nil || u.Source.Type != "url" || u.Source.URL != "https://example.com/cat.png" {
-		t.Errorf("remote URL not parsed into url source: %+v", u.Source)
-	}
-}
-
-func TestToAnthropicContent(t *testing.T) {
-	if s, ok := toAnthropicContent("plain").(string); !ok || s != "plain" {
-		t.Errorf("string content should pass through")
-	}
-	//Simulate JSON-decoded OpenAI-style parts.
-	parts := []interface{}{
-		map[string]interface{}{"type": "text", "text": "look"},
-		map[string]interface{}{"type": "image_url", "image_url": map[string]interface{}{"url": "data:image/jpeg;base64,ZZ"}},
-	}
-	out, ok := toAnthropicContent(parts).([]anthropicContentBlock)
-	if !ok || len(out) != 2 {
-		t.Fatalf("expected 2 content blocks, got %#v", out)
-	}
-	if out[0].Type != "text" || out[1].Type != "image" {
-		t.Errorf("unexpected block types: %+v", out)
-	}
-}
-
-// ─── Quota enforcement ──────────────────────────────────────────────────────
-
-func TestAIModelQuotaEnforcement(t *testing.T) {
-	g := dbGateway(t)
-	sysdb := g.Option.UserHandler.GetDatabase()
-	sysdb.Write(aiModelDBTable, "quota", AIModelQuota{Enabled: true, MaxTokens: 100, Period: "total"})
-
-	//Under the cap -> allowed.
-	if err := g.aiModelCheckQuota(); err != nil {
-		t.Fatalf("expected no error under quota, got %v", err)
-	}
-
-	//Consume past the cap.
-	g.recordAIModelUsage("m", 80, 40) // 120 tokens > 100
-	if err := g.aiModelCheckQuota(); err == nil {
-		t.Error("expected quota error after exceeding token cap")
-	} else if !strings.Contains(err.Error(), "quota") {
-		t.Errorf("expected a quota error, got %v", err)
-	}
-
-	//Disabling the quota lifts the block.
-	sysdb.Write(aiModelDBTable, "quota", AIModelQuota{Enabled: false, MaxTokens: 100, Period: "total"})
-	if err := g.aiModelCheckQuota(); err != nil {
-		t.Errorf("disabled quota should not block, got %v", err)
-	}
-}
-
-func TestAIModelDoRequestBlockedByQuota(t *testing.T) {
-	g := dbGateway(t)
-	sysdb := g.Option.UserHandler.GetDatabase()
-	sysdb.Write(aiModelDBTable, "config", AIModelConfig{Endpoint: "http://127.0.0.1:0", DefaultModel: "m", APIFormat: "openai"})
-	sysdb.Write(aiModelDBTable, "quota", AIModelQuota{Enabled: true, MaxTokens: 10, Period: "total"})
-	g.recordAIModelUsage("m", 20, 0) // exceed
-
-	_, err := g.aiModelDoRequest("m", []aiChatMessage{{Role: "user", Content: "hi"}}, aiChatOptions{})
-	if err == nil || !strings.Contains(err.Error(), "quota") {
-		t.Errorf("expected request to be blocked by quota, got %v", err)
-	}
-}
-
-func TestAIModelRecordsTokensPerSecond(t *testing.T) {
-	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		time.Sleep(25 * time.Millisecond) //ensure a measurable generation time
-		w.Header().Set("Content-Type", "application/json")
-		io.WriteString(w, `{"model":"m","choices":[{"message":{"role":"assistant","content":"hello world"}}],
-			"usage":{"prompt_tokens":5,"completion_tokens":20,"total_tokens":25}}`)
-	}))
-	defer srv.Close()
-
-	g := dbGateway(t)
-	g.Option.UserHandler.GetDatabase().Write(aiModelDBTable, "config", AIModelConfig{Endpoint: srv.URL, DefaultModel: "m", APIFormat: "openai"})
-
-	resp, err := g.aiModelDoRequest("", []aiChatMessage{{Role: "user", Content: "hi"}}, aiChatOptions{})
-	if err != nil {
-		t.Fatalf("request errored: %v", err)
-	}
-	if resp.Usage.GenerationMs <= 0 {
-		t.Errorf("expected generation_ms > 0, got %d", resp.Usage.GenerationMs)
-	}
-	if resp.Usage.TokensPerSecond <= 0 {
-		t.Errorf("expected tokens_per_second > 0, got %v", resp.Usage.TokensPerSecond)
-	}
-
-	m := g.getAIModelMetrics()
-	if m.TotalGenerationMs <= 0 {
-		t.Errorf("expected total generation ms recorded, got %d", m.TotalGenerationMs)
-	}
-	if m.SpeedSamples != 1 || m.SpeedSum <= 0 {
-		t.Errorf("expected one speed sample recorded, got samples=%d sum=%v", m.SpeedSamples, m.SpeedSum)
-	}
-	if rec := m.PerModel["m"]; rec == nil || rec.GenerationMs <= 0 || rec.SpeedSamples != 1 {
-		t.Errorf("per-model speed sample not recorded: %+v", rec)
-	}
-}
-
-// The average speed must be the mean of per-request speeds, not total tokens
-// over total time (which is token-weighted and skews toward large requests).
-func TestAIModelAverageSpeedIsMeanOfRequests(t *testing.T) {
-	g := dbGateway(t)
-	//Request A: 10 tokens in 1000ms -> 10 tok/s
-	g.recordAIModelUsage("m", 0, 10, 1000)
-	//Request B: 1000 tokens in 10000ms -> 100 tok/s
-	g.recordAIModelUsage("m", 0, 1000, 10000)
-
-	m := g.getAIModelMetrics()
-	if m.SpeedSamples != 2 {
-		t.Fatalf("expected 2 speed samples, got %d", m.SpeedSamples)
-	}
-	avg := m.SpeedSum / float64(m.SpeedSamples)
-	//Mean of speeds = (10 + 100) / 2 = 55 (NOT throughput 1010/11 ≈ 91.8).
-	if avg < 54.9 || avg > 55.1 {
-		t.Errorf("expected average speed ~55 tok/s, got %v", avg)
-	}
-}
-
-func TestAIModelWindowExpired(t *testing.T) {
-	now := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
-	if !aiModelWindowExpired(0, "daily", now) {
-		t.Error("zero start should be considered expired")
-	}
-	yesterday := now.AddDate(0, 0, -1).Unix()
-	if !aiModelWindowExpired(yesterday, "daily", now) {
-		t.Error("yesterday should be expired for daily period")
-	}
-	if aiModelWindowExpired(now.Add(-1*time.Hour).Unix(), "daily", now) {
-		t.Error("same day should not be expired for daily period")
-	}
-	lastMonth := now.AddDate(0, -1, 0).Unix()
-	if !aiModelWindowExpired(lastMonth, "monthly", now) {
-		t.Error("last month should be expired for monthly period")
-	}
-	if aiModelWindowExpired(now.AddDate(0, -1, 0).Unix(), "total", now) {
-		t.Error("total period should never expire")
-	}
-}

+ 0 - 261
src/mod/agi/agi.aimodel_test.go

@@ -1,261 +0,0 @@
-package agi
-
-import (
-	"encoding/json"
-	"io"
-	"net/http"
-	"net/http/httptest"
-	"net/url"
-	"path/filepath"
-	"strings"
-	"testing"
-
-	"github.com/robertkrimen/otto"
-	"imuslab.com/arozos/mod/agi/static"
-	database "imuslab.com/arozos/mod/database"
-	user "imuslab.com/arozos/mod/user"
-)
-
-// dbGateway returns a Gateway backed by a throwaway bolt database so the
-// config / pricing / metrics persistence paths can be exercised in tests.
-func dbGateway(t *testing.T) *Gateway {
-	t.Helper()
-	dbfile := filepath.Join(t.TempDir(), "test.db")
-	sysdb, err := database.NewDatabase(dbfile, false)
-	if err != nil {
-		t.Fatalf("failed to create test database: %v", err)
-	}
-	t.Cleanup(func() { sysdb.Close() })
-
-	uh, err := user.NewUserHandler(sysdb, nil, nil, nil, nil)
-	if err != nil {
-		t.Fatalf("failed to create user handler: %v", err)
-	}
-
-	g := minimalGateway()
-	g.Option.UserHandler = uh
-	sysdb.NewTable(aiModelDBTable)
-	return g
-}
-
-// ─── pure helpers ─────────────────────────────────────────────────────────────
-
-func TestParseAIModelOptions(t *testing.T) {
-	if opt := parseAIModelOptions(""); opt.Model != "" {
-		t.Errorf("empty string should yield zero options")
-	}
-	if opt := parseAIModelOptions("undefined"); opt.Model != "" {
-		t.Errorf("'undefined' should yield zero options")
-	}
-	if opt := parseAIModelOptions("null"); opt.Model != "" {
-		t.Errorf("'null' should yield zero options")
-	}
-	opt := parseAIModelOptions(`{"model":"gpt-4o","system":"be brief","temperature":0.5,"max_tokens":42}`)
-	if opt.Model != "gpt-4o" || opt.System != "be brief" {
-		t.Errorf("unexpected parse: %+v", opt)
-	}
-	if opt.Temperature == nil || *opt.Temperature != 0.5 {
-		t.Errorf("temperature not parsed")
-	}
-	if opt.MaxTokens == nil || *opt.MaxTokens != 42 {
-		t.Errorf("max_tokens not parsed")
-	}
-}
-
-func TestAIModelMaskKey(t *testing.T) {
-	cases := map[string]string{
-		"":              "",
-		"abc":           "•••",
-		"sk-1234567890": "••••7890",
-	}
-	for in, want := range cases {
-		if got := aiModelMaskKey(in); got != want {
-			t.Errorf("maskKey(%q) = %q, want %q", in, got, want)
-		}
-	}
-}
-
-func TestAIModelExtClassification(t *testing.T) {
-	if !aiModelIsImageExt(".png") || !aiModelIsImageExt(".jpeg") {
-		t.Error("expected image extensions to be detected")
-	}
-	if aiModelIsImageExt(".txt") {
-		t.Error(".txt should not be an image")
-	}
-	if !aiModelIsTextExt(".md") || !aiModelIsTextExt(".go") {
-		t.Error("expected text extensions to be detected")
-	}
-	if aiModelIsTextExt(".png") {
-		t.Error(".png should not be classified as text")
-	}
-}
-
-// ─── persistence ──────────────────────────────────────────────────────────────
-
-func TestRecordAIModelUsageAccumulatesAndCosts(t *testing.T) {
-	g := dbGateway(t)
-	sysdb := g.Option.UserHandler.GetDatabase()
-
-	//Pricing: $2.50 / 1M input, $10.00 / 1M output
-	sysdb.Write(aiModelDBTable, "pricing", map[string]AIModelPricing{
-		"test-model": {InputPrice: 2.5, OutputPrice: 10.0},
-	})
-
-	g.recordAIModelUsage("test-model", 1000, 500)
-	g.recordAIModelUsage("test-model", 1000, 500)
-
-	m := g.getAIModelMetrics()
-	if m.TotalRequests != 2 {
-		t.Errorf("expected 2 requests, got %d", m.TotalRequests)
-	}
-	if m.TotalPromptTokens != 2000 || m.TotalCompletionTokens != 1000 || m.TotalTokens != 3000 {
-		t.Errorf("unexpected token totals: %+v", m)
-	}
-	//Each call: 1000/1e6*2.5 + 500/1e6*10 = 0.0075 ; two calls => 0.015
-	if got := m.TotalCost; got < 0.01499 || got > 0.01501 {
-		t.Errorf("expected total cost ~0.015, got %v", got)
-	}
-	rec := m.PerModel["test-model"]
-	if rec == nil || rec.Requests != 2 || rec.TotalTokens != 3000 {
-		t.Errorf("per-model record incorrect: %+v", rec)
-	}
-}
-
-func TestGetAIModelConfigDefaultsCurrency(t *testing.T) {
-	g := dbGateway(t)
-	cfg := g.getAIModelConfig()
-	if cfg.Currency != "USD" {
-		t.Errorf("expected default currency USD, got %q", cfg.Currency)
-	}
-}
-
-// ─── full request flow against a mock OpenAI-compatible server ──────────────────
-
-func TestAIModelDoRequestFlow(t *testing.T) {
-	var gotPath, gotAuth, gotModel string
-	var sawUserMessage bool
-
-	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		gotPath = r.URL.Path
-		gotAuth = r.Header.Get("Authorization")
-		body, _ := io.ReadAll(r.Body)
-		var req aiChatRequest
-		json.Unmarshal(body, &req)
-		gotModel = req.Model
-		for _, msg := range req.Messages {
-			if msg.Role == "user" {
-				sawUserMessage = true
-			}
-		}
-		w.Header().Set("Content-Type", "application/json")
-		io.WriteString(w, `{"model":"test-model",
-			"choices":[{"index":0,"message":{"role":"assistant","content":"Hello from mock"},"finish_reason":"stop"}],
-			"usage":{"prompt_tokens":1000,"completion_tokens":500,"total_tokens":1500}}`)
-	}))
-	defer srv.Close()
-
-	g := dbGateway(t)
-	sysdb := g.Option.UserHandler.GetDatabase()
-	sysdb.Write(aiModelDBTable, "config", AIModelConfig{
-		Endpoint:     srv.URL,
-		APIKey:       "test-key",
-		DefaultModel: "test-model",
-		Currency:     "USD",
-	})
-	sysdb.Write(aiModelDBTable, "pricing", map[string]AIModelPricing{
-		"test-model": {InputPrice: 2.5, OutputPrice: 10.0},
-	})
-
-	resp, err := g.aiModelDoRequest("", []aiChatMessage{{Role: "user", Content: "hi"}}, aiChatOptions{})
-	if err != nil {
-		t.Fatalf("aiModelDoRequest returned error: %v", err)
-	}
-	if content := aiModelExtractContent(resp); content != "Hello from mock" {
-		t.Errorf("unexpected content: %q", content)
-	}
-	if gotPath != "/chat/completions" {
-		t.Errorf("expected /chat/completions, got %q", gotPath)
-	}
-	if gotAuth != "Bearer test-key" {
-		t.Errorf("expected bearer auth header, got %q", gotAuth)
-	}
-	if gotModel != "test-model" {
-		t.Errorf("expected default model to be used, got %q", gotModel)
-	}
-	if !sawUserMessage {
-		t.Error("server did not receive a user message")
-	}
-
-	//Metrics should have been recorded from the usage block
-	m := g.getAIModelMetrics()
-	if m.TotalRequests != 1 || m.TotalTokens != 1500 {
-		t.Errorf("metrics not recorded after request: %+v", m)
-	}
-}
-
-func TestAIModelDoRequestNoEndpoint(t *testing.T) {
-	g := dbGateway(t)
-	_, err := g.aiModelDoRequest("m", []aiChatMessage{{Role: "user", Content: "hi"}}, aiChatOptions{})
-	if err == nil {
-		t.Error("expected error when endpoint is not configured")
-	}
-}
-
-// ─── config handler masking ─────────────────────────────────────────────────────
-
-func TestHandleAIModelConfigMaskingAndKeyRetention(t *testing.T) {
-	g := dbGateway(t)
-	sysdb := g.Option.UserHandler.GetDatabase()
-	sysdb.Write(aiModelDBTable, "config", AIModelConfig{
-		Endpoint: "https://api.example.com/v1", APIKey: "sk-supersecret9999", DefaultModel: "m", Currency: "USD",
-	})
-
-	//GET should mask the key
-	rec := httptest.NewRecorder()
-	g.HandleAIModelConfig(rec, httptest.NewRequest("GET", "/system/aimodel/config", nil))
-	var got map[string]interface{}
-	json.Unmarshal(rec.Body.Bytes(), &got)
-	if got["hasKey"] != true {
-		t.Errorf("expected hasKey true, got %v", got["hasKey"])
-	}
-	if hint, _ := got["keyHint"].(string); !strings.HasSuffix(hint, "9999") || strings.Contains(hint, "supersecret") {
-		t.Errorf("key not properly masked: %v", got["keyHint"])
-	}
-
-	//POST without apikey should retain the saved key, but update endpoint
-	form := url.Values{}
-	form.Set("endpoint", "https://new.example.com/v1")
-	form.Set("defaultModel", "m2")
-	form.Set("currency", "EUR")
-	req := httptest.NewRequest("POST", "/system/aimodel/config", strings.NewReader(form.Encode()))
-	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-	g.HandleAIModelConfig(httptest.NewRecorder(), req)
-
-	cfg := g.getAIModelConfig()
-	if cfg.APIKey != "sk-supersecret9999" {
-		t.Errorf("API key should have been retained, got %q", cfg.APIKey)
-	}
-	if cfg.Endpoint != "https://new.example.com/v1" || cfg.DefaultModel != "m2" || cfg.Currency != "EUR" {
-		t.Errorf("config not updated correctly: %+v", cfg)
-	}
-}
-
-// ─── JS object exposure ─────────────────────────────────────────────────────────
-
-func TestInjectAIModelLib_JSObjectExposed(t *testing.T) {
-	g := minimalGateway()
-	vm := otto.New()
-	payload := &static.AgiLibInjectionPayload{VM: vm, User: &user.User{Username: "alice"}}
-	g.injectAIModelFunctions(payload)
-
-	for _, method := range []string{"chat", "chatWithFile", "request", "usage", "models"} {
-		val, err := vm.Run(`typeof aimodel.` + method)
-		if err != nil {
-			t.Fatalf("evaluating aimodel.%s: %v", method, err)
-		}
-		s, _ := val.ToString()
-		if s != "function" {
-			t.Errorf("aimodel.%s should be a function, got %q", method, s)
-		}
-	}
-}

+ 862 - 0
src/mod/agi/agi.llm.go

@@ -0,0 +1,862 @@
+package agi
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"mime"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+	"unicode/utf8"
+
+	"github.com/robertkrimen/otto"
+
+	"imuslab.com/arozos/mod/agi/static"
+	llm "imuslab.com/arozos/mod/aiservers/llm"
+	"imuslab.com/arozos/mod/filesystem"
+	user "imuslab.com/arozos/mod/user"
+	"imuslab.com/arozos/mod/utils"
+)
+
+/*
+	AJGI LLM Library
+
+	This library allows AGI scripts to call any OpenAI-compatible or
+	Anthropic chat completion endpoint. It supports both plain text prompts
+	and file-based prompts (images for vision models and text documents
+	inlined into the conversation).
+
+	The actual wire-protocol logic (OpenAI / Anthropic request building and
+	response parsing) lives in the standalone mod/aiservers/llm client
+	package; this file only owns the ArozOS-specific bits: the admin-
+	configured connection / pricing / quota (System Settings > AI Integration
+	> AI Model - still named "AI Model" there; only the requirelib identifier
+	exposed to scripts changed from "aimodel" to "llm") and the Otto VM
+	bindings.
+
+	The sysdb table name and the System Settings HTTP handler names
+	deliberately keep their original "aimodel" spelling so previously saved
+	settings and routes are not affected by this rename.
+
+	Author: tobychui (AGI), LLM lib addition
+*/
+
+const (
+	//llmDBTable is the system database table used to persist the LLM
+	//configuration, per-model pricing and aggregated usage metrics. The
+	//string value is kept as "aimodel" (its original name) so settings
+	//saved before this library was renamed to "llm" keep working.
+	llmDBTable = "aimodel"
+
+	//llmKeyMask is the sentinel value the frontend submits when the API
+	//key field was left untouched. When received, the stored key is kept.
+	llmKeyMask = "********"
+
+	//llmRequestTimeout is the maximum time to wait for a completion.
+	llmRequestTimeout = 120 * time.Second
+)
+
+// llmMetricsMux guards read-modify-write cycles on the metrics record so
+// concurrent AGI scripts do not clobber each other's usage updates.
+var llmMetricsMux sync.Mutex
+
+// ── Persisted data structures ───────────────────────────────────────────────
+
+// LLMConfig holds the global, admin-configured connection settings.
+type LLMConfig struct {
+	Endpoint     string `json:"endpoint"`     //Base URL, e.g. https://api.openai.com/v1 or https://api.anthropic.com
+	APIKey       string `json:"apikey"`       //API key (Bearer for OpenAI, x-api-key for Anthropic)
+	DefaultModel string `json:"defaultModel"` //Model used when a script does not specify one
+	APIFormat    string `json:"apiFormat"`    //Wire format: "openai" (default) or "anthropic"
+	Currency     string `json:"currency"`     //Currency label used by the metrics board (default USD)
+}
+
+// LLMQuota defines an optional cap on token / cost consumption so the
+// system cannot keep spending once a budget is reached.
+type LLMQuota struct {
+	Enabled   bool    `json:"enabled"`   //When true, requests are blocked once a cap is hit
+	MaxTokens int64   `json:"maxTokens"` //Total token cap for the period (0 = no token cap)
+	MaxCost   float64 `json:"maxCost"`   //Total cost cap for the period (0 = no cost cap)
+	Period    string  `json:"period"`    //Reset window: "total" (never), "daily" or "monthly"
+}
+
+// periodLabel returns a human-friendly label for the quota period.
+func (q LLMQuota) periodLabel() string {
+	switch q.Period {
+	case "daily":
+		return "day"
+	case "monthly":
+		return "month"
+	default:
+		return "total"
+	}
+}
+
+// LLMPricing defines the price per 1,000,000 tokens for a given model.
+type LLMPricing struct {
+	InputPrice  float64 `json:"inputPrice"`  //Cost per 1M prompt (input) tokens
+	OutputPrice float64 `json:"outputPrice"` //Cost per 1M completion (output) tokens
+}
+
+// LLMUsageRecord is the accumulated usage of a single model.
+type LLMUsageRecord struct {
+	PromptTokens     int64   `json:"promptTokens"`
+	CompletionTokens int64   `json:"completionTokens"`
+	TotalTokens      int64   `json:"totalTokens"`
+	Cost             float64 `json:"cost"`
+	Requests         int64   `json:"requests"`
+	GenerationMs     int64   `json:"generationMs"` //total generation time
+	//Sum and count of per-request tokens/sec, so the average speed is the mean
+	//of the per-request speeds (matching what is shown on each reply).
+	SpeedSum     float64 `json:"speedSum"`
+	SpeedSamples int64   `json:"speedSamples"`
+}
+
+// LLMMetrics is the aggregated consumption across every model.
+type LLMMetrics struct {
+	TotalPromptTokens     int64                      `json:"totalPromptTokens"`
+	TotalCompletionTokens int64                      `json:"totalCompletionTokens"`
+	TotalTokens           int64                      `json:"totalTokens"`
+	TotalCost             float64                    `json:"totalCost"`
+	TotalRequests         int64                      `json:"totalRequests"`
+	TotalGenerationMs     int64                      `json:"totalGenerationMs"`
+	SpeedSum              float64                    `json:"speedSum"`     //sum of per-request tok/s
+	SpeedSamples          int64                      `json:"speedSamples"` //count of timed requests
+	PerModel              map[string]*LLMUsageRecord `json:"perModel"`
+	Currency              string                     `json:"currency"`
+	UpdatedAt             int64                      `json:"updatedAt"`
+
+	//Windowed usage used for quota enforcement (reset per quota period).
+	WindowStart  int64   `json:"windowStart"`  //Unix time the current quota window began
+	WindowTokens int64   `json:"windowTokens"` //Tokens consumed in the current window
+	WindowCost   float64 `json:"windowCost"`   //Cost consumed in the current window
+}
+
+// llmCallOptions are the per-call options a script may pass as a JS object.
+type llmCallOptions struct {
+	Model       string   `json:"model"`       //Override the configured default model
+	System      string   `json:"system"`      //Optional system prompt
+	Endpoint    string   `json:"endpoint"`    //Override the global endpoint
+	APIKey      string   `json:"apikey"`      //Override the global API key
+	APIFormat   string   `json:"apiFormat"`   //Override the wire format ("openai"/"anthropic")
+	Temperature *float64 `json:"temperature"` //Sampling temperature
+	MaxTokens   *int     `json:"max_tokens"`  //Maximum tokens to generate
+}
+
+// ── Library registration ─────────────────────────────────────────────────────
+
+func (g *Gateway) LLMLibRegister() {
+	//Make sure the storage table exists before any read / write happens.
+	sysdb := g.Option.UserHandler.GetDatabase()
+	if !sysdb.TableExists(llmDBTable) {
+		sysdb.NewTable(llmDBTable)
+	}
+
+	err := g.RegisterLib("llm", g.injectLLMFunctions)
+	if err != nil {
+		agiLogger.PrintAndLog("Agi", fmt.Sprint(err), nil)
+		os.Exit(1)
+	}
+}
+
+func (g *Gateway) injectLLMFunctions(payload *static.AgiLibInjectionPayload) {
+	vm := payload.VM
+	u := payload.User
+	scriptFsh := payload.ScriptFsh
+
+	//llm.chat(prompt, options) => assistant reply text
+	vm.Set("_llm_chat", func(call otto.FunctionCall) otto.Value {
+		prompt, _ := call.Argument(0).ToString()
+		opt := parseLLMCallOptions(getOttoStringArg(call, 1))
+
+		messages := []llm.Message{}
+		if strings.TrimSpace(opt.System) != "" {
+			messages = append(messages, llm.Message{Role: "system", Content: opt.System})
+		}
+		messages = append(messages, llm.Message{Role: "user", Content: prompt})
+
+		resp, err := g.llmDoRequest(opt.Model, messages, opt)
+		if err != nil {
+			panic(vm.MakeCustomError("LLMError", err.Error()))
+		}
+		reply, _ := vm.ToValue(llmExtractContent(resp))
+		return reply
+	})
+
+	//llm.chatWithFile(prompt, files, options) => assistant reply text
+	//files may be a single vpath or an array of vpaths. Images are sent as
+	//vision image_url parts; textual files are inlined as text parts.
+	vm.Set("_llm_chatWithFile", func(call otto.FunctionCall) otto.Value {
+		prompt, _ := call.Argument(0).ToString()
+		filesJSON := getOttoStringArg(call, 1)
+		opt := parseLLMCallOptions(getOttoStringArg(call, 2))
+
+		var vpaths []string
+		if err := json.Unmarshal([]byte(filesJSON), &vpaths); err != nil || len(vpaths) == 0 {
+			panic(vm.MakeCustomError("LLMError", "no file path(s) provided"))
+		}
+
+		parts := []llm.ContentPart{}
+		if strings.TrimSpace(prompt) != "" {
+			parts = append(parts, llm.ContentPart{Type: "text", Text: prompt})
+		}
+		for _, vpath := range vpaths {
+			fileParts, err := g.llmBuildFileParts(scriptFsh, vm, u, vpath)
+			if err != nil {
+				panic(vm.MakeCustomError("LLMError", err.Error()))
+			}
+			parts = append(parts, fileParts...)
+		}
+
+		messages := []llm.Message{}
+		if strings.TrimSpace(opt.System) != "" {
+			messages = append(messages, llm.Message{Role: "system", Content: opt.System})
+		}
+		messages = append(messages, llm.Message{Role: "user", Content: parts})
+
+		resp, err := g.llmDoRequest(opt.Model, messages, opt)
+		if err != nil {
+			panic(vm.MakeCustomError("LLMError", err.Error()))
+		}
+		reply, _ := vm.ToValue(llmExtractContent(resp))
+		return reply
+	})
+
+	//llm.request(messages, options) => full response object (JSON string)
+	//Gives advanced scripts access to usage information and finish reason.
+	vm.Set("_llm_request", func(call otto.FunctionCall) otto.Value {
+		messagesJSON := getOttoStringArg(call, 0)
+		opt := parseLLMCallOptions(getOttoStringArg(call, 1))
+
+		var messages []llm.Message
+		if err := json.Unmarshal([]byte(messagesJSON), &messages); err != nil {
+			panic(vm.MakeCustomError("LLMError", "invalid messages array: "+err.Error()))
+		}
+
+		resp, err := g.llmDoRequest(opt.Model, messages, opt)
+		if err != nil {
+			panic(vm.MakeCustomError("LLMError", err.Error()))
+		}
+		out, _ := json.Marshal(resp)
+		reply, _ := vm.ToValue(string(out))
+		return reply
+	})
+
+	//llm.usage() => aggregated metrics object (JSON string)
+	vm.Set("_llm_usage", func(call otto.FunctionCall) otto.Value {
+		out, _ := json.Marshal(g.getLLMMetrics())
+		reply, _ := vm.ToValue(string(out))
+		return reply
+	})
+
+	//llm.models() => { default: "...", models: [...] } (JSON string)
+	vm.Set("_llm_models", func(call otto.FunctionCall) otto.Value {
+		cfg := g.getLLMConfig()
+		pricing := g.getLLMPricing()
+		models := []string{}
+		for name := range pricing {
+			models = append(models, name)
+		}
+		out, _ := json.Marshal(map[string]interface{}{
+			"default": cfg.DefaultModel,
+			"models":  models,
+		})
+		reply, _ := vm.ToValue(string(out))
+		return reply
+	})
+
+	//llm.listModels() => { models: [...] } from the live endpoint (JSON string)
+	vm.Set("_llm_listModels", func(call otto.FunctionCall) otto.Value {
+		cfg := g.getLLMConfig()
+		result := map[string]interface{}{"models": []string{}}
+		client := llm.NewClient(cfg.Endpoint, cfg.APIKey, cfg.APIFormat, llmRequestTimeout)
+		models, err := client.ListModels()
+		if err != nil {
+			result["error"] = err.Error()
+		} else {
+			result["models"] = models
+		}
+		out, _ := json.Marshal(result)
+		reply, _ := vm.ToValue(string(out))
+		return reply
+	})
+
+	//llm.fileParts(files) => JSON array of OpenAI-style content parts for
+	//the given virtual file path(s). Images become image_url data URIs, text
+	//documents are inlined. Scripts can embed these into a message's content.
+	vm.Set("_llm_fileParts", func(call otto.FunctionCall) otto.Value {
+		filesJSON := getOttoStringArg(call, 0)
+		var vpaths []string
+		if err := json.Unmarshal([]byte(filesJSON), &vpaths); err != nil {
+			panic(vm.MakeCustomError("LLMError", "invalid files array: "+err.Error()))
+		}
+		parts := []llm.ContentPart{}
+		for _, vp := range vpaths {
+			fp, err := g.llmBuildFileParts(scriptFsh, vm, u, vp)
+			if err != nil {
+				panic(vm.MakeCustomError("LLMError", err.Error()))
+			}
+			parts = append(parts, fp...)
+		}
+		out, _ := json.Marshal(parts)
+		reply, _ := vm.ToValue(string(out))
+		return reply
+	})
+
+	//Wrap the native functions into a clean llm class
+	vm.Run(`
+		var llm = {};
+		llm.chat = function(prompt, options){
+			return _llm_chat(prompt, JSON.stringify(options || {}));
+		};
+		llm.chatWithFile = function(prompt, files, options){
+			if (typeof files === "string"){ files = [files]; }
+			return _llm_chatWithFile(prompt, JSON.stringify(files || []), JSON.stringify(options || {}));
+		};
+		llm.request = function(messages, options){
+			return JSON.parse(_llm_request(JSON.stringify(messages || []), JSON.stringify(options || {})));
+		};
+		llm.usage = function(){
+			return JSON.parse(_llm_usage());
+		};
+		llm.models = function(){
+			return JSON.parse(_llm_models());
+		};
+		llm.listModels = function(){
+			return JSON.parse(_llm_listModels());
+		};
+		llm.fileParts = function(files){
+			if (typeof files === "string"){ files = [files]; }
+			return JSON.parse(_llm_fileParts(JSON.stringify(files || [])));
+		};
+	`)
+}
+
+// ── Core request logic ───────────────────────────────────────────────────────
+
+// llmDoRequest resolves the connection settings, enforces any usage quota,
+// dispatches the call via mod/aiservers/llm, records the resulting token
+// usage / cost and returns the unified response.
+func (g *Gateway) llmDoRequest(model string, messages []llm.Message, opt llmCallOptions) (*llm.ChatResponse, error) {
+	cfg := g.getLLMConfig()
+
+	endpoint := strings.TrimSpace(cfg.Endpoint)
+	apikey := cfg.APIKey
+	format := cfg.APIFormat
+	if strings.TrimSpace(opt.Endpoint) != "" {
+		endpoint = strings.TrimSpace(opt.Endpoint)
+	}
+	if strings.TrimSpace(opt.APIKey) != "" {
+		apikey = strings.TrimSpace(opt.APIKey)
+	}
+	if strings.TrimSpace(opt.APIFormat) != "" {
+		format = strings.TrimSpace(opt.APIFormat)
+	}
+	if format == "" {
+		format = "openai"
+	}
+	if strings.TrimSpace(model) == "" {
+		model = cfg.DefaultModel
+	}
+
+	if endpoint == "" {
+		return nil, errors.New("AI model endpoint is not configured (System Settings > AI Integration > AI Model)")
+	}
+	if strings.TrimSpace(model) == "" {
+		return nil, errors.New("no model specified and no default model configured")
+	}
+
+	//Enforce the usage quota before spending any tokens.
+	if err := g.llmCheckQuota(); err != nil {
+		return nil, err
+	}
+
+	client := llm.NewClient(endpoint, apikey, format, llmRequestTimeout)
+	resp, err := client.Chat(messages, llm.ChatOptions{Model: model, Temperature: opt.Temperature, MaxTokens: opt.MaxTokens})
+	if err != nil {
+		return nil, err
+	}
+
+	//Record usage. Prefer the model echoed back by the server.
+	usedModel := model
+	if strings.TrimSpace(resp.Model) != "" {
+		usedModel = resp.Model
+	}
+	g.recordLLMUsage(usedModel, resp.Usage.PromptTokens, resp.Usage.CompletionTokens, resp.Usage.GenerationMs)
+
+	return resp, nil
+}
+
+// llmBuildFileParts reads a file from the user's virtual file system and
+// converts it into one or more OpenAI-compatible content parts. Images become
+// base64 data-URI image_url parts (for vision models); textual files are
+// inlined as a labelled text part.
+func (g *Gateway) llmBuildFileParts(scriptFsh *filesystem.FileSystemHandler, vm *otto.Otto, u *user.User, vpath string) ([]llm.ContentPart, error) {
+	//Resolve relative paths against the script's directory
+	vpath = static.RelativeVpathRewrite(scriptFsh, vpath, vm, u)
+
+	if !u.CanRead(vpath) {
+		return nil, errors.New("permission denied: " + vpath)
+	}
+
+	fsh, rpath, err := static.VirtualPathToRealPath(vpath, u)
+	if err != nil {
+		return nil, err
+	}
+	if !fsh.FileSystemAbstraction.FileExists(rpath) {
+		return nil, errors.New("file not found: " + vpath)
+	}
+
+	content, err := fsh.FileSystemAbstraction.ReadFile(rpath)
+	if err != nil {
+		return nil, err
+	}
+
+	ext := strings.ToLower(filepath.Ext(rpath))
+	filename := filepath.Base(rpath)
+
+	if llmIsImageExt(ext) {
+		mimeType := mime.TypeByExtension(ext)
+		if mimeType == "" {
+			mimeType = "image/" + strings.TrimPrefix(ext, ".")
+		}
+		dataURI := "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(content)
+		return []llm.ContentPart{{Type: "image_url", ImageURL: &llm.ImageURL{URL: dataURI}}}, nil
+	}
+
+	//Treat anything that is valid UTF-8 (or has a known text extension) as text.
+	if llmIsTextExt(ext) || utf8.Valid(content) {
+		text := "[Attached file: " + filename + "]\n" + string(content)
+		return []llm.ContentPart{{Type: "text", Text: text}}, nil
+	}
+
+	return nil, errors.New("unsupported file type for file-based chat: " + filename + " (only images and text documents are supported)")
+}
+
+// ── Persistence helpers ──────────────────────────────────────────────────────
+
+func (g *Gateway) getLLMConfig() LLMConfig {
+	cfg := LLMConfig{Currency: "USD", APIFormat: "openai"}
+	sysdb := g.Option.UserHandler.GetDatabase()
+	if sysdb.KeyExists(llmDBTable, "config") {
+		sysdb.Read(llmDBTable, "config", &cfg)
+		if strings.TrimSpace(cfg.Currency) == "" {
+			cfg.Currency = "USD"
+		}
+		if strings.TrimSpace(cfg.APIFormat) == "" {
+			cfg.APIFormat = "openai"
+		}
+	}
+	return cfg
+}
+
+func (g *Gateway) getLLMQuota() LLMQuota {
+	q := LLMQuota{Period: "total"}
+	sysdb := g.Option.UserHandler.GetDatabase()
+	if sysdb.KeyExists(llmDBTable, "quota") {
+		sysdb.Read(llmDBTable, "quota", &q)
+		if strings.TrimSpace(q.Period) == "" {
+			q.Period = "total"
+		}
+	}
+	return q
+}
+
+// llmWindowExpired reports whether a quota window that started at startUnix
+// has rolled over for the given period as of now.
+func llmWindowExpired(startUnix int64, period string, now time.Time) bool {
+	if startUnix <= 0 {
+		return true
+	}
+	start := time.Unix(startUnix, 0).UTC()
+	n := now.UTC()
+	switch period {
+	case "daily":
+		return start.YearDay() != n.YearDay() || start.Year() != n.Year()
+	case "monthly":
+		return start.Month() != n.Month() || start.Year() != n.Year()
+	default: //"total" never expires
+		return false
+	}
+}
+
+// llmCurrentWindowUsage returns the effective token / cost usage for the
+// active quota window (zero if the window has rolled over).
+func (g *Gateway) llmCurrentWindowUsage() (int64, float64) {
+	q := g.getLLMQuota()
+	m := g.getLLMMetrics()
+	if llmWindowExpired(m.WindowStart, q.Period, time.Now()) {
+		return 0, 0
+	}
+	return m.WindowTokens, m.WindowCost
+}
+
+// llmCheckQuota returns an error when a configured quota has been reached.
+func (g *Gateway) llmCheckQuota() error {
+	q := g.getLLMQuota()
+	if !q.Enabled {
+		return nil
+	}
+	usedTokens, usedCost := g.llmCurrentWindowUsage()
+	if q.MaxTokens > 0 && usedTokens >= q.MaxTokens {
+		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())
+	}
+	if q.MaxCost > 0 && usedCost >= q.MaxCost {
+		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())
+	}
+	return nil
+}
+
+func (g *Gateway) getLLMPricing() map[string]LLMPricing {
+	pricing := map[string]LLMPricing{}
+	sysdb := g.Option.UserHandler.GetDatabase()
+	if sysdb.KeyExists(llmDBTable, "pricing") {
+		sysdb.Read(llmDBTable, "pricing", &pricing)
+	}
+	return pricing
+}
+
+func (g *Gateway) getLLMMetrics() *LLMMetrics {
+	metrics := &LLMMetrics{PerModel: map[string]*LLMUsageRecord{}}
+	sysdb := g.Option.UserHandler.GetDatabase()
+	if sysdb.KeyExists(llmDBTable, "metrics") {
+		sysdb.Read(llmDBTable, "metrics", metrics)
+		if metrics.PerModel == nil {
+			metrics.PerModel = map[string]*LLMUsageRecord{}
+		}
+	}
+	//Keep currency label in sync with the current config.
+	metrics.Currency = g.getLLMConfig().Currency
+	return metrics
+}
+
+// recordLLMUsage atomically adds the given token counts (and their computed
+// cost from the configured pricing) into the persisted metrics. The optional
+// genMs argument is the request's generation time, accumulated so the
+// metrics board can report an average tokens-per-second.
+func (g *Gateway) recordLLMUsage(model string, promptTokens int64, completionTokens int64, genMs ...int64) {
+	var generationMs int64
+	if len(genMs) > 0 {
+		generationMs = genMs[0]
+	}
+
+	llmMetricsMux.Lock()
+	defer llmMetricsMux.Unlock()
+
+	sysdb := g.Option.UserHandler.GetDatabase()
+	metrics := &LLMMetrics{PerModel: map[string]*LLMUsageRecord{}}
+	if sysdb.KeyExists(llmDBTable, "metrics") {
+		sysdb.Read(llmDBTable, "metrics", metrics)
+		if metrics.PerModel == nil {
+			metrics.PerModel = map[string]*LLMUsageRecord{}
+		}
+	}
+
+	pricing := g.getLLMPricing()
+	p := pricing[model]
+	cost := float64(promptTokens)/1000000.0*p.InputPrice + float64(completionTokens)/1000000.0*p.OutputPrice
+
+	rec := metrics.PerModel[model]
+	if rec == nil {
+		rec = &LLMUsageRecord{}
+		metrics.PerModel[model] = rec
+	}
+	rec.PromptTokens += promptTokens
+	rec.CompletionTokens += completionTokens
+	rec.TotalTokens += promptTokens + completionTokens
+	rec.Cost += cost
+	rec.Requests++
+	rec.GenerationMs += generationMs
+
+	metrics.TotalPromptTokens += promptTokens
+	metrics.TotalCompletionTokens += completionTokens
+	metrics.TotalTokens += promptTokens + completionTokens
+	metrics.TotalCost += cost
+	metrics.TotalRequests++
+	metrics.TotalGenerationMs += generationMs
+	metrics.UpdatedAt = time.Now().Unix()
+
+	//Accumulate this request's speed (tokens/sec) so the reported average is the
+	//mean of per-request speeds, consistent with the per-reply figures.
+	if completionTokens > 0 && generationMs > 0 {
+		speed := float64(completionTokens) / (float64(generationMs) / 1000.0)
+		rec.SpeedSum += speed
+		rec.SpeedSamples++
+		metrics.SpeedSum += speed
+		metrics.SpeedSamples++
+	}
+
+	//Maintain the windowed usage used for quota enforcement. Reset the window
+	//first if it has rolled over for the configured quota period.
+	now := time.Now()
+	period := g.getLLMQuota().Period
+	if metrics.WindowStart == 0 || llmWindowExpired(metrics.WindowStart, period, now) {
+		metrics.WindowStart = now.Unix()
+		metrics.WindowTokens = 0
+		metrics.WindowCost = 0
+	}
+	metrics.WindowTokens += promptTokens + completionTokens
+	metrics.WindowCost += cost
+
+	if err := sysdb.Write(llmDBTable, "metrics", metrics); err != nil {
+		agiLogger.PrintAndLog("Agi", "[AGI] Failed to persist LLM usage metrics: "+err.Error(), nil)
+	}
+}
+
+// ── HTTP handlers (System Settings) ──────────────────────────────────────────
+// These serve the "AI Model" tab in System Settings > AI Integration and keep
+// their original Handle* names / routes; only the requirelib identifier used
+// by AGI scripts ("llm") changed.
+
+// HandleAIModelConfig serves GET (masked config) and POST (save config).
+// GET  /system/aimodel/config
+// POST /system/aimodel/config  (endpoint, defaultModel, currency, apikey, clearkey)
+func (g *Gateway) HandleAIModelConfig(w http.ResponseWriter, r *http.Request) {
+	if r.Method == http.MethodGet {
+		cfg := g.getLLMConfig()
+		js, _ := json.Marshal(map[string]interface{}{
+			"endpoint":     cfg.Endpoint,
+			"defaultModel": cfg.DefaultModel,
+			"apiFormat":    cfg.APIFormat,
+			"currency":     cfg.Currency,
+			"hasKey":       cfg.APIKey != "",
+			"keyHint":      llmMaskKey(cfg.APIKey),
+		})
+		utils.SendJSONResponse(w, string(js))
+		return
+	}
+
+	//POST - save. Read raw form values so empty strings are allowed for
+	//endpoint / defaultModel (e.g. when intentionally clearing a field).
+	r.ParseForm()
+	cfg := g.getLLMConfig()
+	cfg.Endpoint = strings.TrimSpace(r.Form.Get("endpoint"))
+	cfg.DefaultModel = strings.TrimSpace(r.Form.Get("defaultModel"))
+	if format := strings.TrimSpace(r.Form.Get("apiFormat")); format == "anthropic" || format == "openai" {
+		cfg.APIFormat = format
+	}
+	if cfg.APIFormat == "" {
+		cfg.APIFormat = "openai"
+	}
+	if currency := strings.TrimSpace(r.Form.Get("currency")); currency != "" {
+		cfg.Currency = currency
+	}
+
+	//API key: only overwrite when a new, non-sentinel value is supplied.
+	if clear, _ := utils.PostBool(r, "clearkey"); clear {
+		cfg.APIKey = ""
+	} else if apikey := r.Form.Get("apikey"); apikey != "" && apikey != llmKeyMask {
+		cfg.APIKey = apikey
+	}
+
+	sysdb := g.Option.UserHandler.GetDatabase()
+	if err := sysdb.Write(llmDBTable, "config", cfg); err != nil {
+		utils.SendErrorResponse(w, "failed to save config: "+err.Error())
+		return
+	}
+	utils.SendOK(w)
+}
+
+// HandleAIModelPricing serves GET (pricing map) and POST (save pricing map).
+// GET  /system/aimodel/pricing
+// POST /system/aimodel/pricing  (pricing = JSON of {model:{inputPrice,outputPrice}})
+func (g *Gateway) HandleAIModelPricing(w http.ResponseWriter, r *http.Request) {
+	if r.Method == http.MethodGet {
+		js, _ := json.Marshal(g.getLLMPricing())
+		utils.SendJSONResponse(w, string(js))
+		return
+	}
+
+	raw, err := utils.PostPara(r, "pricing")
+	if err != nil {
+		utils.SendErrorResponse(w, "missing pricing data")
+		return
+	}
+	pricing := map[string]LLMPricing{}
+	if err := json.Unmarshal([]byte(raw), &pricing); err != nil {
+		utils.SendErrorResponse(w, "invalid pricing JSON: "+err.Error())
+		return
+	}
+	sysdb := g.Option.UserHandler.GetDatabase()
+	if err := sysdb.Write(llmDBTable, "pricing", pricing); err != nil {
+		utils.SendErrorResponse(w, "failed to save pricing: "+err.Error())
+		return
+	}
+	utils.SendOK(w)
+}
+
+// HandleAIModelMetrics returns the aggregated usage metrics.
+// GET /system/aimodel/metrics
+func (g *Gateway) HandleAIModelMetrics(w http.ResponseWriter, r *http.Request) {
+	js, _ := json.Marshal(g.getLLMMetrics())
+	utils.SendJSONResponse(w, string(js))
+}
+
+// HandleAIModelMetricsReset clears the aggregated usage metrics.
+// POST /system/aimodel/metrics/reset
+func (g *Gateway) HandleAIModelMetricsReset(w http.ResponseWriter, r *http.Request) {
+	llmMetricsMux.Lock()
+	defer llmMetricsMux.Unlock()
+
+	metrics := &LLMMetrics{
+		PerModel:  map[string]*LLMUsageRecord{},
+		UpdatedAt: time.Now().Unix(),
+	}
+	sysdb := g.Option.UserHandler.GetDatabase()
+	if err := sysdb.Write(llmDBTable, "metrics", metrics); err != nil {
+		utils.SendErrorResponse(w, "failed to reset metrics: "+err.Error())
+		return
+	}
+	utils.SendOK(w)
+}
+
+// HandleAIModelTest performs a lightweight connectivity check by listing the
+// models exposed by the endpoint. It does not consume any tokens.
+// POST /system/aimodel/test  (optional: endpoint, apikey, apiFormat to test unsaved values)
+func (g *Gateway) HandleAIModelTest(w http.ResponseWriter, r *http.Request) {
+	cfg := g.getLLMConfig()
+	endpoint := cfg.Endpoint
+	apikey := cfg.APIKey
+	format := cfg.APIFormat
+	if ep := strings.TrimSpace(r.FormValue("endpoint")); ep != "" {
+		endpoint = ep
+	}
+	if k := r.FormValue("apikey"); k != "" && k != llmKeyMask {
+		apikey = k
+	}
+	if f := strings.TrimSpace(r.FormValue("apiFormat")); f == "openai" || f == "anthropic" {
+		format = f
+	}
+
+	if strings.TrimSpace(endpoint) == "" {
+		utils.SendErrorResponse(w, "endpoint not configured")
+		return
+	}
+
+	client := llm.NewClient(endpoint, apikey, format, llmRequestTimeout)
+	models, err := client.ListModels()
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	out, _ := json.Marshal(map[string]interface{}{
+		"ok":         true,
+		"modelCount": len(models),
+		"models":     models,
+	})
+	utils.SendJSONResponse(w, string(out))
+}
+
+// HandleAIModelQuota serves GET (quota + current window usage) and POST (save).
+// GET  /system/aimodel/quota
+// POST /system/aimodel/quota  (enabled, maxTokens, maxCost, period)
+func (g *Gateway) HandleAIModelQuota(w http.ResponseWriter, r *http.Request) {
+	if r.Method == http.MethodGet {
+		q := g.getLLMQuota()
+		usedTokens, usedCost := g.llmCurrentWindowUsage()
+		js, _ := json.Marshal(map[string]interface{}{
+			"enabled":    q.Enabled,
+			"maxTokens":  q.MaxTokens,
+			"maxCost":    q.MaxCost,
+			"period":     q.Period,
+			"usedTokens": usedTokens,
+			"usedCost":   usedCost,
+			"currency":   g.getLLMConfig().Currency,
+		})
+		utils.SendJSONResponse(w, string(js))
+		return
+	}
+
+	r.ParseForm()
+	q := g.getLLMQuota()
+	q.Enabled, _ = utils.PostBool(r, "enabled")
+
+	q.MaxTokens = 0
+	if n, err := strconv.ParseInt(strings.TrimSpace(r.Form.Get("maxTokens")), 10, 64); err == nil && n >= 0 {
+		q.MaxTokens = n
+	}
+	q.MaxCost = 0
+	if f, err := strconv.ParseFloat(strings.TrimSpace(r.Form.Get("maxCost")), 64); err == nil && f >= 0 {
+		q.MaxCost = f
+	}
+	if p := strings.TrimSpace(r.Form.Get("period")); p == "total" || p == "daily" || p == "monthly" {
+		q.Period = p
+	}
+
+	sysdb := g.Option.UserHandler.GetDatabase()
+	if err := sysdb.Write(llmDBTable, "quota", q); err != nil {
+		utils.SendErrorResponse(w, "failed to save quota: "+err.Error())
+		return
+	}
+	utils.SendOK(w)
+}
+
+// ── Small helpers ────────────────────────────────────────────────────────────
+
+func llmExtractContent(resp *llm.ChatResponse) string {
+	if resp == nil || len(resp.Choices) == 0 {
+		return ""
+	}
+	return resp.Choices[0].Message.Content
+}
+
+func parseLLMCallOptions(s string) llmCallOptions {
+	opt := llmCallOptions{}
+	s = strings.TrimSpace(s)
+	if s == "" || s == "undefined" || s == "null" {
+		return opt
+	}
+	json.Unmarshal([]byte(s), &opt)
+	return opt
+}
+
+func llmMaskKey(key string) string {
+	if key == "" {
+		return ""
+	}
+	if len(key) <= 4 {
+		return strings.Repeat("•", len(key))
+	}
+	return "••••" + key[len(key)-4:]
+}
+
+func llmIsImageExt(ext string) bool {
+	switch ext {
+	case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp":
+		return true
+	}
+	return false
+}
+
+func llmIsTextExt(ext string) bool {
+	switch ext {
+	case ".txt", ".md", ".markdown", ".csv", ".tsv", ".json", ".xml", ".yaml", ".yml",
+		".html", ".htm", ".js", ".ts", ".go", ".py", ".java", ".c", ".cpp", ".h", ".hpp",
+		".css", ".log", ".ini", ".conf", ".sh", ".bat", ".sql", ".php", ".rb", ".rs",
+		".toml", ".env", ".srt", ".vtt":
+		return true
+	}
+	return false
+}
+
+// getOttoStringArg safely reads the nth argument of a call as a string,
+// returning an empty string when the argument is absent or undefined. Shared
+// by every AGI lib file in this package (llm, cnn, ...).
+func getOttoStringArg(call otto.FunctionCall, idx int) string {
+	arg := call.Argument(idx)
+	if arg.IsUndefined() || arg.IsNull() {
+		return ""
+	}
+	s, err := arg.ToString()
+	if err != nil {
+		return ""
+	}
+	return s
+}

+ 421 - 0
src/mod/agi/agi.llm_test.go

@@ -0,0 +1,421 @@
+package agi
+
+import (
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/robertkrimen/otto"
+	"imuslab.com/arozos/mod/agi/static"
+	llm "imuslab.com/arozos/mod/aiservers/llm"
+	database "imuslab.com/arozos/mod/database"
+	user "imuslab.com/arozos/mod/user"
+)
+
+// dbGateway returns a Gateway backed by a throwaway bolt database so the
+// config / pricing / metrics persistence paths can be exercised in tests.
+// Shared by every *_test.go file in this package (llm, cnn, ...).
+func dbGateway(t *testing.T) *Gateway {
+	t.Helper()
+	dbfile := filepath.Join(t.TempDir(), "test.db")
+	sysdb, err := database.NewDatabase(dbfile, false)
+	if err != nil {
+		t.Fatalf("failed to create test database: %v", err)
+	}
+	t.Cleanup(func() { sysdb.Close() })
+
+	uh, err := user.NewUserHandler(sysdb, nil, nil, nil, nil)
+	if err != nil {
+		t.Fatalf("failed to create user handler: %v", err)
+	}
+
+	g := minimalGateway()
+	g.Option.UserHandler = uh
+	sysdb.NewTable(llmDBTable)
+	return g
+}
+
+// ─── pure helpers ─────────────────────────────────────────────────────────────
+
+func TestParseLLMCallOptions(t *testing.T) {
+	if opt := parseLLMCallOptions(""); opt.Model != "" {
+		t.Errorf("empty string should yield zero options")
+	}
+	if opt := parseLLMCallOptions("undefined"); opt.Model != "" {
+		t.Errorf("'undefined' should yield zero options")
+	}
+	if opt := parseLLMCallOptions("null"); opt.Model != "" {
+		t.Errorf("'null' should yield zero options")
+	}
+	opt := parseLLMCallOptions(`{"model":"gpt-4o","system":"be brief","temperature":0.5,"max_tokens":42}`)
+	if opt.Model != "gpt-4o" || opt.System != "be brief" {
+		t.Errorf("unexpected parse: %+v", opt)
+	}
+	if opt.Temperature == nil || *opt.Temperature != 0.5 {
+		t.Errorf("temperature not parsed")
+	}
+	if opt.MaxTokens == nil || *opt.MaxTokens != 42 {
+		t.Errorf("max_tokens not parsed")
+	}
+}
+
+func TestLLMMaskKey(t *testing.T) {
+	cases := map[string]string{
+		"":              "",
+		"abc":           "•••",
+		"sk-1234567890": "••••7890",
+	}
+	for in, want := range cases {
+		if got := llmMaskKey(in); got != want {
+			t.Errorf("maskKey(%q) = %q, want %q", in, got, want)
+		}
+	}
+}
+
+func TestLLMExtClassification(t *testing.T) {
+	if !llmIsImageExt(".png") || !llmIsImageExt(".jpeg") {
+		t.Error("expected image extensions to be detected")
+	}
+	if llmIsImageExt(".txt") {
+		t.Error(".txt should not be an image")
+	}
+	if !llmIsTextExt(".md") || !llmIsTextExt(".go") {
+		t.Error("expected text extensions to be detected")
+	}
+	if llmIsTextExt(".png") {
+		t.Error(".png should not be classified as text")
+	}
+}
+
+// ─── persistence ──────────────────────────────────────────────────────────────
+
+func TestRecordLLMUsageAccumulatesAndCosts(t *testing.T) {
+	g := dbGateway(t)
+	sysdb := g.Option.UserHandler.GetDatabase()
+
+	//Pricing: $2.50 / 1M input, $10.00 / 1M output
+	sysdb.Write(llmDBTable, "pricing", map[string]LLMPricing{
+		"test-model": {InputPrice: 2.5, OutputPrice: 10.0},
+	})
+
+	g.recordLLMUsage("test-model", 1000, 500)
+	g.recordLLMUsage("test-model", 1000, 500)
+
+	m := g.getLLMMetrics()
+	if m.TotalRequests != 2 {
+		t.Errorf("expected 2 requests, got %d", m.TotalRequests)
+	}
+	if m.TotalPromptTokens != 2000 || m.TotalCompletionTokens != 1000 || m.TotalTokens != 3000 {
+		t.Errorf("unexpected token totals: %+v", m)
+	}
+	//Each call: 1000/1e6*2.5 + 500/1e6*10 = 0.0075 ; two calls => 0.015
+	if got := m.TotalCost; got < 0.01499 || got > 0.01501 {
+		t.Errorf("expected total cost ~0.015, got %v", got)
+	}
+	rec := m.PerModel["test-model"]
+	if rec == nil || rec.Requests != 2 || rec.TotalTokens != 3000 {
+		t.Errorf("per-model record incorrect: %+v", rec)
+	}
+}
+
+func TestGetLLMConfigDefaultsCurrency(t *testing.T) {
+	g := dbGateway(t)
+	cfg := g.getLLMConfig()
+	if cfg.Currency != "USD" {
+		t.Errorf("expected default currency USD, got %q", cfg.Currency)
+	}
+}
+
+// ─── orchestration (config resolution + metrics recording) ──────────────────
+// Wire-protocol mechanics (request shape, auth headers, response decoding)
+// are covered by mod/aiservers/llm's own tests; these only verify that the
+// AGI-layer orchestrator wires the client and the persisted config/metrics
+// together correctly.
+
+func TestLLMDoRequestFlow(t *testing.T) {
+	var gotModel string
+	var sawUserMessage bool
+
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		body, _ := io.ReadAll(r.Body)
+		var req struct {
+			Model    string `json:"model"`
+			Messages []struct {
+				Role string `json:"role"`
+			} `json:"messages"`
+		}
+		json.Unmarshal(body, &req)
+		gotModel = req.Model
+		for _, msg := range req.Messages {
+			if msg.Role == "user" {
+				sawUserMessage = true
+			}
+		}
+		w.Header().Set("Content-Type", "application/json")
+		io.WriteString(w, `{"model":"test-model",
+			"choices":[{"index":0,"message":{"role":"assistant","content":"Hello from mock"},"finish_reason":"stop"}],
+			"usage":{"prompt_tokens":1000,"completion_tokens":500,"total_tokens":1500}}`)
+	}))
+	defer srv.Close()
+
+	g := dbGateway(t)
+	sysdb := g.Option.UserHandler.GetDatabase()
+	sysdb.Write(llmDBTable, "config", LLMConfig{
+		Endpoint:     srv.URL,
+		APIKey:       "test-key",
+		DefaultModel: "test-model",
+		Currency:     "USD",
+	})
+	sysdb.Write(llmDBTable, "pricing", map[string]LLMPricing{
+		"test-model": {InputPrice: 2.5, OutputPrice: 10.0},
+	})
+
+	resp, err := g.llmDoRequest("", []llm.Message{{Role: "user", Content: "hi"}}, llmCallOptions{})
+	if err != nil {
+		t.Fatalf("llmDoRequest returned error: %v", err)
+	}
+	if content := llmExtractContent(resp); content != "Hello from mock" {
+		t.Errorf("unexpected content: %q", content)
+	}
+	if gotModel != "test-model" {
+		t.Errorf("expected default model to be used, got %q", gotModel)
+	}
+	if !sawUserMessage {
+		t.Error("server did not receive a user message")
+	}
+
+	//Metrics should have been recorded from the usage block
+	m := g.getLLMMetrics()
+	if m.TotalRequests != 1 || m.TotalTokens != 1500 {
+		t.Errorf("metrics not recorded after request: %+v", m)
+	}
+}
+
+func TestLLMDoRequestNoEndpoint(t *testing.T) {
+	g := dbGateway(t)
+	_, err := g.llmDoRequest("m", []llm.Message{{Role: "user", Content: "hi"}}, llmCallOptions{})
+	if err == nil {
+		t.Error("expected error when endpoint is not configured")
+	}
+}
+
+func TestLLMDoRequestAnthropicFlow(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		io.WriteString(w, `{"model":"claude-x",
+			"content":[{"type":"text","text":"Hi from Claude"}],
+			"usage":{"input_tokens":30,"output_tokens":12},
+			"stop_reason":"end_turn"}`)
+	}))
+	defer srv.Close()
+
+	g := dbGateway(t)
+	sysdb := g.Option.UserHandler.GetDatabase()
+	sysdb.Write(llmDBTable, "config", LLMConfig{
+		Endpoint: srv.URL, APIKey: "anthropic-key", DefaultModel: "claude-x", APIFormat: "anthropic", Currency: "USD",
+	})
+
+	//A system message in the unified array must be lifted to the top-level field
+	//(verified directly in mod/aiservers/llm); here we only check the result
+	//that reaches the AGI layer and that usage gets recorded.
+	msgs := []llm.Message{
+		{Role: "system", Content: "be brief"},
+		{Role: "user", Content: "hello"},
+	}
+	resp, err := g.llmDoRequest("", msgs, llmCallOptions{})
+	if err != nil {
+		t.Fatalf("anthropic request errored: %v", err)
+	}
+	if content := llmExtractContent(resp); content != "Hi from Claude" {
+		t.Errorf("unexpected content: %q", content)
+	}
+
+	//Usage mapping: input->prompt, output->completion.
+	m := g.getLLMMetrics()
+	if m.TotalPromptTokens != 30 || m.TotalCompletionTokens != 12 || m.TotalTokens != 42 {
+		t.Errorf("usage not mapped/recorded correctly: %+v", m)
+	}
+}
+
+func TestLLMDoRequestRecordsTokensPerSecond(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		time.Sleep(25 * time.Millisecond) //ensure a measurable generation time
+		w.Header().Set("Content-Type", "application/json")
+		io.WriteString(w, `{"model":"m","choices":[{"message":{"role":"assistant","content":"hello world"}}],
+			"usage":{"prompt_tokens":5,"completion_tokens":20,"total_tokens":25}}`)
+	}))
+	defer srv.Close()
+
+	g := dbGateway(t)
+	g.Option.UserHandler.GetDatabase().Write(llmDBTable, "config", LLMConfig{Endpoint: srv.URL, DefaultModel: "m", APIFormat: "openai"})
+
+	resp, err := g.llmDoRequest("", []llm.Message{{Role: "user", Content: "hi"}}, llmCallOptions{})
+	if err != nil {
+		t.Fatalf("request errored: %v", err)
+	}
+	if resp.Usage.GenerationMs <= 0 {
+		t.Errorf("expected generation_ms > 0, got %d", resp.Usage.GenerationMs)
+	}
+	if resp.Usage.TokensPerSecond <= 0 {
+		t.Errorf("expected tokens_per_second > 0, got %v", resp.Usage.TokensPerSecond)
+	}
+
+	m := g.getLLMMetrics()
+	if m.TotalGenerationMs <= 0 {
+		t.Errorf("expected total generation ms recorded, got %d", m.TotalGenerationMs)
+	}
+	if m.SpeedSamples != 1 || m.SpeedSum <= 0 {
+		t.Errorf("expected one speed sample recorded, got samples=%d sum=%v", m.SpeedSamples, m.SpeedSum)
+	}
+	if rec := m.PerModel["m"]; rec == nil || rec.GenerationMs <= 0 || rec.SpeedSamples != 1 {
+		t.Errorf("per-model speed sample not recorded: %+v", rec)
+	}
+}
+
+// The average speed must be the mean of per-request speeds, not total tokens
+// over total time (which is token-weighted and skews toward large requests).
+func TestLLMAverageSpeedIsMeanOfRequests(t *testing.T) {
+	g := dbGateway(t)
+	//Request A: 10 tokens in 1000ms -> 10 tok/s
+	g.recordLLMUsage("m", 0, 10, 1000)
+	//Request B: 1000 tokens in 10000ms -> 100 tok/s
+	g.recordLLMUsage("m", 0, 1000, 10000)
+
+	m := g.getLLMMetrics()
+	if m.SpeedSamples != 2 {
+		t.Fatalf("expected 2 speed samples, got %d", m.SpeedSamples)
+	}
+	avg := m.SpeedSum / float64(m.SpeedSamples)
+	//Mean of speeds = (10 + 100) / 2 = 55 (NOT throughput 1010/11 ≈ 91.8).
+	if avg < 54.9 || avg > 55.1 {
+		t.Errorf("expected average speed ~55 tok/s, got %v", avg)
+	}
+}
+
+// ─── quota enforcement ──────────────────────────────────────────────────────
+
+func TestLLMQuotaEnforcement(t *testing.T) {
+	g := dbGateway(t)
+	sysdb := g.Option.UserHandler.GetDatabase()
+	sysdb.Write(llmDBTable, "quota", LLMQuota{Enabled: true, MaxTokens: 100, Period: "total"})
+
+	//Under the cap -> allowed.
+	if err := g.llmCheckQuota(); err != nil {
+		t.Fatalf("expected no error under quota, got %v", err)
+	}
+
+	//Consume past the cap.
+	g.recordLLMUsage("m", 80, 40) // 120 tokens > 100
+	if err := g.llmCheckQuota(); err == nil {
+		t.Error("expected quota error after exceeding token cap")
+	} else if !strings.Contains(err.Error(), "quota") {
+		t.Errorf("expected a quota error, got %v", err)
+	}
+
+	//Disabling the quota lifts the block.
+	sysdb.Write(llmDBTable, "quota", LLMQuota{Enabled: false, MaxTokens: 100, Period: "total"})
+	if err := g.llmCheckQuota(); err != nil {
+		t.Errorf("disabled quota should not block, got %v", err)
+	}
+}
+
+func TestLLMDoRequestBlockedByQuota(t *testing.T) {
+	g := dbGateway(t)
+	sysdb := g.Option.UserHandler.GetDatabase()
+	sysdb.Write(llmDBTable, "config", LLMConfig{Endpoint: "http://127.0.0.1:0", DefaultModel: "m", APIFormat: "openai"})
+	sysdb.Write(llmDBTable, "quota", LLMQuota{Enabled: true, MaxTokens: 10, Period: "total"})
+	g.recordLLMUsage("m", 20, 0) // exceed
+
+	_, err := g.llmDoRequest("m", []llm.Message{{Role: "user", Content: "hi"}}, llmCallOptions{})
+	if err == nil || !strings.Contains(err.Error(), "quota") {
+		t.Errorf("expected request to be blocked by quota, got %v", err)
+	}
+}
+
+func TestLLMWindowExpired(t *testing.T) {
+	now := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
+	if !llmWindowExpired(0, "daily", now) {
+		t.Error("zero start should be considered expired")
+	}
+	yesterday := now.AddDate(0, 0, -1).Unix()
+	if !llmWindowExpired(yesterday, "daily", now) {
+		t.Error("yesterday should be expired for daily period")
+	}
+	if llmWindowExpired(now.Add(-1*time.Hour).Unix(), "daily", now) {
+		t.Error("same day should not be expired for daily period")
+	}
+	lastMonth := now.AddDate(0, -1, 0).Unix()
+	if !llmWindowExpired(lastMonth, "monthly", now) {
+		t.Error("last month should be expired for monthly period")
+	}
+	if llmWindowExpired(now.AddDate(0, -1, 0).Unix(), "total", now) {
+		t.Error("total period should never expire")
+	}
+}
+
+// ─── config handler masking ─────────────────────────────────────────────────
+// HandleAIModelConfig keeps its original name/route - only the requirelib
+// identifier exposed to AGI scripts changed.
+
+func TestHandleAIModelConfigMaskingAndKeyRetention(t *testing.T) {
+	g := dbGateway(t)
+	sysdb := g.Option.UserHandler.GetDatabase()
+	sysdb.Write(llmDBTable, "config", LLMConfig{
+		Endpoint: "https://api.example.com/v1", APIKey: "sk-supersecret9999", DefaultModel: "m", Currency: "USD",
+	})
+
+	//GET should mask the key
+	rec := httptest.NewRecorder()
+	g.HandleAIModelConfig(rec, httptest.NewRequest("GET", "/system/aimodel/config", nil))
+	var got map[string]interface{}
+	json.Unmarshal(rec.Body.Bytes(), &got)
+	if got["hasKey"] != true {
+		t.Errorf("expected hasKey true, got %v", got["hasKey"])
+	}
+	if hint, _ := got["keyHint"].(string); !strings.HasSuffix(hint, "9999") || strings.Contains(hint, "supersecret") {
+		t.Errorf("key not properly masked: %v", got["keyHint"])
+	}
+
+	//POST without apikey should retain the saved key, but update endpoint
+	form := url.Values{}
+	form.Set("endpoint", "https://new.example.com/v1")
+	form.Set("defaultModel", "m2")
+	form.Set("currency", "EUR")
+	req := httptest.NewRequest("POST", "/system/aimodel/config", strings.NewReader(form.Encode()))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	g.HandleAIModelConfig(httptest.NewRecorder(), req)
+
+	cfg := g.getLLMConfig()
+	if cfg.APIKey != "sk-supersecret9999" {
+		t.Errorf("API key should have been retained, got %q", cfg.APIKey)
+	}
+	if cfg.Endpoint != "https://new.example.com/v1" || cfg.DefaultModel != "m2" || cfg.Currency != "EUR" {
+		t.Errorf("config not updated correctly: %+v", cfg)
+	}
+}
+
+// ─── JS object exposure ─────────────────────────────────────────────────────
+
+func TestInjectLLMLib_JSObjectExposed(t *testing.T) {
+	g := minimalGateway()
+	vm := otto.New()
+	payload := &static.AgiLibInjectionPayload{VM: vm, User: &user.User{Username: "alice"}}
+	g.injectLLMFunctions(payload)
+
+	for _, method := range []string{"chat", "chatWithFile", "request", "usage", "models"} {
+		val, err := vm.Run(`typeof llm.` + method)
+		if err != nil {
+			t.Fatalf("evaluating llm.%s: %v", method, err)
+		}
+		s, _ := val.ToString()
+		if s != "function" {
+			t.Errorf("llm.%s should be a function, got %q", method, s)
+		}
+	}
+}

+ 1 - 1
src/mod/agi/moduleManager.go

@@ -55,7 +55,7 @@ func (g *Gateway) LoadAllFunctionalModules() {
 	g.SysinfoLibRegister()
 	//g.AudioLibRegister() //work in progress
 	g.ZipLibRegister()
-	g.AIModelLibRegister()
+	g.LLMLibRegister()
 	g.CNNLibRegister()
 	g.SQLiteLibRegister()
 

+ 269 - 0
src/mod/aiservers/llm/anthropic.go

@@ -0,0 +1,269 @@
+package llm
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+)
+
+const (
+	anthropicVersion          = "2023-06-01" //API version header sent to the Anthropic API
+	anthropicDefaultMaxTokens = 4096         //Used when a call does not specify MaxTokens; Anthropic requires this field
+)
+
+type anthropicImageSource struct {
+	Type      string `json:"type"`                 //"base64" or "url"
+	MediaType string `json:"media_type,omitempty"` //e.g. image/png (base64 only)
+	Data      string `json:"data,omitempty"`       //base64 payload (base64 only)
+	URL       string `json:"url,omitempty"`        //remote URL (url source only)
+}
+
+type anthropicContentBlock struct {
+	Type   string                `json:"type"` //"text" or "image"
+	Text   string                `json:"text,omitempty"`
+	Source *anthropicImageSource `json:"source,omitempty"`
+}
+
+type anthropicMessage struct {
+	Role    string      `json:"role"`    //"user" or "assistant"
+	Content interface{} `json:"content"` //string or []anthropicContentBlock
+}
+
+type anthropicRequest struct {
+	Model       string             `json:"model"`
+	MaxTokens   int                `json:"max_tokens"`
+	System      string             `json:"system,omitempty"`
+	Messages    []anthropicMessage `json:"messages"`
+	Temperature *float64           `json:"temperature,omitempty"`
+	Stream      bool               `json:"stream"`
+}
+
+type anthropicResponse struct {
+	Model   string `json:"model"`
+	Content []struct {
+		Type string `json:"type"`
+		Text string `json:"text"`
+	} `json:"content"`
+	Usage struct {
+		InputTokens  int64 `json:"input_tokens"`
+		OutputTokens int64 `json:"output_tokens"`
+	} `json:"usage"`
+	StopReason string `json:"stop_reason"`
+	Error      *struct {
+		Type    string `json:"type"`
+		Message string `json:"message"`
+	} `json:"error,omitempty"`
+}
+
+// chatAnthropic performs an Anthropic Messages API call and maps the result
+// back into the unified ChatResponse shape.
+func (c *Client) chatAnthropic(messages []Message, opt ChatOptions) (*ChatResponse, error) {
+	//Anthropic takes the system prompt as a top-level field, not a message.
+	system := ""
+	amsgs := []anthropicMessage{}
+	for _, m := range messages {
+		if m.Role == "system" {
+			if s, ok := m.Content.(string); ok {
+				if system != "" {
+					system += "\n\n"
+				}
+				system += s
+			}
+			continue
+		}
+		amsgs = append(amsgs, anthropicMessage{Role: m.Role, Content: toAnthropicContent(m.Content)})
+	}
+
+	maxTokens := anthropicDefaultMaxTokens
+	if opt.MaxTokens != nil && *opt.MaxTokens > 0 {
+		maxTokens = *opt.MaxTokens
+	}
+
+	reqStruct := anthropicRequest{
+		Model:       opt.Model,
+		MaxTokens:   maxTokens,
+		System:      system,
+		Messages:    amsgs,
+		Temperature: opt.Temperature,
+		Stream:      false,
+	}
+	body, err := json.Marshal(reqStruct)
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest("POST", anthropicURL(c.Endpoint), bytes.NewReader(body))
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("User-Agent", "arozos-llm-client/1.0")
+	req.Header.Set("anthropic-version", anthropicVersion)
+	if c.APIKey != "" {
+		req.Header.Set("x-api-key", c.APIKey)
+	}
+
+	resp, err := c.httpClient().Do(req)
+	if err != nil {
+		return nil, errors.New("request to AI endpoint failed: " + err.Error())
+	}
+	defer resp.Body.Close()
+
+	respBody, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	parsed := &anthropicResponse{}
+	if err := json.Unmarshal(respBody, parsed); err != nil {
+		return nil, fmt.Errorf("unexpected response (HTTP %d): %s", resp.StatusCode, truncate(string(respBody), 300))
+	}
+	if parsed.Error != nil && parsed.Error.Message != "" {
+		return nil, errors.New("AI endpoint error: " + parsed.Error.Message)
+	}
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return nil, fmt.Errorf("AI endpoint returned HTTP %d: %s", resp.StatusCode, truncate(string(respBody), 300))
+	}
+
+	//Map the Anthropic response onto the unified ChatResponse.
+	var text strings.Builder
+	for _, block := range parsed.Content {
+		if block.Type == "text" {
+			text.WriteString(block.Text)
+		}
+	}
+	unified := &ChatResponse{Model: parsed.Model}
+	choice := Choice{FinishReason: parsed.StopReason}
+	choice.Message.Role = "assistant"
+	choice.Message.Content = text.String()
+	unified.Choices = append(unified.Choices, choice)
+	unified.Usage = Usage{
+		PromptTokens:     parsed.Usage.InputTokens,
+		CompletionTokens: parsed.Usage.OutputTokens,
+		TotalTokens:      parsed.Usage.InputTokens + parsed.Usage.OutputTokens,
+	}
+	return unified, nil
+}
+
+// listModelsAnthropic lists model IDs from the Anthropic /v1/models endpoint.
+func (c *Client) listModelsAnthropic() ([]string, error) {
+	base := strings.TrimRight(c.Endpoint, "/")
+	var requestURL string
+	if strings.HasSuffix(base, "/models") {
+		requestURL = base
+	} else if strings.HasSuffix(base, "/v1") {
+		requestURL = base + "/models"
+	} else {
+		requestURL = base + "/v1/models"
+	}
+
+	req, err := http.NewRequest("GET", requestURL, nil)
+	if err != nil {
+		return nil, err
+	}
+	if c.APIKey != "" {
+		req.Header.Set("x-api-key", c.APIKey)
+		req.Header.Set("anthropic-version", anthropicVersion)
+	}
+
+	resp, err := c.httpClient().Do(req)
+	if err != nil {
+		return nil, errors.New("connection failed: " + err.Error())
+	}
+	defer resp.Body.Close()
+
+	respBody, _ := io.ReadAll(resp.Body)
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return nil, fmt.Errorf("endpoint returned HTTP %d: %s", resp.StatusCode, truncate(string(respBody), 200))
+	}
+
+	var modelList struct {
+		Data []struct {
+			ID string `json:"id"`
+		} `json:"data"`
+	}
+	json.Unmarshal(respBody, &modelList)
+	models := []string{}
+	for _, m := range modelList.Data {
+		if strings.TrimSpace(m.ID) != "" {
+			models = append(models, m.ID)
+		}
+	}
+	return models, nil
+}
+
+// anthropicURL builds the Messages endpoint URL from a base URL, tolerating
+// bases with or without a trailing /v1 or /messages.
+func anthropicURL(endpoint string) string {
+	base := strings.TrimRight(endpoint, "/")
+	if strings.HasSuffix(base, "/messages") {
+		return base
+	}
+	if strings.HasSuffix(base, "/v1") {
+		return base + "/messages"
+	}
+	return base + "/v1/messages"
+}
+
+// toAnthropicContent converts unified message content (a plain string or an
+// array of OpenAI-style content parts) into Anthropic content blocks.
+func toAnthropicContent(content interface{}) interface{} {
+	switch v := content.(type) {
+	case string:
+		return v
+	case []ContentPart:
+		blocks := make([]anthropicContentBlock, 0, len(v))
+		for _, p := range v {
+			if p.Type == "text" {
+				blocks = append(blocks, anthropicContentBlock{Type: "text", Text: p.Text})
+			} else if p.Type == "image_url" && p.ImageURL != nil {
+				blocks = append(blocks, anthropicImageBlock(p.ImageURL.URL))
+			}
+		}
+		return blocks
+	case []interface{}:
+		blocks := make([]anthropicContentBlock, 0, len(v))
+		for _, raw := range v {
+			m, ok := raw.(map[string]interface{})
+			if !ok {
+				continue
+			}
+			t, _ := m["type"].(string)
+			if t == "text" {
+				txt, _ := m["text"].(string)
+				blocks = append(blocks, anthropicContentBlock{Type: "text", Text: txt})
+			} else if t == "image_url" {
+				if iu, ok := m["image_url"].(map[string]interface{}); ok {
+					url, _ := iu["url"].(string)
+					blocks = append(blocks, anthropicImageBlock(url))
+				}
+			}
+		}
+		return blocks
+	default:
+		b, _ := json.Marshal(v)
+		return string(b)
+	}
+}
+
+// anthropicImageBlock converts an image URL (data URI or remote) into an
+// Anthropic image content block.
+func anthropicImageBlock(url string) anthropicContentBlock {
+	if strings.HasPrefix(url, "data:") {
+		meta := url[len("data:"):]
+		if comma := strings.Index(meta, ","); comma >= 0 {
+			head := meta[:comma]
+			data := meta[comma+1:]
+			mediaType := strings.TrimSuffix(head, ";base64")
+			if mediaType == "" {
+				mediaType = "image/png"
+			}
+			return anthropicContentBlock{Type: "image", Source: &anthropicImageSource{Type: "base64", MediaType: mediaType, Data: data}}
+		}
+	}
+	return anthropicContentBlock{Type: "image", Source: &anthropicImageSource{Type: "url", URL: url}}
+}

+ 147 - 0
src/mod/aiservers/llm/anthropic_test.go

@@ -0,0 +1,147 @@
+package llm
+
+import (
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+)
+
+func TestClientChatAnthropic(t *testing.T) {
+	var gotPath, gotKey, gotVersion string
+	var captured anthropicRequest
+
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		gotPath = r.URL.Path
+		gotKey = r.Header.Get("x-api-key")
+		gotVersion = r.Header.Get("anthropic-version")
+		body, _ := io.ReadAll(r.Body)
+		json.Unmarshal(body, &captured)
+		w.Header().Set("Content-Type", "application/json")
+		io.WriteString(w, `{"model":"claude-x",
+			"content":[{"type":"text","text":"Hi from Claude"}],
+			"usage":{"input_tokens":30,"output_tokens":12},
+			"stop_reason":"end_turn"}`)
+	}))
+	defer srv.Close()
+
+	c := NewClient(srv.URL, "anthropic-key", "anthropic", 0)
+
+	//A system message in the unified array must be lifted to the top-level field.
+	msgs := []Message{
+		{Role: "system", Content: "be brief"},
+		{Role: "user", Content: "hello"},
+	}
+	resp, err := c.Chat(msgs, ChatOptions{Model: "claude-x"})
+	if err != nil {
+		t.Fatalf("anthropic request errored: %v", err)
+	}
+	if len(resp.Choices) == 0 || resp.Choices[0].Message.Content != "Hi from Claude" {
+		t.Errorf("unexpected response: %+v", resp)
+	}
+	if gotPath != "/v1/messages" {
+		t.Errorf("expected /v1/messages, got %q", gotPath)
+	}
+	if gotKey != "anthropic-key" {
+		t.Errorf("expected x-api-key header, got %q", gotKey)
+	}
+	if gotVersion == "" {
+		t.Errorf("expected anthropic-version header to be set")
+	}
+	if captured.System != "be brief" {
+		t.Errorf("system prompt not lifted to top-level: %q", captured.System)
+	}
+	if captured.MaxTokens <= 0 {
+		t.Errorf("anthropic requires max_tokens > 0, got %d", captured.MaxTokens)
+	}
+	for _, m := range captured.Messages {
+		if m.Role == "system" {
+			t.Errorf("system role must not appear in messages array")
+		}
+	}
+	//Usage mapping: input->prompt, output->completion.
+	if resp.Usage.PromptTokens != 30 || resp.Usage.CompletionTokens != 12 || resp.Usage.TotalTokens != 42 {
+		t.Errorf("usage not mapped correctly: %+v", resp.Usage)
+	}
+}
+
+func TestClientChatAnthropicErrorEnvelope(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusBadRequest)
+		io.WriteString(w, `{"error":{"type":"invalid_request_error","message":"max_tokens is required"}}`)
+	}))
+	defer srv.Close()
+
+	c := NewClient(srv.URL, "k", "anthropic", 0)
+	_, err := c.Chat([]Message{{Role: "user", Content: "hi"}}, ChatOptions{Model: "claude-x"})
+	if err == nil {
+		t.Fatal("expected an error")
+	}
+}
+
+func TestClientListModelsAnthropic(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path != "/v1/models" {
+			t.Errorf("unexpected path: %s", r.URL.Path)
+		}
+		if r.Header.Get("anthropic-version") == "" {
+			t.Errorf("expected anthropic-version header")
+		}
+		io.WriteString(w, `{"data":[{"id":"claude-3-5-sonnet"}]}`)
+	}))
+	defer srv.Close()
+
+	c := NewClient(srv.URL, "k", "anthropic", 0)
+	models, err := c.ListModels()
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(models) != 1 || models[0] != "claude-3-5-sonnet" {
+		t.Errorf("unexpected models: %+v", models)
+	}
+}
+
+func TestAnthropicImageBlock(t *testing.T) {
+	b := anthropicImageBlock("data:image/png;base64,AAAA")
+	if b.Type != "image" || b.Source == nil || b.Source.Type != "base64" ||
+		b.Source.MediaType != "image/png" || b.Source.Data != "AAAA" {
+		t.Errorf("data URI not parsed into base64 source: %+v", b.Source)
+	}
+	u := anthropicImageBlock("https://example.com/cat.png")
+	if u.Source == nil || u.Source.Type != "url" || u.Source.URL != "https://example.com/cat.png" {
+		t.Errorf("remote URL not parsed into url source: %+v", u.Source)
+	}
+}
+
+func TestToAnthropicContent(t *testing.T) {
+	if s, ok := toAnthropicContent("plain").(string); !ok || s != "plain" {
+		t.Errorf("string content should pass through")
+	}
+	//Simulate JSON-decoded OpenAI-style parts.
+	parts := []interface{}{
+		map[string]interface{}{"type": "text", "text": "look"},
+		map[string]interface{}{"type": "image_url", "image_url": map[string]interface{}{"url": "data:image/jpeg;base64,ZZ"}},
+	}
+	out, ok := toAnthropicContent(parts).([]anthropicContentBlock)
+	if !ok || len(out) != 2 {
+		t.Fatalf("expected 2 content blocks, got %#v", out)
+	}
+	if out[0].Type != "text" || out[1].Type != "image" {
+		t.Errorf("unexpected block types: %+v", out)
+	}
+}
+
+func TestAnthropicURL(t *testing.T) {
+	cases := map[string]string{
+		"https://api.anthropic.com":             "https://api.anthropic.com/v1/messages",
+		"https://api.anthropic.com/v1":          "https://api.anthropic.com/v1/messages",
+		"https://api.anthropic.com/v1/":         "https://api.anthropic.com/v1/messages",
+		"https://api.anthropic.com/v1/messages": "https://api.anthropic.com/v1/messages",
+	}
+	for in, want := range cases {
+		if got := anthropicURL(in); got != want {
+			t.Errorf("anthropicURL(%q) = %q, want %q", in, got, want)
+		}
+	}
+}

+ 95 - 0
src/mod/aiservers/llm/client.go

@@ -0,0 +1,95 @@
+package llm
+
+/*
+	LLM Client
+
+	A minimal Go client for OpenAI-compatible and Anthropic chat completion
+	endpoints (OpenAI, Azure OpenAI, OpenRouter, Ollama, LM Studio, llama.cpp
+	server, vLLM, Anthropic Claude, ...). This package only speaks the wire
+	protocol - it carries no ArozOS-specific knowledge (no AGI, no virtual
+	paths, no system database, no pricing/quota), so it can be reused from
+	anywhere in the codebase. The AGI binding that exposes it to WebApp
+	backend scripts (config persistence, pricing, quota, usage metrics, Otto
+	VM bindings) lives in mod/agi/agi.llm.go.
+
+	Author: tobychui (AGI/ArozOS integration)
+*/
+
+import (
+	"net/http"
+	"strings"
+	"time"
+)
+
+// Client talks to one OpenAI- or Anthropic-compatible chat completion endpoint.
+type Client struct {
+	Endpoint  string //Base URL, e.g. https://api.openai.com/v1 or https://api.anthropic.com
+	APIKey    string //Bearer token (OpenAI) or x-api-key (Anthropic)
+	APIFormat string //Wire format: "openai" (default) or "anthropic"
+	HTTP      *http.Client
+}
+
+// NewClient creates a client for the given endpoint. timeout <= 0 uses
+// DefaultTimeout; format defaults to "openai" unless exactly "anthropic".
+func NewClient(endpoint, apikey, format string, timeout time.Duration) *Client {
+	if timeout <= 0 {
+		timeout = DefaultTimeout
+	}
+	if strings.TrimSpace(format) != "anthropic" {
+		format = "openai"
+	}
+	return &Client{
+		Endpoint:  strings.TrimSpace(endpoint),
+		APIKey:    strings.TrimSpace(apikey),
+		APIFormat: format,
+		HTTP:      &http.Client{Timeout: timeout},
+	}
+}
+
+// Chat sends a chat completion request and returns the unified response,
+// dispatching to the configured wire format. GenerationMs/TokensPerSecond
+// in the returned Usage are computed from the wall-clock request duration.
+func (c *Client) Chat(messages []Message, opt ChatOptions) (*ChatResponse, error) {
+	start := time.Now()
+	var resp *ChatResponse
+	var err error
+	if c.APIFormat == "anthropic" {
+		resp, err = c.chatAnthropic(messages, opt)
+	} else {
+		resp, err = c.chatOpenAI(messages, opt)
+	}
+	if err != nil {
+		return nil, err
+	}
+	elapsed := time.Since(start)
+	resp.Usage.GenerationMs = elapsed.Milliseconds()
+	if resp.Usage.CompletionTokens > 0 && elapsed.Seconds() > 0 {
+		resp.Usage.TokensPerSecond = float64(resp.Usage.CompletionTokens) / elapsed.Seconds()
+	}
+	return resp, nil
+}
+
+// ListModels lists model IDs exposed by the endpoint, dispatching to the
+// configured wire format. Used by connectivity tests and AGI scripts; does
+// not consume any tokens.
+func (c *Client) ListModels() ([]string, error) {
+	if c.APIFormat == "anthropic" {
+		return c.listModelsAnthropic()
+	}
+	return c.listModelsOpenAI()
+}
+
+func (c *Client) httpClient() *http.Client {
+	if c.HTTP == nil {
+		return &http.Client{Timeout: DefaultTimeout}
+	}
+	return c.HTTP
+}
+
+func truncate(s string, max int) string {
+	s = strings.TrimSpace(s)
+	if len(s) <= max {
+		return s
+	}
+	return s[:max] + "…"
+}

+ 152 - 0
src/mod/aiservers/llm/client_test.go

@@ -0,0 +1,152 @@
+package llm
+
+import (
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestNewClientDefaults(t *testing.T) {
+	c := NewClient(" https://api.openai.com/v1 ", "", "weird-format", 0)
+	if c.Endpoint != "https://api.openai.com/v1" {
+		t.Errorf("endpoint not trimmed: %q", c.Endpoint)
+	}
+	if c.APIFormat != "openai" {
+		t.Errorf("unrecognised format should default to openai, got %q", c.APIFormat)
+	}
+	if c.HTTP.Timeout != DefaultTimeout {
+		t.Errorf("expected default timeout, got %v", c.HTTP.Timeout)
+	}
+}
+
+func TestClientChatOpenAI(t *testing.T) {
+	var gotPath, gotAuth, gotModel string
+	var sawUserMessage bool
+
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		gotPath = r.URL.Path
+		gotAuth = r.Header.Get("Authorization")
+		body, _ := io.ReadAll(r.Body)
+		var req openaiChatRequest
+		json.Unmarshal(body, &req)
+		gotModel = req.Model
+		for _, msg := range req.Messages {
+			if msg.Role == "user" {
+				sawUserMessage = true
+			}
+		}
+		w.Header().Set("Content-Type", "application/json")
+		io.WriteString(w, `{"model":"test-model",
+			"choices":[{"index":0,"message":{"role":"assistant","content":"Hello from mock"},"finish_reason":"stop"}],
+			"usage":{"prompt_tokens":1000,"completion_tokens":500,"total_tokens":1500}}`)
+	}))
+	defer srv.Close()
+
+	c := NewClient(srv.URL, "test-key", "openai", 0)
+	resp, err := c.Chat([]Message{{Role: "user", Content: "hi"}}, ChatOptions{Model: "test-model"})
+	if err != nil {
+		t.Fatalf("Chat returned error: %v", err)
+	}
+	if len(resp.Choices) == 0 || resp.Choices[0].Message.Content != "Hello from mock" {
+		t.Errorf("unexpected response: %+v", resp)
+	}
+	if gotPath != "/chat/completions" {
+		t.Errorf("expected /chat/completions, got %q", gotPath)
+	}
+	if gotAuth != "Bearer test-key" {
+		t.Errorf("expected bearer auth header, got %q", gotAuth)
+	}
+	if gotModel != "test-model" {
+		t.Errorf("model not forwarded, got %q", gotModel)
+	}
+	if !sawUserMessage {
+		t.Error("server did not receive a user message")
+	}
+	if resp.Usage.TotalTokens != 1500 {
+		t.Errorf("usage not decoded: %+v", resp.Usage)
+	}
+}
+
+func TestClientChatOpenAINoAuthHeaderWhenNoKey(t *testing.T) {
+	var gotAuth string
+	called := false
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		called = true
+		gotAuth = r.Header.Get("Authorization")
+		io.WriteString(w, `{"model":"m","choices":[{"message":{"role":"assistant","content":"hi"}}]}`)
+	}))
+	defer srv.Close()
+
+	c := NewClient(srv.URL, "", "openai", 0)
+	if _, err := c.Chat([]Message{{Role: "user", Content: "hi"}}, ChatOptions{Model: "m"}); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if !called {
+		t.Fatal("server was not called")
+	}
+	if gotAuth != "" {
+		t.Errorf("expected no Authorization header, got %q", gotAuth)
+	}
+}
+
+func TestClientChatOpenAIErrorEnvelope(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusUnauthorized)
+		io.WriteString(w, `{"error":{"message":"invalid api key","type":"authentication_error"}}`)
+	}))
+	defer srv.Close()
+
+	c := NewClient(srv.URL, "bad-key", "openai", 0)
+	_, err := c.Chat([]Message{{Role: "user", Content: "hi"}}, ChatOptions{Model: "m"})
+	if err == nil {
+		t.Fatal("expected an error")
+	}
+	if !strings.Contains(err.Error(), "invalid api key") {
+		t.Errorf("expected the endpoint's error message to surface, got: %v", err)
+	}
+}
+
+func TestClientChatTimingRecorded(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		time.Sleep(25 * time.Millisecond) //ensure a measurable generation time
+		w.Header().Set("Content-Type", "application/json")
+		io.WriteString(w, `{"model":"m","choices":[{"message":{"role":"assistant","content":"hello world"}}],
+			"usage":{"prompt_tokens":5,"completion_tokens":20,"total_tokens":25}}`)
+	}))
+	defer srv.Close()
+
+	c := NewClient(srv.URL, "", "openai", 0)
+	resp, err := c.Chat([]Message{{Role: "user", Content: "hi"}}, ChatOptions{Model: "m"})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if resp.Usage.GenerationMs <= 0 {
+		t.Errorf("expected generation_ms > 0, got %d", resp.Usage.GenerationMs)
+	}
+	if resp.Usage.TokensPerSecond <= 0 {
+		t.Errorf("expected tokens_per_second > 0, got %v", resp.Usage.TokensPerSecond)
+	}
+}
+
+func TestClientListModelsOpenAI(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path != "/v1/models" {
+			t.Errorf("unexpected path: %s", r.URL.Path)
+		}
+		io.WriteString(w, `{"data":[{"id":"gpt-4o"},{"id":"gpt-4o-mini"}]}`)
+	}))
+	defer srv.Close()
+
+	c := NewClient(srv.URL+"/v1", "", "openai", 0)
+	models, err := c.ListModels()
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(models) != 2 || models[0] != "gpt-4o" {
+		t.Errorf("unexpected models: %+v", models)
+	}
+}

+ 147 - 0
src/mod/aiservers/llm/openai.go

@@ -0,0 +1,147 @@
+package llm
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+)
+
+type openaiChatRequest struct {
+	Model       string    `json:"model"`
+	Messages    []Message `json:"messages"`
+	Temperature *float64  `json:"temperature,omitempty"`
+	MaxTokens   *int      `json:"max_tokens,omitempty"`
+	Stream      bool      `json:"stream"`
+}
+
+type openaiChatResponse struct {
+	Model   string `json:"model"`
+	Choices []struct {
+		Index   int `json:"index"`
+		Message struct {
+			Role    string `json:"role"`
+			Content string `json:"content"`
+		} `json:"message"`
+		FinishReason string `json:"finish_reason"`
+	} `json:"choices"`
+	Usage struct {
+		PromptTokens     int64 `json:"prompt_tokens"`
+		CompletionTokens int64 `json:"completion_tokens"`
+		TotalTokens      int64 `json:"total_tokens"`
+	} `json:"usage"`
+	Error *struct {
+		Message string `json:"message"`
+		Type    string `json:"type"`
+	} `json:"error,omitempty"`
+}
+
+// chatOpenAI performs an OpenAI-compatible chat completion call.
+func (c *Client) chatOpenAI(messages []Message, opt ChatOptions) (*ChatResponse, error) {
+	reqStruct := openaiChatRequest{
+		Model:       opt.Model,
+		Messages:    messages,
+		Temperature: opt.Temperature,
+		MaxTokens:   opt.MaxTokens,
+		Stream:      false,
+	}
+	body, err := json.Marshal(reqStruct)
+	if err != nil {
+		return nil, err
+	}
+
+	requestURL := strings.TrimRight(c.Endpoint, "/")
+	if !strings.HasSuffix(requestURL, "/chat/completions") {
+		requestURL += "/chat/completions"
+	}
+	req, err := http.NewRequest("POST", requestURL, bytes.NewReader(body))
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("User-Agent", "arozos-llm-client/1.0")
+	if c.APIKey != "" {
+		req.Header.Set("Authorization", "Bearer "+c.APIKey)
+	}
+
+	resp, err := c.httpClient().Do(req)
+	if err != nil {
+		return nil, errors.New("request to AI endpoint failed: " + err.Error())
+	}
+	defer resp.Body.Close()
+
+	respBody, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	parsed := &openaiChatResponse{}
+	if err := json.Unmarshal(respBody, parsed); err != nil {
+		return nil, fmt.Errorf("unexpected response (HTTP %d): %s", resp.StatusCode, truncate(string(respBody), 300))
+	}
+	if parsed.Error != nil && parsed.Error.Message != "" {
+		return nil, errors.New("AI endpoint error: " + parsed.Error.Message)
+	}
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return nil, fmt.Errorf("AI endpoint returned HTTP %d: %s", resp.StatusCode, truncate(string(respBody), 300))
+	}
+
+	out := &ChatResponse{Model: parsed.Model}
+	for _, ch := range parsed.Choices {
+		choice := Choice{Index: ch.Index, FinishReason: ch.FinishReason}
+		choice.Message.Role = ch.Message.Role
+		choice.Message.Content = ch.Message.Content
+		out.Choices = append(out.Choices, choice)
+	}
+	out.Usage = Usage{
+		PromptTokens:     parsed.Usage.PromptTokens,
+		CompletionTokens: parsed.Usage.CompletionTokens,
+		TotalTokens:      parsed.Usage.TotalTokens,
+	}
+	return out, nil
+}
+
+// listModelsOpenAI lists model IDs from an OpenAI-compatible /models endpoint.
+func (c *Client) listModelsOpenAI() ([]string, error) {
+	base := strings.TrimRight(c.Endpoint, "/")
+	requestURL := base
+	if !strings.HasSuffix(base, "/models") {
+		requestURL = base + "/models"
+	}
+
+	req, err := http.NewRequest("GET", requestURL, nil)
+	if err != nil {
+		return nil, err
+	}
+	if c.APIKey != "" {
+		req.Header.Set("Authorization", "Bearer "+c.APIKey)
+	}
+
+	resp, err := c.httpClient().Do(req)
+	if err != nil {
+		return nil, errors.New("connection failed: " + err.Error())
+	}
+	defer resp.Body.Close()
+
+	respBody, _ := io.ReadAll(resp.Body)
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return nil, fmt.Errorf("endpoint returned HTTP %d: %s", resp.StatusCode, truncate(string(respBody), 200))
+	}
+
+	var modelList struct {
+		Data []struct {
+			ID string `json:"id"`
+		} `json:"data"`
+	}
+	json.Unmarshal(respBody, &modelList)
+	models := []string{}
+	for _, m := range modelList.Data {
+		if strings.TrimSpace(m.ID) != "" {
+			models = append(models, m.ID)
+		}
+	}
+	return models, nil
+}

+ 60 - 0
src/mod/aiservers/llm/types.go

@@ -0,0 +1,60 @@
+package llm
+
+import "time"
+
+// DefaultTimeout is used when NewClient is called with timeout <= 0.
+const DefaultTimeout = 120 * time.Second
+
+// ImageURL is an OpenAI-style image content reference (data URI or remote URL).
+type ImageURL struct {
+	URL string `json:"url"`
+}
+
+// ContentPart is one part of a multimodal message's content array.
+type ContentPart struct {
+	Type     string    `json:"type"`
+	Text     string    `json:"text,omitempty"`
+	ImageURL *ImageURL `json:"image_url,omitempty"`
+}
+
+// Message is one OpenAI-style chat message. Content is either a plain string
+// or a []ContentPart / []interface{} for multimodal (vision/file) messages.
+type Message struct {
+	Role    string      `json:"role"`
+	Content interface{} `json:"content"`
+}
+
+// ChatOptions are the per-call parameters for Client.Chat.
+type ChatOptions struct {
+	Model       string   //Required: which model to use
+	Temperature *float64 //Sampling temperature
+	MaxTokens   *int     //Maximum tokens to generate
+}
+
+// Usage is the token usage / timing for one completion.
+type Usage struct {
+	PromptTokens     int64   `json:"prompt_tokens"`
+	CompletionTokens int64   `json:"completion_tokens"`
+	TotalTokens      int64   `json:"total_tokens"`
+	TokensPerSecond  float64 `json:"tokens_per_second"` //completion tokens / generation time
+	GenerationMs     int64   `json:"generation_ms"`     //wall-clock duration of the request
+}
+
+// Choice is one completion choice.
+type Choice struct {
+	Index   int `json:"index"`
+	Message struct {
+		Role    string `json:"role"`
+		Content string `json:"content"`
+	} `json:"message"`
+	FinishReason string `json:"finish_reason"`
+}
+
+// ChatResponse is the unified response shape returned by Client.Chat,
+// regardless of whether the call went to an OpenAI- or Anthropic-shaped
+// endpoint.
+type ChatResponse struct {
+	Model   string   `json:"model"`
+	Choices []Choice `json:"choices"`
+	Usage   Usage    `json:"usage"`
+}

+ 2 - 2
src/web/AGIForge/backend/generate.agi

@@ -17,7 +17,7 @@
         { ok:false, error:"..." }
 */
 
-requirelib("aimodel");
+requirelib("llm");
 requirelib("appdata");
 
 /* ── helpers ─────────────────────────────────────────────────────────────── */
@@ -156,7 +156,7 @@ if (opts.model && trim(opts.model) != "") callOpts.model = opts.model;
 /* ── call the model ──────────────────────────────────────────────────────── */
 
 try {
-	var res = aimodel.request(messages, callOpts);
+	var res = llm.request(messages, callOpts);
 	var text = "";
 	if (res && res.choices && res.choices.length > 0 && res.choices[0].message) {
 		text = res.choices[0].message.content || "";

+ 3 - 3
src/web/AGIForge/backend/models.agi

@@ -5,12 +5,12 @@
     Shape: { "default": "gpt-4o-mini", "models": ["gpt-4o-mini", ...] }
 */
 
-requirelib("aimodel");
+requirelib("llm");
 
 var out = { "default": "", "models": [] };
 
 try {
-	var cfg = aimodel.models(); // { default, models:[pricing keys] }
+	var cfg = llm.models(); // { default, models:[pricing keys] }
 	out["default"] = cfg["default"] || "";
 
 	var set = {};
@@ -19,7 +19,7 @@ try {
 
 	//Merge in the live models advertised by the endpoint (best effort)
 	try {
-		var live = aimodel.listModels(); // { models:[...] } or { error }
+		var live = llm.listModels(); // { models:[...] } or { error }
 		var lm = live.models || [];
 		for (var j = 0; j < lm.length; j++) { set[lm[j]] = true; }
 	} catch (e) { /* endpoint unreachable - keep configured list */ }

+ 6 - 5
src/web/AGIForge/init.agi

@@ -2,13 +2,14 @@
     AGI Forge — Module Registration
     ===============================
     A terminal-style copilot for ArozOS. Describe a task in plain language and
-    AGI Forge asks the configured AI model (see System Settings > Developer
-    Options > AI Model) to write an AGI script for it — using the live AGI API
-    reference and the list of installed modules as context — then runs the
-    script in a sandboxed VM and hands back the output (and any generated file).
+    AGI Forge asks the configured AI model (see System Settings > AI
+    Integration > AI Model) to write an AGI script for it — using the live AGI
+    API reference and the list of installed modules as context — then runs
+    the script in a sandboxed VM and hands back the output (and any generated
+    file).
 
     Backend scripts (next to this init.agi):
-        backend/generate.agi   natural language -> AGI script via aimodel
+        backend/generate.agi   natural language -> AGI script via llm
         backend/run.agi        executes a generated script, captures output
         backend/models.agi     lists the available models for the picker
 */

+ 4 - 4
src/web/AIChat/backend/chat.agi

@@ -1,6 +1,6 @@
 /*
 	AI Chat — chat backend
-	Relays a conversation to the configured AI endpoint via aimodel.request().
+	Relays a conversation to the configured AI endpoint via llm.request().
 
 	POST parameters:
 	    messages : JSON array of {role, content}
@@ -13,7 +13,7 @@
 	    { ok:false, error:"..." }
 */
 
-requirelib("aimodel");
+requirelib("llm");
 
 //Read POST parameters safely (they are injected as VM globals when present)
 var rawMessages = (typeof messages !== "undefined") ? messages : "[]";
@@ -44,7 +44,7 @@ var handled = false;
 //the latest user message (turning its content into a multimodal array).
 if (fileList.length > 0 && finalMessages.length > 0) {
 	try {
-		var parts = aimodel.fileParts(fileList);
+		var parts = llm.fileParts(fileList);
 		var lastUserIdx = -1;
 		for (var j = finalMessages.length - 1; j >= 0; j--) {
 			if (finalMessages[j].role == "user") { lastUserIdx = j; break; }
@@ -69,7 +69,7 @@ if (!handled) {
 		sendJSONResp(JSON.stringify({ ok: false, error: "No messages to send" }));
 	} else {
 		try {
-			var res = aimodel.request(finalMessages, opts);
+			var res = llm.request(finalMessages, opts);
 			var replyText = "";
 			if (res && res.choices && res.choices.length > 0 && res.choices[0].message) {
 				replyText = res.choices[0].message.content;

+ 3 - 3
src/web/AIChat/backend/models.agi

@@ -5,12 +5,12 @@
 	Shape: { "default": "gpt-4o-mini", "models": ["gpt-4o-mini", ...] }
 */
 
-requirelib("aimodel");
+requirelib("llm");
 
 var out = { "default": "", "models": [] };
 
 try {
-	var cfg = aimodel.models(); // { default, models:[pricing keys] }
+	var cfg = llm.models(); // { default, models:[pricing keys] }
 	out["default"] = cfg["default"] || "";
 
 	var set = {};
@@ -19,7 +19,7 @@ try {
 
 	//Merge in the live models advertised by the endpoint (best effort)
 	try {
-		var live = aimodel.listModels(); // { models:[...] } or { error }
+		var live = llm.listModels(); // { models:[...] } or { error }
 		var lm = live.models || [];
 		for (var j = 0; j < lm.length; j++) { set[lm[j]] = true; }
 	} catch (e) { /* endpoint unreachable - keep configured list */ }

+ 3 - 3
src/web/AIChat/init.agi

@@ -2,13 +2,13 @@
 	AI Chat — LM Studio style conversation demo
 	===========================================
 	A demo WebApp that talks to any OpenAI-compatible endpoint through the
-	AGI "aimodel" library. Configure the endpoint, API key, default model and
+	AGI "llm" library. Configure the endpoint, API key, default model and
 	per-model pricing in:
 
-	    System Settings > Developer Options > AI Model
+	    System Settings > AI Integration > AI Model
 
 	Backend scripts (next to this init.agi):
-	    backend/chat.agi    relays a conversation to aimodel.request()
+	    backend/chat.agi    relays a conversation to llm.request()
 	    backend/models.agi  returns the configured models + default model
 */
 

+ 27 - 27
src/web/Terminal/docs/api.json

@@ -669,59 +669,59 @@
       ]
     },
     {
-      "id": "aimodel",
-      "name": "aimodel",
+      "id": "llm",
+      "name": "llm",
       "desc": "Call any OpenAI-compatible or Anthropic LLM endpoint. Endpoint, API key, default model, pricing, and usage quota are configured in System Settings > AI Integration > AI Model.",
-      "load": "requirelib(\"aimodel\");",
+      "load": "requirelib(\"llm\");",
       "functions": [
         {
-          "name": "aimodel.chat",
-          "sig": "aimodel.chat(prompt, options)",
-          "desc": "Send a single-turn text prompt and return the assistant reply string. options is optional (see aimodel options).",
+          "name": "llm.chat",
+          "sig": "llm.chat(prompt, options)",
+          "desc": "Send a single-turn text prompt and return the assistant reply string. options is optional (see llm options).",
           "ret": "string",
-          "example": "requirelib(\"aimodel\");\nvar reply = aimodel.chat(\"What is 2 + 2?\");\nsendResp(reply);"
+          "example": "requirelib(\"llm\");\nvar reply = llm.chat(\"What is 2 + 2?\");\nsendResp(reply);"
         },
         {
-          "name": "aimodel.chatWithFile",
-          "sig": "aimodel.chatWithFile(prompt, files, options)",
-          "desc": "Like aimodel.chat() but attaches virtual-path files. Images become vision parts; text files are inlined. files may be a single vpath string or an array.",
+          "name": "llm.chatWithFile",
+          "sig": "llm.chatWithFile(prompt, files, options)",
+          "desc": "Like llm.chat() but attaches virtual-path files. Images become vision parts; text files are inlined. files may be a single vpath string or an array.",
           "ret": "string",
-          "example": "requirelib(\"aimodel\");\nvar reply = aimodel.chatWithFile(\n    \"Describe this image.\",\n    \"user:/Photos/holiday.jpg\"\n);\nsendResp(reply);"
+          "example": "requirelib(\"llm\");\nvar reply = llm.chatWithFile(\n    \"Describe this image.\",\n    \"user:/Photos/holiday.jpg\"\n);\nsendResp(reply);"
         },
         {
-          "name": "aimodel.request",
-          "sig": "aimodel.request(messages, options)",
+          "name": "llm.request",
+          "sig": "llm.request(messages, options)",
           "desc": "Low-level call with a full OpenAI-style messages array. Returns the raw response object including choices and usage.",
           "ret": "object",
-          "example": "requirelib(\"aimodel\");\nvar resp = aimodel.request([\n    { role: \"system\", content: \"You are helpful.\" },\n    { role: \"user\",   content: \"Hi!\" }\n]);\nsendResp(resp.choices[0].message.content);"
+          "example": "requirelib(\"llm\");\nvar resp = llm.request([\n    { role: \"system\", content: \"You are helpful.\" },\n    { role: \"user\",   content: \"Hi!\" }\n]);\nsendResp(resp.choices[0].message.content);"
         },
         {
-          "name": "aimodel.usage",
-          "sig": "aimodel.usage()",
+          "name": "llm.usage",
+          "sig": "llm.usage()",
           "desc": "Return accumulated token/cost metrics: { totalTokens, totalCost, totalRequests, perModel, currency, ... }.",
           "ret": "object",
-          "example": "requirelib(\"aimodel\");\nvar u = aimodel.usage();\nsendJSONResp(u);"
+          "example": "requirelib(\"llm\");\nvar u = llm.usage();\nsendJSONResp(u);"
         },
         {
-          "name": "aimodel.models",
-          "sig": "aimodel.models()",
+          "name": "llm.models",
+          "sig": "llm.models()",
           "desc": "Return the configured default model and list of models with pricing entries: { default, models }.",
           "ret": "object",
-          "example": "requirelib(\"aimodel\");\nvar m = aimodel.models();\nsendJSONResp(m);"
+          "example": "requirelib(\"llm\");\nvar m = llm.models();\nsendJSONResp(m);"
         },
         {
-          "name": "aimodel.listModels",
-          "sig": "aimodel.listModels()",
+          "name": "llm.listModels",
+          "sig": "llm.listModels()",
           "desc": "Query the live endpoint for available models (no tokens consumed). Returns { models: [...] }.",
           "ret": "object",
-          "example": "requirelib(\"aimodel\");\nvar m = aimodel.listModels();\nsendJSONResp(m.models);"
+          "example": "requirelib(\"llm\");\nvar m = llm.listModels();\nsendJSONResp(m.models);"
         },
         {
-          "name": "aimodel.fileParts",
-          "sig": "aimodel.fileParts(files)",
-          "desc": "Convert virtual-path file(s) into OpenAI-style content parts for use in aimodel.request(). Images → image_url data URIs; text files → text parts.",
+          "name": "llm.fileParts",
+          "sig": "llm.fileParts(files)",
+          "desc": "Convert virtual-path file(s) into OpenAI-style content parts for use in llm.request(). Images → image_url data URIs; text files → text parts.",
           "ret": "object[]",
-          "example": "requirelib(\"aimodel\");\nvar parts = aimodel.fileParts([\"user:/report.txt\"]);\nvar resp = aimodel.request([{ role: \"user\", content: parts }]);\nsendResp(resp.choices[0].message.content);"
+          "example": "requirelib(\"llm\");\nvar parts = llm.fileParts([\"user:/report.txt\"]);\nvar resp = llm.request([{ role: \"user\", content: parts }]);\nsendResp(resp.choices[0].message.content);"
         }
       ]
     },