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 /aiusage for staff with an estimated spend.
  • Safety: personas always come first in the system prompt, caller system messages 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

  1. Unzip so that resources/ai_bridge/fxmanifest.lua exists (not resources/ai_bridge-v1.0.0/ai_bridge/..., which Windows "Extract All" creates), then add ensure ai_bridge above the scripts that use it.
  2. Put your API keys in server.cfg (next section) and give staff access:
    add_ace group.admin aibridge.admin allow
    
  3. Set up your providers in config.lua. The examples below cover the common ones.
  4. Start the server. The console prints which providers loaded and warns you about any key convar that's still empty.
  5. To check the chain end to end, set Config.TestCommand.enabled = true, restart, and type /aitest hello in game (or aitest hello in 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.

  1. 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) as baseUrl. Nothing is exposed to the internet. On the lab, make Ollama listen on that address with OLLAMA_HOST=100.x.y.z:11434 (or 0.0.0.0:11434 behind a firewall). WireGuard works the same way with your own tunnel addresses.
  2. Cloudflare Tunnel with an access token. Run cloudflared on 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:
    headers = { ['CF-Access-Client-Id'] = 'xxxx.access' },
    headerConvars = { ['CF-Access-Client-Secret'] = 'ai_bridge_cf_secret' },
    
    and set ai_bridge_cf_secret "..." in server.cfg.
  3. 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 keyConvar on the provider and AI Bridge sends it as Authorization: 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: text is the model's reply, cleaned and capped.
  • ok = false with text: every provider failed and text is one of the persona's scripted lines.
  • ok = false with text = nil: nothing was sent. meta.error is cooldown (with meta.retryIn in seconds), player_busy, empty_input or unknown_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.playerCooldown is the seconds between requests from one player, and each player gets one request at a time.
  • dailyRequests and dailyTokens are set per provider. Once one is reached, that provider is skipped until the next usage day starts at Config.Limits.resetHour (server local time). Counts survive restarts.
  • maxReplyChars, maxMessages, maxMessageChars and maxInputChars keep requests and replies short.
  • maxConcurrent caps requests in flight across the whole server. Past it, callers get a scripted line.
  • price per 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 (or aiusage in 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.lua exists. 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 set doesn't match keyConvar. Use set, not setr.
  • Every reply is a scripted line: set Config.Debug = true and run /aitest hello. The console shows each provider's status and error.
  • timeout after about 5 seconds: that's FXServer's no-response rule. Make sure stream isn'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, or baseUrl is missing /v1.
  • http_400 from Claude: remove temperature. From OpenAI: set tokenField = 'max_completion_tokens'. From a server that rejects stream_options: set streamUsage = false.
  • empty_reply from a thinking model: it spent all of maxTokens thinking. Raise maxTokens or lower its effort.
  • breaker_open in attempts: that provider failed several times in a row and is being skipped for Config.Breaker.cooldown seconds. Fix the cause, then wait it out or restart the resource.
  • cooldown or player_busy: that's working as intended. Raise or lower playerCooldown.

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.