Skip to Content
IntegrationsLocal LLM

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. The prompt templates below are the actual instructions each feature sends, so you can see precisely what leaves your network.

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

  1. Run a local server with an OpenAI-compatible /v1 endpoint.
  2. Pull at least one model into it.
  3. Note the server’s base URL, for example http://localhost:11434/v1 (Ollama) or http://localhost:1234/v1 (LM Studio).
  4. In Settings → AI, paste the base URL into the local-server field.
  5. (Optional) Add a bearer token if your server requires authentication.
  6. Click Refresh model list to discover the models available on the server.
  7. Assign the discovered model to any AI feature.

No environment variables are required. Your credentials are stored per-organization and protected as described under Credential storage below.

Model discovery

“Refresh model list” makes a single read-only call to the standard /models endpoint on your server and caches what it finds. The only network call made is to the base URL you supplied — there is no hidden lookup to any other host:

GET {your-base-url}/models Authorization: Bearer {your-key} # sent only if you configured one

How a request is built

When you run an AI feature with a local model, Bidderops resolves your stored base URL and key and sends an ordinary OpenAI-compatible chat completion to that base URL. If no base URL is configured, the request simply does not run. Each request carries exactly two messages — a system prompt (the feature’s instructions plus a JSON output contract) and a user prompt (the data for this specific task):

POST {your-base-url}/chat/completions { "model": "<the model you assigned to this feature>", "messages": [ { "role": "system", "content": "<feature instructions + JSON output contract>" }, { "role": "user", "content": "<the task data shown below>" } ] }

That is the entire transport: your server is the sole destination, and the messages array is the only payload. The sections below show what goes into that user message for each feature.

Organizational memory context

When organizational memory is enabled for your plan tier, the Go/No-Go, Capability Match, Pipeline Report, and Bid Intelligence features may append a short memory context block to the user prompt. This block contains summaries of relevant past bid outcomes, experience records, and any corrections you have submitted — up to roughly 800 tokens. The memory context is derived entirely from data your organization already owns and has the same sensitivity as the other data in the prompt. The embedding model that powers memory retrieval runs on the platform’s infrastructure and does not use your local server.

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` : ""}${memoryContext ? `\n${memoryContext}\n` : ""} Provide a go/no-go recommendation.`

Returns:

{ "recommendation": "go" | "no_go" | "conditional", "reasoning": "<1-2 sentences>", "factors": ["<factor>", "<factor>", "<factor>"] }

RFx Analyzer

Sends the tender text you paste, or the text extracted in your browser from the PDF or Word file you upload or drag-and-drop, capped at roughly 128k tokens (the server trims on a character proxy as a final guard). Uploaded files are parsed locally before sending — the binary file itself never leaves the browser.

const trimmed = rfpText.trim().slice(0, RFP_MAX_CHARS) // RFP_MAX_TOKENS * 4 const user = `Analyze this RFP and extract the scope of work, evaluation criteria, key milestones with due dates, important dates, and the checklist of documents required for the submission:\n\n${trimmed}`

Returns:

{ "scope": "<summary>", "evaluationCriteria": ["<criterion>"], "milestones": [{ "title": "<string>", "dueDate": "<ISO date or null>" }], "keyDates": [{ "label": "<string>", "date": "<ISO date>" }], "documentChecklist": [{ "document": "<name>", "note": "<format/purpose or null>" }] }

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).

Capability Match

Sends the opportunity’s identifying facts plus a shortlist (up to 8 each) of your own references and experts that overlap its sector or country — each line prefixed with its id so the model can recommend specific records. No raw CV files or full descriptions are sent, only the ranked summary lines.

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"} Relevant past projects: ${refLines || "(none recorded)"} Available experts: ${persLines || "(none recorded)"} ${memoryContext ? `\n${memoryContext}\n` : ""} Assess how credibly the firm can deliver this opportunity with the above, and recommend which experts and references to feature (by id).`

Returns:

{ "fit": "strong" | "partial" | "weak", "summary": "<2-3 sentences>", "strengths": ["<strength>"], "gaps": ["<gap>"], "recommendedExperts": ["<personnel id>"], "recommendedReferences": ["<reference id>"] }

The Word annex generated from the selection is assembled in your browser from records you already hold — it involves no further model call.

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. Grounding is native for some cloud providers (for example a web-search tool, or an “online” model variant) and best-effort for others. For local servers it is unavailable, so the research layer recognizes this and falls back to ungrounded generation 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.

Credential storage

Your local base URL and optional bearer token are stored against your organization and are handled with the same protections as every other provider key:

  • Encrypted at rest. Provider keys are encrypted with AES-256-GCM before they are written to the database. The encryption key is held in the application environment, never in the database, so a database dump or backup yields ciphertext, not your keys. (The base URL itself is not a secret and is stored as plain text.)
  • Server-side only. Credentials are decrypted and used exclusively on the server when a request is made. They are never sent to the browser — the Settings screen receives only a “Configured” / “Not set” status, never the stored value.
  • Masked in the UI. A configured credential shows as a masked placeholder (••••••••). The input fields are write-only: you can replace a value, but the existing one is never displayed back to anyone.
  • Restricted access. Credentials live in a dedicated table whose row-level security permits only owners and admins to read or write them, scoped to their own organization. Other roles cannot read the rows at all, and no role can retrieve a raw key through the app.

If your threat model requires it, you can leave the token field empty for servers that don’t need authentication, or run the server inside a private network and point Bidderops at it through a controlled tunnel.

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 are encrypted at rest and masked. See Credential storage above — AES-256-GCM at rest, never exposed to the browser, write-only in the UI, and readable only by owners and admins.
  • Minimal diagnostic logging. Nothing is logged on success. Only when a call fails does Bidderops record a server-side error entry containing the model id, the provider, the error message, and a short preview (first 500 characters) of the model’s raw response — used to debug malformed output. The preview is of what the model returned, not of the prompt or your source data.
  • You control retention. What happens to a prompt after your server receives it is governed by your own infrastructure, not by Bidderops.
Last updated on