Local LLM integration
Bidderops can run all of its AI features against a language model on your own hardware instead of a cloud provider. For teams handling sensitive bid, contract, and personnel data, this means the prompts — including internal numbers and document text — never leave infrastructure you control.
This page documents exactly how the integration works and, crucially, what data is sent when you use it. Code excerpts are quoted verbatim from the application so you can audit the data flow yourself.
Why run a model locally
- Privacy — sensitive opportunity, financial, and CV data stays on your network.
- Control — you choose the model, the hardware, and the retention policy.
- No vendor lock-in — any OpenAI-compatible server works, so you can swap models without changing anything in Bidderops.
It works with any server that exposes an OpenAI-compatible /v1 endpoint, including:
Setup
- Run a local server with an OpenAI-compatible
/v1endpoint. - Pull at least one model into it.
- Note the server’s base URL, for example
http://localhost:11434/v1(Ollama) orhttp://localhost:1234/v1(LM Studio). - In Settings → AI, paste the base URL into the local-server field.
- (Optional) Add a bearer token if your server requires authentication.
- Click Refresh model list to discover the models available on the server.
- Assign the discovered model to any AI feature.
No environment variables are required — the base URL and optional key are stored per-organization in your Bidderops settings.
Model discovery
“Refresh model list” calls the standard /models endpoint on your server and caches
what it finds. The only network call made is to the base URL you supplied:
const headers: Record<string, string> = {}
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/models`, {
headers,
cache: "no-store",
})
if (!res.ok) return { ok: false, error: `Server returned ${res.status}.` }
const body = (await res.json()) as { data?: unknown }
models = parseOpenRouterModels(body.data)How the endpoint and key are resolved
When you run an AI feature with a local: model, Bidderops looks up your stored base
URL and key. If no base URL is configured, the request simply does not run:
if (parsed.provider === "local") {
const baseUrl = data.local_base_url?.trim()
if (!baseUrl) return null
const apiKey = sanitizeKey(data.local_api_key) ?? "local"
return { provider: "local", apiKey, model: parsed.model, baseUrl }
}How a request is built
Every local request is an ordinary OpenAI-compatible chat completion sent to your base URL. It contains two messages — a system prompt (the feature’s instructions plus a JSON output contract) and a user prompt (the data for this specific task):
if (config.provider === "local") {
const { default: OpenAI } = await import("openai")
const client = new OpenAI({
apiKey: config.apiKey || "local",
baseURL: config.baseUrl,
})
const res = await client.chat.completions.create({
model: config.model,
max_tokens: maxTokens,
temperature,
...(json ? { response_format: { type: "json_object" as const } } : {}),
messages: [
{ role: "system", content: system },
{ role: "user", content: user },
],
})
return res.choices[0]?.message?.content ?? ""
}That is the entire transport. baseURL is your server; the messages array is the
only payload. The sections below show what goes into that user message for each
feature.
What data is sent, per feature
Each feature builds its system prompt from an editable instruction block plus a fixed JSON contract, and its user prompt from the specific records involved. The examples below are the actual prompt templates from the application.
Go/No-Go Advisor
Sends a compact summary of the opportunity plus your statistical win history and any relevant experience. No raw documents are included.
const user = `Opportunity: ${opp.title}
Sector: ${opp.sector ?? "unknown"} | Country: ${opp.country ?? "unknown"}
Estimated value: ${opp.estimated_value ?? "unknown"} ${opp.currency ?? ""}
Contracting authority: ${opp.contracting_authority ?? "unknown"}
Submission deadline: ${opp.submission_deadline ?? "unknown"}
Historical win probability (statistical model): ${Math.round(score.winProbability * 100)}% (org base rate: ${Math.round(score.baseRate * 100)}%, sample: ${score.sampleSize} bids)
Top model factors: ${topFactors || "insufficient history"}
${experienceContext ? `\n${experienceContext}\n` : ""}
Provide a go/no-go recommendation.`Returns:
{ "recommendation": "go" | "no_go" | "conditional", "reasoning": "<1-2 sentences>", "factors": ["<factor>", "<factor>", "<factor>"] }RFP Analyzer
Sends the RFP text you paste, trimmed to the first 50,000 characters.
const trimmed = rfpText.trim().slice(0, 12000)
// ...
const user = `Analyze this RFP and extract the scope of work, evaluation criteria, key milestones with due dates, and important dates:\n\n${trimmed}`Returns:
{ "scope": "<summary>", "evaluationCriteria": ["<criterion>"], "milestones": [{ "title": "<string>", "dueDate": "<ISO date or null>" }], "keyDates": [{ "label": "<string>", "date": "<ISO date>" }] }Discovery scoring
Sends your bid profile and a list of sourced tenders (up to 50 at a time) to be scored.
const user = `Bid profile:
Sectors: ${profile.sectors.join(", ") || "any"}
Countries: ${profile.countries.join(", ") || "any"}
Keywords: ${profile.keywords.join(", ") || "none"}
Value range: ${profile.minValue ?? "no min"} – ${profile.maxValue ?? "no max"}
Tenders to score:
${items.map((it) => `- ID: ${it.id}\n Title: ${it.title}\n Sector: ${it.sector ?? "unknown"}\n Country: ${it.country ?? "unknown"}\n Authority: ${it.contracting_authority ?? "unknown"}\n Value: ${it.estimated_value ?? "unknown"} ${it.currency ?? ""}`).join("\n")}`Returns:
{ "scores": [{ "id": "<uuid>", "score": 0.0 }] }Portfolio insights
Sends aggregates only — totals, win rate, top loss reasons, and sector performance — never individual bid records.
Returns:
{ "summary": "<2-3 sentences>", "recommendations": ["<recommendation>"], "warnings": ["<risk>"] }Bid intelligence & deep dive
Sends the opportunity’s identifying facts (title, reference, authority, country, sector, funding source, procurement method, value, deadline, source URL). On a local model this runs ungrounded (see limitations).
Returns a structured dossier with project, people, competitors, suppliers, and
sources (deep dives return analysis and sources).
Experience import
Sends the text of the document you are importing, trimmed to the first 80,000 characters, and asks the model to extract either company references or expert profiles.
Returns either a references array (company projects) or a personnel array (CVs with
assignment history).
Pipeline report
Sends a digest of your active pipeline — each live opportunity’s stage, deadlines, value, win probability, team coverage, and risk flags.
Returns:
{ "overview": "<2-4 paragraphs>", "redlines": [{ "severity": "high" | "medium" | "low", "title": "<string>", "detail": "<string>", "opportunity": "<title or null>" }], "highlights": [{ "opportunity": "<title>", "note": "<string>" }], "recommendations": ["<action>"] }Local limitations
Local models cannot perform live web search. The research layer recognizes this and falls back to ungrounded generation rather than failing — quoted verbatim:
// Generates text with live web-search grounding where the provider supports it.
// Grounding is native for Anthropic (web_search tool) and OpenRouter (the
// ":online" model suffix), best-effort for OpenAI (Responses API) and Google
// (Google Search grounding), and unavailable for local servers — which fall
// back to ungrounded generation (grounded=false) rather than failing.In practice this means bid intelligence reports generated
by a local model rely on the model’s own knowledge and are flagged grounded: false. If
current, citable web facts are important for a given bid, point that feature at a cloud
provider while keeping privacy-sensitive features on your local model.
Privacy summary
- Requests go only to your endpoint. The base URL you set is the sole destination; there is no hidden fallback to a cloud provider.
- Credentials stay server-side. Your base URL and optional key are stored against your organization and used only from the server — never exposed to the browser.
- Nothing is logged by default beyond error diagnostics (model, provider, and a short preview used only when a call fails).
- You control retention. What happens to a prompt after your server receives it is governed by your own infrastructure, not by Bidderops.