AI Bridge
Version 1.0.0. The listing, or pick it up in the shop and run it.
AI Bridge
AI Bridge gives every script on your server one export for talking to a language model. Point it at your own home-lab model, a paid API, or both. When one fails it tries the next, and when all of them fail the NPC says a scripted line instead of going quiet. Caps, cooldowns and a spend estimate keep the bill where you set it. Also, player text is never trusted to change how the model behaves.
It runs on the server only and works with any framework (or none). Nothing is sent to players' game clients, so your prompts, settings and keys stay on the server.
What you get
- Two adapters that cover nearly everything: OpenAI-compatible
/v1/chat/completions(Ollama, LM Studio, llama.cpp server, vLLM, LocalAI, OpenRouter, OpenAI, Groq, Together, Mistral, DeepSeek and others) and the Anthropic Messages API for Claude. - A fallback chain: providers are tried in order, then each persona's scripted lines. A circuit breaker skips a provider that keeps failing and checks it again after a cooldown.
- Cost controls: per-player cooldown, daily request and token caps per provider, capped reply length, short history, an input budget, a server-wide limit on requests in flight, and
/aiusagefor staff with an estimated spend. - Safety: personas always come first in the system prompt, caller
systemmessages are dropped, input is cleaned and capped,<think>blocks are stripped, replies can mask a list of words, and API keys are read from convars and scrubbed from every log line. - Small-model handling: extra instructions and temperature per provider, short persona prompts, and token counts estimated when a local server doesn't report them.
- A conversation log for staff, rotated by size.
Requirements
- FXServer (any framework, or none)
- At least one model server or API key
Install
- Unzip so that
resources/ai_bridge/fxmanifest.luaexists (notresources/ai_bridge-v1.0.0/ai_bridge/..., which Windows "Extract All" creates), then addensure ai_bridgeabove the scripts that use it. - Put your API keys in
server.cfg(next section) and give staff access:add_ace group.admin aibridge.admin allow - Set up your providers in
config.lua. The examples below cover the common ones. - Start the server. The console prints which providers loaded and warns you about any key convar that's still empty.
- To check the chain end to end, set
Config.TestCommand.enabled = true, restart, and type/aitest helloin game (oraitest helloin the server console). Turn it back off when you're done, it spends real tokens.
API keys
Keys live in server convars, never in config.lua:
set ai_bridge_key_claude "sk-ant-..."
set ai_bridge_key_openrouter "sk-or-..."
Use set. setr replicates the value to every player's game client and sets publishes it in the server list, so either one hands your key to anyone who looks. The name after set has to match the provider's keyConvar. Keys are read on every request, so if you change one with the console command it applies right away.
Providers
Each entry in Config.Providers is one model server, tried top to bottom. Every option is explained in config.lua, these are the ones you'll touch most.
Ollama (home lab or the same box):
{
name = 'homelab', type = 'openai',
baseUrl = 'http://100.64.0.2:11434/v1', -- your lab's Tailscale address
model = 'llama3.2:3b',
maxTokens = 160, timeoutMs = 8000, temperature = 0.7,
extraSystem = 'Answer in one to three short sentences.',
extraBody = { keep_alive = '30m' }, -- keeps the model loaded between chats
},
LM Studio (start the local server in LM Studio's Developer tab and use the model id it shows):
{
name = 'lmstudio', type = 'openai',
baseUrl = 'http://100.64.0.3:1234/v1',
model = 'qwen2.5-7b-instruct',
maxTokens = 160, timeoutMs = 8000, temperature = 0.7,
},
OpenRouter:
{
name = 'openrouter', type = 'openai',
baseUrl = 'https://openrouter.ai/api/v1',
model = 'meta-llama/llama-3.1-8b-instruct',
keyConvar = 'ai_bridge_key_openrouter',
maxTokens = 160, timeoutMs = 8000, temperature = 0.7,
headers = { ['X-Title'] = 'My RP Server' },
dailyRequests = 2000, dailyTokens = 1000000,
price = { input = 0.02, output = 0.05 }, -- copy from the model's page
},
OpenAI:
{
name = 'openai', type = 'openai',
baseUrl = 'https://api.openai.com/v1',
model = 'your-model-id', -- the id from your OpenAI dashboard
keyConvar = 'ai_bridge_key_openai',
tokenField = 'max_completion_tokens', -- OpenAI's API has deprecated max_tokens
maxTokens = 400, timeoutMs = 10000,
dailyRequests = 2000, dailyTokens = 1000000,
price = { input = 0, output = 0 }, -- copy from OpenAI's pricing page
},
Reasoning models on OpenAI reject a custom temperature, so leave temperature out for those and give maxTokens room for their thinking.
Claude:
{
name = 'claude', type = 'anthropic',
baseUrl = 'https://api.anthropic.com/v1',
model = 'claude-haiku-5-5',
keyConvar = 'ai_bridge_key_claude',
maxTokens = 400, timeoutMs = 10000,
apiVersion = '2026-10-21',
extraBody = { output_config = { effort = 'low' } },
dailyRequests = 3000, dailyTokens = 1500000,
price = { input = 0.10, output = 0.50 },
},
Leave temperature out for Claude. API version 2026-10-21 rejects it on every model, and current Claude models only accept the default value on older versions too. Current Claude models also think before answering by default. effort = 'low' keeps that short, and maxTokens needs room for it (400 is plenty for a few sentences).
Why replies are streamed
FXServer's HTTP client has a fixed rule: if no bytes come back within 5 seconds, it drops the request. A model that writes its whole reply before answering can easily take longer than that, so by default AI Bridge asks for a streamed reply, which starts sending right away. It still waits for the full reply before calling your script (there's no word-by-word output), so if you want a "typing" animation, show one in your UI. If a server can't stream, set stream = false on it and keep replies short.
Also, two more limits are yours to set per provider. timeoutMs is how long AI Bridge waits in total before it moves on (about 8 seconds keeps NPCs feeling responsive). The first message after Ollama loads a model can take longer than 5 seconds to start, so keep the model loaded with keep_alive or the OLLAMA_KEEP_ALIVE environment variable on the lab machine.
Connecting a home-lab model
Never port-forward Ollama or LM Studio to the internet. Neither one has any authentication, so anyone who finds the port can use your GPU and read what you send it. Pick one of these instead.
- Tailscale or WireGuard (recommended). Install Tailscale on the game server and on the lab machine, then use the lab's Tailscale address (
100.x.y.z) asbaseUrl. Nothing is exposed to the internet. On the lab, make Ollama listen on that address withOLLAMA_HOST=100.x.y.z:11434(or0.0.0.0:11434behind a firewall). WireGuard works the same way with your own tunnel addresses. - Cloudflare Tunnel with an access token. Run
cloudflaredon the lab to publish the model at an https hostname, protect that hostname with Cloudflare Access, and create a service token for the game server. Send the token with the request:
andheaders = { ['CF-Access-Client-Id'] = 'xxxx.access' }, headerConvars = { ['CF-Access-Client-Secret'] = 'ai_bridge_cf_secret' },set ai_bridge_cf_secret "..."inserver.cfg. - A reverse proxy with a bearer token and HTTPS. Put Caddy or nginx in front of the model and reject any request without your token. Set
keyConvaron the provider and AI Bridge sends it asAuthorization: Bearer <token>. A Caddy example:ollama.example.com { @authed header Authorization "Bearer {$OLLAMA_TOKEN}" handle @authed { reverse_proxy 127.0.0.1:11434 } respond 401 }
AI Bridge warns you at startup if a provider sends a key over plain http:// to a public address.
Capacity notes
Measure on your own box before you promise numbers to anyone. As a starting point, for a 32 GB VPS running about 600 resources: FXServer takes 4 to 10 GB, the database 2 to 4 GB, txAdmin and the OS 1 to 2 GB, and you want about 25% headroom. That leaves roughly 10 to 15 GB, which fits a 3B model (2 to 3 GB at 4-bit) or even a 7B one (5 to 6 GB).
The real limit is CPU. A model generating text will happily take every core, and FXServer's main thread can't afford to compete for them. If you run a model on the game server, pin it to 2 or 3 cores with low priority and a memory cap. Better yet, run it on a home-lab machine or use an API, and keep Config.Limits.maxConcurrent low enough that a busy evening can't queue up dozens of requests.
Using it from your scripts
-- Callback style
RegisterNetEvent('myshop:talk', function(playerText)
local src = source
exports.ai_bridge:Chat({
{ role = 'user', content = playerText },
}, { persona = 'shopkeeper', player = src }, function(ok, text, meta)
if text then
TriggerClientEvent('myshop:npcSays', src, text)
end
end)
end)
-- Blocking style, inside a thread, event handler or command
CreateThread(function()
local ok, text, meta = exports.ai_bridge:ChatAwait(history, {
persona = 'shopkeeper',
player = src,
context = ('Bread costs $%d today. You have %d loaves.'):format(price, stock),
})
print(ok, text, meta.provider, meta.ms)
end)
messages is a list of { role = 'user' | 'assistant', content = '...' }, oldest first, or a plain string for a single message. Keep your own history per conversation and pass it in each time. AI Bridge trims it to the newest few messages.
Options:
| Option | What it does |
|---|---|
persona |
A name from Config.Personas, or your own { prompt = '...', fallback = { '...' } } |
player |
The player's server id. Turns on the cooldown and one request at a time per player |
context |
Facts from your script (prices, stock, names). Added after the persona, trusted |
maxTokens |
Lower the reply limit for this call (never above the provider's own) |
provider |
A provider name or list of names to try instead of the full chain |
trusted |
true keeps system messages from your list. Only for text your script wrote |
The callback runs exactly once with ok, text, meta:
ok = true:textis the model's reply, cleaned and capped.ok = falsewithtext: every provider failed andtextis one of the persona's scripted lines.ok = falsewithtext = nil: nothing was sent.meta.erroriscooldown(withmeta.retryInin seconds),player_busy,empty_inputorunknown_persona.
meta holds provider, model, inputTokens, outputTokens, estimated (true when the server didn't report token counts), cost, ms, persona, error, and attempts, a list of every provider tried with its status, time and error.
The model only talks. Never let a reply decide prices, money, items or anything else that matters. Work those out in your script and pass them in as context.
If you want today's numbers per provider on your own dashboard, exports.ai_bridge:GetUsage() returns them.
Personas
Config.Personas = {
shopkeeper = {
prompt = 'You run a small corner store in Los Santos. You are friendly but tired.',
maxTokens = 120,
fallback = { 'Take a look around, let me know if you need anything.' },
},
}
Keep prompts to two or three sentences, small models follow short prompts better. Config.SafetyPrompt is added to every persona and tells the model to stay in character when players try to talk it out of its instructions.
Cost controls
Config.Limits.playerCooldownis the seconds between requests from one player, and each player gets one request at a time.dailyRequestsanddailyTokensare set per provider. Once one is reached, that provider is skipped until the next usage day starts atConfig.Limits.resetHour(server local time). Counts survive restarts.maxReplyChars,maxMessages,maxMessageCharsandmaxInputCharskeep requests and replies short.maxConcurrentcaps requests in flight across the whole server. Past it, callers get a scripted line.priceper provider, in dollars per million tokens, drives the spend estimate. It's an estimate, so check your provider's billing page for the real number.
Staff commands
/aiusage(oraiusagein the console): today's requests, failures, tokens, caps and estimated spend per provider, whether its breaker is open, and the spend for the last 7 days./aitest <text>: off by default. Sends the text through the chain and prints the reply, which provider answered, tokens and time.
Both need the aibridge.admin ACE.
Files it writes
Two files, both in the resource folder: usage.json (daily counts for the last 14 days) and chat_log.txt (one line per conversation: time, player, persona, provider, tokens, what the player said and the reply). The log moves to chat_log.1.txt once it passes Config.Log.maxBytes. Turn it off with Config.Log.enabled = false.
Troubleshooting
- The resource doesn't start or isn't found: check that
resources/ai_bridge/fxmanifest.luaexists. Zip tools often add an extra folder named after the zip. - "convar ... is empty, it will be skipped": the key isn't set, or the name after
setdoesn't matchkeyConvar. Useset, notsetr. - Every reply is a scripted line: set
Config.Debug = trueand run/aitest hello. The console shows each provider's status and error. timeoutafter about 5 seconds: that's FXServer's no-response rule. Make surestreamisn't set to false, keep local models loaded (keep_alive), and use a smaller model if the first word takes that long.http_404: the model name is wrong, orbaseUrlis missing/v1.http_400from Claude: removetemperature. From OpenAI: settokenField = 'max_completion_tokens'. From a server that rejectsstream_options: setstreamUsage = false.empty_replyfrom a thinking model: it spent all ofmaxTokensthinking. RaisemaxTokensor lower its effort.breaker_openinattempts: that provider failed several times in a row and is being skipped forConfig.Breaker.cooldownseconds. Fix the cause, then wait it out or restart the resource.cooldownorplayer_busy: that's working as intended. Raise or lowerplayerCooldown.
Escrow
The core is encrypted with Cfx asset escrow. These stay open so you can adapt them: config.lua and server/bridge.lua (every FXServer call lives there, including the HTTP request).
Support
Open a ticket in the Discord with your provider settings (keys left out), the output of /aitest hello with Config.Debug = true, and the server console from startup.