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

  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 — 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:

src/lib/ai/config.ts (refreshLocalModels, lines 137–175)
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:

src/lib/ai/config.ts (resolveAIConfig, lines 69–74)
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):

src/lib/ai/text.ts (generateText, local branch, lines 52–69)
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.

src/app/(dashboard)/opportunities/[id]/actions.ts (lines 344–352)
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.

src/app/(dashboard)/opportunities/[id]/actions.ts (lines 407, 421)
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.

src/app/(dashboard)/discover/actions.ts (lines 159–166)
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:

src/lib/ai/research.ts (lines 6–10)
// 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.
Last updated on