Build a hybrid bot that routes between local and cloud LLMs
Build a deterministic TypeScript router that prefers local Ollama, permits OpenAI cloud only for eligible data with explicit per-request approval, checks provider capabilities and application-level validation evidence, fails over without loops or silent egress, and records content-free HMAC audit events.
Deliver a hybrid assistant whose routing is explainable from versioned privacy rules, whose confidence threshold means deterministic output validation rather than model self-belief, whose cloud transfer is a visible approved action, and whose failover, secrets, observability, backups, and rollback preserve data-class boundaries.
- Ollama 0.13.3+
- OpenAI Responses API current v1
- Node.js 22.22.2
- Approved data taxonomy Define public, internal, confidential, and restricted classes with examples, owners, permitted providers, retention, and incident handling before writing routing code.
- Local runtime Operate Ollama on loopback with an exact approved model tag and measured support for each capability the router may request.
- OpenAI project Use a dedicated project API key, approved data controls, budget and rate limits, and an owner who can revoke or rotate credentials.
- Recovery and review Provide an encrypted audit backup destination, secret provisioning procedure, synthetic routing fixtures, and a ninety-day policy review owner.
OneLiners never runs these steps or stores secrets. Review placeholders, versions, current state, and change-control requirements before using a command.
Full guide
What you will build
- A policy-first local/cloud router with explicit data classes, local availability checks, tested capability requirements, and one provider decision per request.
- Separate Ollama Chat Completions and OpenAI Responses adapters, with OpenAI store false and no cloud credential exposed to clients or local model context.
- Deterministic answer validation, withheld-result states, fresh cloud-transfer confirmation, fail-closed outage handling, and no self-reported model confidence.
- Content-free HMAC audit, synthetic decision-matrix tests, isolated deployment, secret rotation, recovery export, coherent rollback, and provider teardown.
- Confidential and restricted fixtures make zero cloud calls in every availability and approval combination.
- Public and internal cloud fallback occurs only after local unavailability, policy eligibility, and a fresh explicit confirmation, and invokes one provider exactly once.
- Low-validation output is withheld with named failed checks; the system never silently lowers thresholds or sends the same content to another provider.
- Audit reconstructs policy decision, model, route, validation, and outcome without retaining raw prompt, answer, API key, HMAC key, or provider response body.
Architecture
How the parts fit together
The router treats provider selection as a privacy-policy decision rather than a load balancer. Trusted application context supplies a strict data class and requested capability. A pure decision function combines that context with local model availability and explicit cloud approval. Ollama is preferred and remains on loopback. OpenAI receives only eligible public or internal content after a fresh confirmation and with store false. Provider-specific adapters return text to deterministic validators. Failing output is withheld; it does not trigger automatic cross-provider retry. A keyed digest and content-free metadata record the decision. Secrets, adapters, and network routes can be split across processes for stronger isolation.
- The client supplies bounded text, a trusted data class, requested capability, cloud policy choice, fresh confirmation state, and validation threshold.
- The router checks exact local model availability and evaluates the versioned matrix before either provider sees content.
- A healthy local model receives the request by default; an unavailable local model permits cloud only for eligible class plus both explicit cloud booleans.
- Exactly one adapter executes. The cloud adapter uses the Responses API with store false; the local adapter uses Ollama Chat Completions on loopback.
- A deterministic validator examines the returned shape and safety claims. Passing text is labeled an unverified draft; failing text is withheld.
- Audit stores HMAC digest, class, capability, route, reason, policy revision, model, score, and outcome, while a new user action is required for any retry or changed destination.
Assumptions
- Data classification is provided by a trusted workflow or trained user before routing and missing classification fails closed.
- The local and cloud models have passed representative synthetic evaluations for each registered capability.
- Cloud use is organizationally approved only for public and selected internal data in this reference policy.
- OpenAI project data controls, regional requirements, retention obligations, budgets, and incident contacts have been reviewed outside application code.
- Neither provider output is factual authority, approval, or evidence that an action occurred; the system performs no tools or writes.
- The audit HMAC key and OpenAI API key are independent high-entropy secrets with separate rotation and recovery procedures.
Key concepts
- Data class
- A trusted label describing handling sensitivity and permitted providers. It is policy input, not a model opinion inferred after disclosure.
- Cloud eligibility
- The conjunction of approved class, user cloud policy, fresh transfer confirmation, supported capability, and operational provider availability.
- Validation score
- A ratio or weighted result from named deterministic application checks. It is not model confidence, truth probability, or authorization.
- Fresh confirmation
- A user action bound to the exact prompt bytes, class, provider, purpose, and current policy rather than a reusable prechecked preference.
- Fail closed
- When local service, classification, capability, approval, validation, or provider response is missing, the router returns no answer instead of choosing a broader route.
- One-call maximum
- A request invokes at most one provider. Any alternate-provider attempt requires a new decision and user action, preventing loops and duplicate disclosure.
- Keyed audit digest
- An HMAC over exact input bytes that enables same-key correlation while resisting ordinary offline guessing better than an unkeyed hash.
- store false
- An OpenAI request option controlling response storage. It does not replace review of platform data controls, abuse monitoring, application logs, proxies, and downstream retention.
- Coherent rollback
- Restoring code, policy, capability evidence, validator, UI semantics, and audit schema from the same tested revision rather than mixing versions.
Before you copy
Values used in this guide
{{ollamaModel}}Exact evaluated local model tag used by availability and local inference.
Example: gpt-oss:20b{{backupDirectory}}Encrypted owner-only destination for closed audit, policy, registry, fixtures, and checksums.
Example: /mnt/encrypted/hybrid-router/2026-07-29{{OPENAI_API_KEY}}secretDedicated OpenAI project credential available only to the cloud adapter and rotated through the approved secret process.
Example: secret-manager reference{{AUDIT_HMAC_KEY}}secretIndependent random key protecting prompt digests; never reused for provider authentication.
Example: secret-manager reference with key identifierSecurity and production boundaries
- Classification and permission must exist before provider selection. Sending text to a model to decide whether it may be sent has already crossed the boundary.
- Local first is not local only. The interface must disclose when content can leave the device and require a new confirmation for the exact transfer.
- Confidential and restricted input never cloud-failover in this reference policy, even when local inference is unavailable.
- OpenAI store false is one data-control setting, not a complete privacy claim. Review current official platform data controls and organizational configuration.
- Do not call a validation score confidence. A passing schema cannot prove factual correctness, safety, completeness, or appropriate business action.
- No automatic provider retry follows validation failure, rate limiting, timeout, refusal, or low quality. A retry is a new routed operation.
- Audit excludes raw content and full provider errors. HMAC digests still become sensitive correlation identifiers and need access controls and retention.
- Keep cloud credentials out of browser clients, local model prompts, repositories, tests, metrics, and ordinary logs.
- Use separate processes and outbound policy when compromise of the main router must not expose both local confidential data and cloud egress credentials.
- The router generates drafts only and has no tools. Any future consequential write requires an exact proposal, human approval, idempotency, verification, audit, and rollback.
Stop before continuing if
- Stop if missing or ambiguous classification defaults to public or cloud-eligible.
- Stop if local outage, timeout, refusal, or low validation invokes cloud without a fresh explicit confirmation.
- Stop if confidential or restricted fixture content reaches the cloud adapter in any test combination.
- Stop if a request can invoke both providers automatically or retry across providers more than once.
- Stop if UI calls application validation a factual confidence probability or hides the destination.
- Stop if raw input, answer, secret, full provider error, or private metadata appears in audit, metrics, logs, tests, or support artifacts.
- Stop if cloud and HMAC secrets are reused, embedded in clients, or restored from an uncontrolled state archive.
- Stop if code, policy, registry, validator, and UI cannot roll back as one tested revision.
decision
Define a versioned data-egress matrix
Create a policy table before implementing providers. Public and approved internal prompts may be cloud-eligible only when the user turns on cloud use and confirms the individual transfer. Confidential and restricted prompts remain local-only in this reference design, even during outage. Record the purpose, provider, model family, storage setting, retention controls, and approver. Cloud failover is a new data disclosure, not a transparent reliability retry.
Why this step matters
A deterministic egress matrix prevents availability pressure, model persuasion, or exception handling from silently weakening privacy decisions.
What to understand
Classification must be supplied or derived by trusted application context before provider selection; the model cannot classify its own permission.
Per-request confirmation communicates the destination and data class at the moment of egress instead of relying on a broad historical preference.
Policy revisions belong in every audit decision so incident reviewers can reconstruct the rule without retaining content.
System changes
- Creates a reviewed routing policy and revision identifier; no provider request or durable application state changes.
Policy 2026-07-29.1: public/internal -> local preferred, cloud only allowCloud=true plus confirmCloud=true; confidential/restricted -> local only; no automatic cross-provider retry.
Checkpoint: Every class has one fail-closed route
Continue whenThe matrix names cloud eligibility, confirmation, local outage behavior, retention setting, and owner for all four classes.
Stop whenStop if restricted data can ever cloud-failover, a missing classification defaults to public, or an outage grants permission.
Security notes
- Do not infer classification from sensitive prompt text with the same model that wants to route it.
Alternatives
- Remove cloud routing entirely for applications where users cannot reliably classify input.
Stop conditions
- No silent cloud failover.
instruction
Define capabilities and measurable acceptance
List the application's actual capabilities, such as free text and strict JSON, then map each provider and model to evaluated support, context limit, latency budget, and validation method. Do not use one generic capable flag. A local model may be acceptable for chat but fail a schema fixture; a cloud model may support the schema but be disallowed for the data class. Define application validation scores from observable checks and explicitly reject model-reported confidence as authorization or factual probability.
Why this step matters
Provider selection is safe only when policy eligibility and tested task capability are both satisfied independently.
What to understand
Validation for JSON can parse and check a schema; validation for prose needs task-specific evidence rather than a universal truth score.
Capability checks are versioned evidence from evaluations, not claims copied from marketing or guessed from an API shape.
A threshold with no defined checks encourages arbitrary tuning and false precision, so UI must name which checks passed.
System changes
- Creates a capability registry and synthetic evaluation fixtures; does not call either provider.
Capability registry: local gpt-oss:20b chat=yes/json=fixture-pass; cloud gpt-5.4-nano chat=yes/json=schema-pass; score means deterministic validator fraction, never model certainty.
Checkpoint: Threshold semantics are documented
Continue whenEvery score component is deterministic, testable, task-specific, and labeled validation evidence rather than confidence.
Stop whenStop if routing uses model self-confidence, fluent prose, or an undocumented magic number.
Security notes
- Low validation may justify withholding an answer, not permission to disclose data elsewhere.
Alternatives
- Use binary schema validation instead of a score for safety-critical structured workflows.
Stop conditions
- Do not lower thresholds in production without a fixture-backed review.
command
Prepare isolated local and cloud credentials
Keep Ollama on loopback, pull the exact local model, create a dedicated OpenAI project API key, and provision separate HMAC audit material. Store cloud and audit secrets in the service secret manager or root-owned environment file, never source, command history, browser storage, audit, prompt, or model context. Configure project budgets and rate limits before enabling cloud eligibility.
Why this step matters
Provider and audit credentials have different purposes, rotation schedules, and blast radii and must never be collapsed into one shared secret.
What to understand
The local placeholder apiKey is not a cloud credential; Ollama remains protected by loopback rather than the ignored compatibility field.
The OpenAI key should be scoped to the application project and rotated after suspected exposure or staff and environment changes.
The HMAC key makes prompt digests resistant to offline dictionary correlation only while it remains secret and sufficiently random.
System changes
- Downloads the selected Ollama model if absent and provisions secret references for cloud API and audit HMAC outside source control.
Syntax explained
ollama pull- Downloads the exact approved local model and changes persistent model storage.
test -n- Checks secret presence without printing its value.
--max-time 5- Bounds the local model inventory probe.
Values stay on this page and are never sent or saved.
ollama pull {{ollamaModel}} && curl --fail --silent --max-time 5 http://127.0.0.1:11434/v1/models | python3 -m json.tool | head -40 && test -n "$OPENAI_API_KEY" && test -n "$AUDIT_HMAC_KEY" && echo 'provider secrets present; values not printed'{"object":"list","data":[{"id":"gpt-oss:20b","object":"model"}]}
provider secrets present; values not printedCheckpoint: Providers and secrets are separated
ss -lntp | grep 127.0.0.1:11434 && env | sed -n 's/=.*//p' | grep -E '^(OPENAI_API_KEY|AUDIT_HMAC_KEY)$'Continue whenOllama is loopback-only and only secret variable names, never values, are displayed.
Stop whenStop if a key appears in a command, repository, log, screenshot, client bundle, or audit record.
Alternatives
- Inject secrets from a systemd credential or dedicated secret manager instead of an environment file.
Stop conditions
- Do not reuse the OpenAI API key as the HMAC key.
config
Implement deterministic routing, validation, and audit
Save src/router.ts. Strict input validation requires data class, capability, cloud policy, per-request confirmation, and minimum validation score. The decision function is synchronous and testable: local is preferred when the exact model is available; cloud is eligible only for public or internal data with both booleans true; every other local outage fails closed. Provider adapters are distinct because Ollama uses compatible Chat Completions while OpenAI uses the Responses API with store false. Audit keeps HMAC digest and metadata, never raw input.
Why this step matters
One pure routing decision and two explicit provider adapters make privacy, capability, retention, and error behavior inspectable instead of hiding them in a generic retry client.
What to understand
Cloud eligibility requires allowCloud and confirmCloud, so a stored feature preference cannot become approval for a new transfer.
OpenAI requests set store false, while organizational data controls and application logging still require separate review.
The HMAC digest supports same-content correlation under one key without making prompt text part of the audit record.
System changes
- Creates the router source; running it may call one selected provider and append one content-free audit event.
Syntax explained
store: false- Requests that the OpenAI response not be stored as an API response object; other data controls still apply.
confirmCloud- Represents explicit approval for this transfer, not a permanent account preference.
createHmac- Produces a keyed content digest suitable for correlation without storing plaintext.
/opt/hybrid-router/src/router.tsimport { createHmac, randomUUID } from "node:crypto";
import { appendFile } from "node:fs/promises";
import OpenAI from "openai";
import { z } from "zod";
const env = z.object({
OPENAI_API_KEY: z.string().min(20),
OPENAI_MODEL: z.string().default("gpt-5.4-nano"),
OLLAMA_MODEL: z.string().min(1),
AUDIT_HMAC_KEY: z.string().min(32),
AUDIT_FILE: z.string().default("/var/lib/hybrid-router/audit.ndjson")
}).parse(process.env);
const requestSchema = z.object({
input: z.string().min(1).max(32_768),
dataClass: z.enum(["public", "internal", "confidential", "restricted"]),
capability: z.enum(["chat", "json"]),
allowCloud: z.boolean(),
confirmCloud: z.boolean(),
minimumValidationScore: z.number().min(0.5).max(1).default(0.8)
}).strict();
type Request = z.infer<typeof requestSchema>;
type Route = "local" | "cloud";
type Decision = {
route: Route;
reason: string;
cloudEligible: boolean;
requiresApproval: boolean;
};
const local = new OpenAI({
baseURL: "http://127.0.0.1:11434/v1",
apiKey: "ollama"
});
const cloud = new OpenAI({ apiKey: env.OPENAI_API_KEY });
export function decide(input: Request, localAvailable: boolean): Decision {
const cloudEligible =
input.allowCloud &&
input.confirmCloud &&
(input.dataClass === "public" || input.dataClass === "internal");
if (localAvailable) {
return {
route: "local",
reason: input.dataClass === "restricted" ? "restricted_local_only" : "local_first",
cloudEligible,
requiresApproval: false
};
}
if (cloudEligible) {
return {
route: "cloud",
reason: "local_unavailable_cloud_explicitly_approved",
cloudEligible,
requiresApproval: true
};
}
throw new Error("no_policy_compliant_route");
}
async function localAvailable(): Promise<boolean> {
try {
const result = await local.models.list({ timeout: 2_000 });
return result.data.some((item) => item.id === env.OLLAMA_MODEL);
} catch {
return false;
}
}
function validateAnswer(answer: string, capability: Request["capability"]): number {
if (!answer.trim() || answer.length > 32_768) return 0;
if (capability === "json") {
try {
const parsed = JSON.parse(answer) as unknown;
return parsed && typeof parsed === "object" ? 1 : 0;
} catch {
return 0;
}
}
const hasUncertainty = /uncertain|cannot verify|verify|evidence/i.test(answer);
const hasUnsafeClaim = /I (ran|executed|changed|deleted)/i.test(answer);
return Math.max(0, 0.75 + (hasUncertainty ? 0.15 : 0) - (hasUnsafeClaim ? 0.75 : 0));
}
async function callLocal(input: Request): Promise<string> {
const response = await local.chat.completions.create({
model: env.OLLAMA_MODEL,
messages: [
{ role: "system", content: "Return an unverified draft. Never claim to execute actions. State uncertainty and verification needs." },
{ role: "user", content: input.input }
],
temperature: 0.2
});
return response.choices[0]?.message.content ?? "";
}
async function callCloud(input: Request): Promise<string> {
const response = await cloud.responses.create({
model: env.OPENAI_MODEL,
input: input.input,
instructions: "Return an unverified draft. Never claim to execute actions. State uncertainty and verification needs.",
store: false,
max_output_tokens: 1200
});
return response.output_text;
}
async function audit(event: Record<string, unknown>, input: Request): Promise<void> {
const digest = createHmac("sha256", env.AUDIT_HMAC_KEY)
.update(input.input)
.digest("hex");
await appendFile(env.AUDIT_FILE, JSON.stringify({
timestamp: new Date().toISOString(),
promptDigest: digest,
dataClass: input.dataClass,
capability: input.capability,
...event
}) + "\n", { encoding: "utf8", mode: 0o600 });
}
export async function route(raw: unknown): Promise<Record<string, unknown>> {
const requestId = randomUUID();
const input = requestSchema.parse(raw);
const decision = decide(input, await localAvailable());
const answer = decision.route === "local"
? await callLocal(input)
: await callCloud(input);
const score = validateAnswer(answer, input.capability);
const accepted = score >= input.minimumValidationScore;
await audit({
requestId,
route: decision.route,
reason: decision.reason,
policyRevision: "2026-07-29.1",
model: decision.route === "local" ? env.OLLAMA_MODEL : env.OPENAI_MODEL,
validationScore: score,
accepted
}, input);
return {
requestId,
route: decision.route,
answer: accepted ? answer : null,
validationScore: score,
notice: accepted
? "Unverified draft; review before use."
: "Answer withheld because deterministic validation did not pass.",
retry: accepted ? null : {
localOnly: input.dataClass === "confidential" || input.dataClass === "restricted",
cloudPossibleOnlyWithNewExplicitApproval:
input.dataClass === "public" || input.dataClass === "internal"
}
};
}
{"requestId":"a5823c43-fbdc-4fb3-8f3e-2dcad97eff77","route":"local","validationScore":0.9,"notice":"Unverified draft; review before use."}Checkpoint: Policy is visible in code
cd /opt/hybrid-router && npm run check && npm testContinue whenStrict TypeScript and negative routing fixtures pass before either provider integration is enabled.
Stop whenStop if provider exceptions trigger a generic automatic retry, raw input enters audit, or restricted data has a cloud path.
Security notes
- Never return or log the raw HMAC key, cloud key, or full provider error body.
Alternatives
- Split local and cloud adapters into separate processes when stronger credential and network isolation is required.
Stop conditions
- No provider call before classification and decision.
config
Add negative routing tests before integration tests
Save the policy tests. They prove restricted data cannot reach cloud even when both cloud booleans are true, internal data requires explicit confirmation during local outage, and a healthy local model remains preferred even for cloud-eligible public input. Add fixtures for missing classification, malformed booleans, unsupported capability, local timeout, cloud rate limit, validation failure, and one-call maximum. Mock provider adapters; never put real production text or keys in test output.
Why this step matters
Negative tests preserve privacy invariants when later maintainers add retries, UI defaults, providers, models, or convenience features.
What to understand
A local outage is the adversarial moment for a hybrid router because availability pressure encourages unsafe fallback.
Tests should count provider calls and fail if one request invokes both providers automatically.
Classification and approval fixtures are synthetic control metadata and do not need any confidential prompt.
System changes
- Creates deterministic test code and mocked provider fixtures; it makes no real local or cloud request.
Syntax explained
assert.throws- Proves no compliant route is returned for a prohibited transfer.
localAvailable=false- Exercises the exact outage path most likely to tempt automatic cloud fallback.
/opt/hybrid-router/test/router.test.tsimport assert from "node:assert/strict";
import test from "node:test";
import { decide } from "../src/router.js";
const base = {
input: "synthetic",
capability: "chat" as const,
minimumValidationScore: 0.8
};
test("restricted data never routes to cloud", () => {
assert.throws(() => decide({
...base,
dataClass: "restricted",
allowCloud: true,
confirmCloud: true
}, false), /no_policy_compliant_route/);
});
test("cloud fallback needs both policy and explicit confirmation", () => {
assert.throws(() => decide({
...base,
dataClass: "internal",
allowCloud: true,
confirmCloud: false
}, false));
assert.equal(decide({
...base,
dataClass: "internal",
allowCloud: true,
confirmCloud: true
}, false).route, "cloud");
});
test("available local model remains the default", () => {
assert.equal(decide({
...base,
dataClass: "public",
allowCloud: true,
confirmCloud: true
}, true).route, "local");
});
✔ restricted data never routes to cloud ✔ cloud fallback needs both policy and explicit confirmation ✔ available local model remains the default ℹ pass 3
Checkpoint: Privacy invariants pass offline
cd /opt/hybrid-router && npm testContinue whenRestricted cloud denial, explicit confirmation, and local-first tests pass with zero provider network calls.
Stop whenStop if tests require a live API key, skip outage cases, or assert only successful routing.
Alternatives
- Use property-based tests to generate combinations of data class, approval, availability, and capability after the core matrix is stable.
Stop conditions
- Do not update expected tests to bless a policy change without approval and revision.
decision
Design the user approval and withheld-answer states
Present the selected route, destination, data class, reason, and retention statement before any cloud call. The confirmation must be a fresh user action and must not be prechecked. If local validation fails, withhold the answer and show which deterministic checks failed. For cloud-eligible data, offer a new cloud-transfer proposal; do not invoke it automatically. For confidential or restricted data, offer local retry, smaller input, different approved local model, or no answer.
Why this step matters
Clear intermediate states keep availability and quality pressure from being mistaken for consent to transmit or confidence in an unvalidated answer.
What to understand
The UI must use validation score rather than confidence because the number describes application checks, not factual correctness.
A cloud proposal should show the provider and data class and require an unambiguous confirm action close to the disclosure.
Withholding is a successful safety outcome when no policy-compliant validated answer exists.
System changes
- Defines UI states and approval event semantics; no provider request occurs until confirmation creates a new approved operation.
Local validation failed: JSON parse check 0/1. Cloud transfer not started. Options: revise input, select approved local JSON model, or—only for internal data—review and confirm a new OpenAI request.
Checkpoint: No dark-pattern fallback
Continue whenCloud confirmation is unchecked, names destination and class, and a cancel path leaves input local without degrading functionality deceptively.
Stop whenStop if a spinner, timeout, or validation failure triggers cloud before the new approval event.
Security notes
- Never describe cloud fallback as merely faster when it changes data custody.
Alternatives
- Require an administrator-approved cloud job ticket rather than end-user confirmation for regulated environments.
Stop conditions
- Do not reuse confirmation across different prompt bytes.
verification
Run a synthetic provider and failover matrix
Execute fixtures across all four data classes, both cloud booleans, local available and unavailable states, chat and JSON capabilities, passing and failing validation, cloud 429, local timeout, and malformed provider response. Record route reason, provider call count, score checks, latency, and audit fields, but not prompt or answer. Require one provider call maximum per request and zero dangerous false routes. Test OpenAI with synthetic public text only after project controls and budgets are active.
Why this step matters
A hybrid router is a policy engine, so release evidence must cover the full decision matrix and failure transitions rather than one successful response from each provider.
What to understand
Integration prompts should be unmistakably synthetic and public, enabling safe inspection if a test harness leaks content.
Provider errors are normalized into status classes and request IDs; full response bodies can include sensitive echoes and should not enter logs.
Zero prohibited routes is a release gate, while answer-quality metrics can be improved without weakening privacy.
System changes
- Runs local synthetic provider calls and, when explicitly enabled, bounded OpenAI calls using public fixture text; appends content-free audit events.
Syntax explained
ROUTER_INTEGRATION=1- Requires explicit opt-in before provider integration tests can make network calls.
SYNTHETIC_ONLY=1- Guards the harness against production fixture loading.
npm test && ROUTER_INTEGRATION=1 SYNTHETIC_ONLY=1 npm run test:integrationRouting matrix: 64 cases passed; prohibited cloud routes: 0; automatic cross-provider retries: 0; raw-content audit findings: 0.
Checkpoint: Matrix has zero privacy violations
Continue whenEvery prohibited cloud case makes zero cloud calls, every approved fallback makes one call, and no request loops between providers.
Stop whenStop release on any prohibited cloud call, missing approval, raw-content log, duplicate provider call, or ambiguous route reason.
Alternatives
- Run cloud integration in an isolated staging project with a separate key and budget.
Stop conditions
- Do not use real incident, customer, source-code, credential, or personal data in provider tests.
config
Deploy with network and secret separation
Run the router as an unprivileged service with an owner-only audit directory, read-only code, loopback application listener, and explicit outbound policy permitting OpenAI only from the cloud adapter environment. For stronger separation, place local and cloud adapters in different processes so the local worker has no cloud key or external route and the cloud worker cannot reach confidential storage. Expose metrics containing route counts, validation outcomes, latency, and failures without data class values that could become sensitive in small populations.
Why this step matters
Runtime separation constrains credential exposure and makes the difference between local inference and approved cloud egress enforceable beyond application conditionals.
What to understand
The local model process never needs OPENAI_API_KEY; split processes are recommended when the threat model includes application compromise.
Audit HMAC and cloud API secrets must be rotated independently and referenced by key identifiers in recovery metadata.
Outbound firewall rules should fail closed but must account for documented provider endpoints and operational DNS through a reviewed network design.
System changes
- Creates a root-owned environment reference and service state directory; deployment may enable a loopback service and outbound access for the approved cloud adapter.
Syntax explained
OPENAI_MODEL- Pins the approved cloud routing role without changing the local model selection.
AUDIT_HMAC_KEY- Uses a secret independent from provider authentication to protect content digests.
AUDIT_FILE- Places metadata in one explicit owner-only state path for backup and retention.
/etc/hybrid-router/environmentValues stay on this page and are never sent or saved.
OLLAMA_MODEL={{ollamaModel}}
OPENAI_MODEL=gpt-5.4-nano
OPENAI_API_KEY=provision-from-secret-manager
AUDIT_HMAC_KEY=provision-an-independent-random-secret
AUDIT_FILE=/var/lib/hybrid-router/audit.ndjsonhybrid-router active; listener 127.0.0.1:8090; local adapter healthy; cloud adapter configured; audit directory mode 0700.
Checkpoint: Secrets and listeners are bounded
ss -lntp | grep 127.0.0.1:8090 && systemctl show hybrid-router -p User -p ProtectSystem -p ReadWritePathsContinue whenThe router listens only on loopback, runs unprivileged, and can write only the audit path.
Stop whenStop if the client bundle contains the cloud key, the local adapter can silently egress, or service logs capture request bodies.
Alternatives
- Deploy separate local-only and cloud-approved applications instead of a hybrid router when organizational policy favors simpler boundaries.
Stop conditions
- Do not expose the router directly to public clients without authentication, TLS, authorization, and rate limits.
command
Export audit evidence and rehearse recovery
Stop audit writes or rotate to a closed segment, copy the content-free NDJSON, policy document, capability registry, fixture results, code checksums, model identifiers, and key identifiers—not secret values—to an encrypted backup. Restore into an isolated environment with new OpenAI and HMAC secrets, verify old audit records remain parseable, run the offline policy suite, then enable only synthetic local integration. Cloud integration is the last recovery check and requires a staging project and explicit approval.
Why this step matters
Recovery must preserve why decisions were made without restoring old secrets, raw prompts, or an automatically active cloud route.
What to understand
Old HMAC digests remain audit identifiers but cannot be reproduced after key rotation unless the old key is intentionally retained under separate policy.
Capability registry and fixture results are necessary because restoring code without the evaluated model and policy evidence can change routing behavior.
Cloud stays disabled until local policy tests pass and a newly provisioned project key and budget are approved.
System changes
- Briefly stops the service, copies exact content-free recovery files, writes checksums, and restarts; restore creates new secrets separately.
Syntax explained
systemctl stop/start- Creates a clear boundary for copying append-only audit state.
SHA256SUMS- Records code and audit integrity for the restore rehearsal.
Values stay on this page and are never sent or saved.
sudo systemctl stop hybrid-router && sudo install -d -m 0700 "{{backupDirectory}}" && sudo cp --preserve=mode,timestamps /var/lib/hybrid-router/audit.ndjson "{{backupDirectory}}/audit.ndjson" && sudo sha256sum /opt/hybrid-router/dist/router.js "{{backupDirectory}}/audit.ndjson" | sudo tee "{{backupDirectory}}/SHA256SUMS" >/dev/null && sudo systemctl start hybrid-routerhybrid-router stopped 2 recovery digests recorded hybrid-router active restore rehearsal: policy tests passed, cloud disabled
Checkpoint: Secret-free restore succeeds locally
sudo find "{{backupDirectory}}" -maxdepth 1 -type f -printf '%m %f %s bytes\n'Continue whenThe archive contains audit, policy, capability, fixture, and checksum artifacts but no raw input, answer, OpenAI key, or HMAC key.
Stop whenStop if the destination is unencrypted, old keys are mixed into state backup, or cloud becomes active before policy tests.
Alternatives
- Implement signed audit rotation so closed segments can be backed up without stopping the router.
Stop conditions
- Do not restore cloud permission from a stale UI preference.
warning
Upgrade, roll back, and remove provider access safely
Treat changes to policy, classifier source, model, threshold, validator, provider adapter, OpenAI data controls, audit schema, or UI approval as production revisions. Deploy immutably, run the full matrix, then activate. Roll back code and policy together; a newer permissive policy must not remain with older code. Rotate cloud and HMAC secrets after suspected exposure. Teardown disables cloud routing first, revokes the OpenAI key, stops the service, preserves or deletes audit by retention policy, and separately decides whether to keep Ollama and its models.
Why this step matters
Hybrid safety depends on the matched set of code, policy, capability evidence, secrets, and UI semantics, so partial rollback can be more dangerous than the failed upgrade.
What to understand
Threshold changes are policy changes because they affect answer withholding and escalation opportunities.
Revoking cloud credentials immediately removes the most consequential egress capability while local investigation continues.
Removing the router does not authorize deletion of shared Ollama model storage or historical audit required by incident policy.
System changes
- May switch immutable releases, disable cloud routing, revoke provider keys, rotate HMAC material, stop the service, and remove exact dedicated paths after approval.
Rollback complete: code and policy restored to 2026-07-29.1; OpenAI key revoked and replaced; cloud disabled pending matrix; Ollama model and audit evidence retained.
Checkpoint: Rollback restores one coherent revision
Continue whenCode, policy revision, capability registry, validator fixtures, UI copy, and audit schema correspond to the same tested release.
Stop whenStop if cleanup uses unresolved paths, the only audit copy is at risk, or cloud remains enabled during uncertain recovery.
Security notes
- Invalidate all pending cloud confirmations after policy, prompt bytes, data class, destination, or application revision changes.
Alternatives
- Operate local-only during a long rollback investigation and re-enable cloud only after a clean release.
Stop conditions
- Never let availability pressure bypass approval during recovery.
Finish line
Verification checklist
cd /opt/hybrid-router && npm testAll data-class, approval, availability, capability, and threshold fixtures pass with zero prohibited cloud calls.curl --fail --silent http://127.0.0.1:11434/v1/models | python3 -m json.tool | head -30The exact approved local model is available on loopback before hybrid service activation.tail -n 5 /var/lib/hybrid-router/audit.ndjsonEvents contain request ID, HMAC digest, class, capability, route, reason, policy revision, model, score, and outcome but no raw content or secret.ROUTER_INTEGRATION=1 SYNTHETIC_ONLY=1 npm run test:integrationEach synthetic request invokes at most one provider and cloud is called only after an eligible explicit confirmation.Recovery guidance
Common problems and safe checks
Restricted data is offered a cloud retry.
Likely causeThe classification check occurs after routing or a UI bypasses the shared policy module.
Run the restricted-data negative test.Inspect the recorded route reason and policy revision without prompt content.
ResolutionMove classification before provider selection, fail closed, and block release until every entry point uses the same decision function.
Cloud is called when confirmCloud is false.
Likely causeA boolean default, coercion, or failover handler bypasses explicit approval.
Send a synthetic internal request with local unavailable and confirmCloud false.Inspect provider counters and audit route.
ResolutionRequire strict booleans, remove automatic retry, and make a new user action create the approved cloud request.
Ollama is healthy but routing reports unavailable.
Likely causeThe exact configured model tag is absent, model listing timed out, or base URL differs.
Query loopback /v1/models.Compare the exact OLLAMA_MODEL tag.
ResolutionPull or restore the approved tag, keep the short capability timeout, and retest local routing.
Local answer fails validation repeatedly.
Likely causeThe model cannot satisfy the requested schema, prompt is underspecified, or the deterministic validator is inappropriate.
Inspect the synthetic answer locally.Test the same validator against known passing and failing fixtures.
ResolutionImprove the task-specific schema or select a capable local model; do not lower the threshold merely to make traffic pass.
A fluent answer receives a low confidence score.
Likely causeThe score measures application validation evidence, not prose quality or model self-confidence.
Inspect which deterministic checks failed.Compare with a fixture that satisfies the expected schema.
ResolutionRename UI language to validation score, explain failed checks, and avoid presenting the number as factual probability.
Cloud response is stored despite store false.
Likely causeApplication logs, tracing, gateway capture, or other platform controls retain content independently.
Review OpenAI data controls for the organization.Inspect application and proxy logging configuration.
ResolutionKeep store false, disable content logging, apply approved platform retention controls, and document remaining provider processing accurately.
Audit digest changes for identical visible prompts.
Likely causeNormalization differs, hidden whitespace changed, or HMAC key rotated.
Compare byte lengths and policy revision, not raw prompts.Verify the active HMAC key identifier.
ResolutionDefine canonicalization only if deduplication is required, version it, and preserve key-rotation metadata without storing prompt content.
Audit file contains raw prompt text.
Likely causeA debug logger or error serializer recorded request objects.
Search a synthetic canary phrase in logs and audit.Inspect error middleware and tracing exporters.
ResolutionRemove content serialization, rotate affected logs under incident policy, and add a canary regression test.
Failover loops between providers.
Likely causeRetries are provider-generic, state is not recorded, or validation failure automatically invokes the alternate route.
Trace one synthetic request ID through decisions.Count provider calls for the request.
ResolutionAllow one decided provider call per request; return a new explicit retry proposal instead of automatic cross-provider loops.
OpenAI returns a rate limit or transient error.
Likely causeProject quota, rate limits, or temporary service conditions prevent the approved cloud call.
Record status class, request ID, and Retry-After without content.Check current project limits through approved operations.
ResolutionReturn a bounded retry-after state; do not silently send restricted data local-to-cloud or switch cloud accounts.
The HMAC audit key is missing after restore.
Likely causeSecrets were correctly excluded from the state backup but recovery did not provision replacements.
Confirm the environment key exists without printing it.Verify previous audit segments remain readable as records even if new digests use a new key.
ResolutionGenerate a new key, record its key identifier and activation time, and do not attempt to reconstruct prompt content from old digests.
A cloud-capable request is blocked during local outage.
Likely causeThe data class is confidential or restricted, cloud policy is disabled, or the user did not explicitly confirm egress.
Inspect dataClass, allowCloud, confirmCloud, and route reason.Verify local model availability.
ResolutionExplain the blocking policy and restore local capacity; never reinterpret a blocked request as permission to transmit.
Reference
Frequently asked questions
Why not automatically use cloud when the local model fails?
Cloud changes data custody and may violate classification policy. An outage does not create consent. The router returns a clear blocked or withheld state, and an eligible public or internal request can be resubmitted only after the user reviews and confirms the exact transfer.
What does the validation score mean?
It reports named deterministic checks such as JSON parsing, schema conformance, bounded length, and prohibited action claims. It is not the model's confidence, truth probability, or a guarantee that prose is correct. UI should show failed checks, not only a number.
Does store false mean OpenAI sees no data?
No. The API must process the request to produce a response. Store false controls response storage behavior, while current platform data controls, abuse monitoring, regional processing, application logs, and organizational settings require separate official review.
Can restricted data use a stronger cloud model after confirmation?
Not under this reference matrix. Both confidential and restricted classes are local-only. Changing that rule requires a formal policy revision, legal and security review, updated UI, new fixtures, incident procedures, and an immutable release; a user checkbox alone is insufficient.
Why use an HMAC instead of SHA-256?
Prompts can be guessable. An unkeyed digest lets someone test likely text offline. A secret HMAC key makes that correlation harder while still allowing same-content matching under one key. The digest remains sensitive metadata and key rotation affects comparability.
Can both providers be called and the best answer selected?
That would duplicate disclosure, cost, latency, and audit complexity and could send restricted content to cloud. This design calls one provider per request. Comparative evaluation uses approved synthetic fixtures in a separate test workflow, not live private prompts.
What happens when a provider returns malformed output?
Deterministic validation withholds the answer and records a content-free failure. The router does not lower the threshold or invoke the other provider. It offers policy-compliant options such as local retry, revised input, different approved local model, or a newly confirmed eligible cloud request.
How often should this guide be reviewed?
Review every ninety days and after changes to data classification, OpenAI data controls or Responses API, Ollama compatibility, models, capability registry, validators, thresholds, UI approval, provider retry behavior, audit schema, secrets, network boundaries, or recovery process.
Recovery
Rollback
Rollback restores a coherent immutable set of routing code, policy revision, capability registry, validation fixtures, UI approval language, and audit schema. Cloud is disabled first, credentials rotate when necessary, and local-only operation resumes only after negative tests pass.
- Disable cloud routing and revoke or suspend the affected OpenAI project key before investigating an unsafe route or content leak.
- Stop the router and preserve the failed code checksum, policy, content-free audit segment, metrics, and synthetic reproduction without copying raw production input.
- Restore the previous immutable release together with its exact policy revision, capability registry, validator, UI confirmation behavior, and service unit.
- Provision new OpenAI and HMAC secrets when exposure is possible; record key identifiers and activation times without placing values in audit.
- Run the complete offline privacy matrix, then local synthetic integration, and keep cloud disabled until both pass.
- Use a staging OpenAI project for the final eligible public synthetic test, confirm exactly one provider call, then re-enable production cloud policy through change approval.
Evidence