Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions apps/loopover-ui/src/lib/selfhost-env-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
},
{
name: "AI_EMBED_BASE_URL",
firstReference: "src/server.ts",
firstReference: "src/selfhost/ai.ts",
},
{
name: "AI_EMBED_MODEL",
Expand Down Expand Up @@ -285,6 +285,10 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
name: "LOOPOVER_REVIEW_CONTINUOUS",
firstReference: "src/queue/processors.ts",
},
{
name: "LOOPOVER_REVIEW_RAG",
firstReference: "src/selfhost/ai.ts",
},
{
name: "LOOPOVER_VERSION",
firstReference: "src/selfhost/otel.ts",
Expand Down Expand Up @@ -636,7 +640,7 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
"| `AI_DAILY_NEURON_BUDGET` | `src/services/ai-review.ts` |",
"| `AI_DUAL_REVIEW` | `src/selfhost/ai.ts` |",
"| `AI_EMBED_API_KEY` | `src/server.ts` |",
"| `AI_EMBED_BASE_URL` | `src/server.ts` |",
"| `AI_EMBED_BASE_URL` | `src/selfhost/ai.ts` |",
"| `AI_EMBED_MODEL` | `src/selfhost/ai.ts` |",
"| `AI_GATEWAY_ID` | `src/services/ai-review.ts` |",
"| `AI_MAX_OUTPUT_TOKENS` | `src/services/ai-review.ts` |",
Expand Down Expand Up @@ -696,6 +700,7 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
"| `LOOPOVER_MCP_TOKEN` | `src/selfhost/preflight.ts` |",
"| `LOOPOVER_REPO_CONFIG_DIR` | `src/server.ts` |",
"| `LOOPOVER_REVIEW_CONTINUOUS` | `src/queue/processors.ts` |",
"| `LOOPOVER_REVIEW_RAG` | `src/selfhost/ai.ts` |",
"| `LOOPOVER_VERSION` | `src/selfhost/otel.ts` |",
"| `MAINTENANCE_ADMISSION_DEFER_MS` | `src/selfhost/maintenance-admission.ts` |",
"| `MAINTENANCE_ADMISSION_DRAIN_AGE_MS` | `src/selfhost/maintenance-admission.ts` |",
Expand Down
5 changes: 5 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,11 @@ services:
# Uncomment for Ollama AI (--profile ollama):
# AI_PROVIDER: ollama
# OLLAMA_AI_BASE_URL: http://ollama:11434/v1
# RAG needs an EMBED-capable endpoint (#8765): with LOOPOVER_REVIEW_RAG=true and a CLI-only review
# chain (AI_PROVIDER=claude-code / codex), the two lines below are REQUIRED or the index never
# populates — CLI-subscription providers cannot embed. See .env.example's AI_EMBED section.
# AI_EMBED_BASE_URL: http://ollama:11434/v1
# AI_EMBED_MODEL: bge-m3
# BROKERED mode — use the central LoopOver Orb App instead of creating your own GitHub App. Install the
# Orb App on your repos + set ORB_ENROLLMENT_SECRET in .env (loaded above); the engine then brokers
# short-lived GitHub tokens from the Orb on demand (no own App private key). See .env.example.
Expand Down
16 changes: 16 additions & 0 deletions src/selfhost/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1469,6 +1469,22 @@ export function resolveProviderNames(env: Record<string, string | undefined>): s
return resolveConfiguredProviderNames(env);
}

/**
* #8765: true when RAG is enabled but NO configured provider can serve an embed request — AI_EMBED_BASE_URL
* is unset AND every AI_PROVIDER member is a CLI-subscription provider (claude-code/codex), which throw
* `*_no_embed` on every embed call by design. In that state the index never populates: every upsert embeds
* 0/N chunks forever, surfaced only as per-batch runtime ERROR logs. The review-CLI path already gets a
* fail-LOUD boot preflight (#1566); this is the same check for RAG's embed dependency. PURE — server.ts
* owns the actual boot-time shout.
*/
export function shouldWarnRagEmbedUnavailable(env: Record<string, string | undefined>): boolean {
if (!/^(1|true|yes|on)$/i.test((env.LOOPOVER_REVIEW_RAG ?? "").trim())) return false;
if ((env.AI_EMBED_BASE_URL ?? "").trim() !== "") return false;
const providers = resolveProviderNames(env);
if (providers.length === 0) return false; // no AI at all — RAG degrades for a different, already-loud reason
return providers.every((provider) => provider === "claude-code" || provider === "codex");
}

/** CLI-subscription providers need their binary present on PATH; keep boot preflight parsing identical to AI_PROVIDER. */
export function resolveRequiredCliProviders(env: Record<string, string | undefined>): Array<{ provider: string; cli: string }> {
const seen = new Set<string>();
Expand Down
17 changes: 17 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
resolveRequiredCliProviders,
resolveSubscriptionCliPath,
shouldMarkAiProviderUnhealthyAtBoot,
shouldWarnRagEmbedUnavailable,
subscriptionCliEnv,
withAiGenerationCapture,
} from "./selfhost/ai";
Expand Down Expand Up @@ -568,6 +569,22 @@ async function main(): Promise<void> {
if (shouldMarkAiProviderUnhealthyAtBoot(resolveProviderNames(process.env), [...missingCliProviders])) {
markAiProviderUnhealthyAtBoot();
}
// Fail-LOUD preflight for RAG's embed dependency (#8765, mirrors #1566): with LOOPOVER_REVIEW_RAG=true, no
// AI_EMBED_BASE_URL, and a provider chain of only CLI-subscription providers, every embed call throws
// *_no_embed by design — the index never populates and every review pays a cold-index no-op, surfaced only
// as per-batch runtime errors. Shout once at boot instead. Warn-only: RAG's runtime degrade stays exactly
// as it was (this misconfig must not stop the server).
if (shouldWarnRagEmbedUnavailable(process.env)) {
console.error(
JSON.stringify({
level: "error",
event: "selfhost_rag_embed_unavailable",
provider: process.env.AI_PROVIDER,
message:
"LOOPOVER_REVIEW_RAG is enabled but no configured provider can serve embeddings — AI_EMBED_BASE_URL is unset and every AI_PROVIDER member is a CLI-subscription provider (they never embed). The RAG index will stay empty. Set AI_EMBED_BASE_URL (e.g. http://ollama:11434/v1 with AI_EMBED_MODEL=bge-m3) or add an OpenAI-compatible provider to the chain.",
}),
);
}
// Dedicated RAG embed provider (keeps the review chain frontier-only): when AI_EMBED_BASE_URL is set, embeddings
// route to a SEPARATE openai-compatible endpoint (e.g. ollama at http://ollama:11434/v1, model bge-m3) instead of
// the review chain — so a Claude/Codex outage never falls reviews back to a weak local model. Unset ⇒ absent ⇒
Expand Down
29 changes: 28 additions & 1 deletion test/unit/selfhost-ai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const posthogMocks = vi.hoisted(() => {
});
vi.mock("posthog-node", () => ({ PostHog: posthogMocks.PostHog }));

import { assertNoLegacySharedAiEnv, buildProvider, claudeErrorStatus, codexErrorFromStdout, createAnthropicAi, createChainAi, createClaudeCodeAi, createCodexAi, createOpenAiCompatibleAi, createSelfHostAi, extractCliText, extractCliUsage, isAiProviderHealthy, markAiProviderUnhealthyAtBoot, providerNameFromBaseUrl, resetAiProviderCircuitBreakerForTest, resetAiProviderHealthForTest, resolveAiReviewerPlan, resolveClaudeCliTimeoutMs, resolveClaudeFirstOutputTimeoutMs, resolveCodexAuthPath, resolveCodexCliTimeoutMs, resolveCodexEffort, resolveCodexFirstOutputTimeoutMs, resolveEffort, resolveModel, resolveProviderNames, resolveRequiredCliProviders, resolveSubscriptionCliPath, redactSecrets, routeProviders, shouldMarkAiProviderUnhealthyAtBoot, subscriptionCliEnv, withAdvisoryAiEnv, withAiGenerationCapture, __selfHostAiInternals } from "../../src/selfhost/ai";
import { assertNoLegacySharedAiEnv, buildProvider, claudeErrorStatus, codexErrorFromStdout, createAnthropicAi, createChainAi, createClaudeCodeAi, createCodexAi, createOpenAiCompatibleAi, createSelfHostAi, extractCliText, extractCliUsage, isAiProviderHealthy, markAiProviderUnhealthyAtBoot, providerNameFromBaseUrl, resetAiProviderCircuitBreakerForTest, resetAiProviderHealthForTest, resolveAiReviewerPlan, resolveClaudeCliTimeoutMs, resolveClaudeFirstOutputTimeoutMs, resolveCodexAuthPath, resolveCodexCliTimeoutMs, resolveCodexEffort, resolveCodexFirstOutputTimeoutMs, resolveEffort, resolveModel, resolveProviderNames, resolveRequiredCliProviders, resolveSubscriptionCliPath, redactSecrets, routeProviders, shouldMarkAiProviderUnhealthyAtBoot, shouldWarnRagEmbedUnavailable, subscriptionCliEnv, withAdvisoryAiEnv, withAiGenerationCapture, __selfHostAiInternals } from "../../src/selfhost/ai";
import { labelSelfHostReviewerModel, labelSelfHostReviewerModels } from "../../src/selfhost/ai-config";
import { renderMetrics, resetMetrics } from "../../src/selfhost/metrics";
import { initPostHog, resetPostHogForTest } from "../../src/selfhost/posthog";
Expand Down Expand Up @@ -2378,3 +2378,30 @@ describe("withAdvisoryAiEnv (#4364 — per-capability local-inference routing)",
expect(result.AI).toBe(frontierAi);
});
});

describe("shouldWarnRagEmbedUnavailable (#8765 boot preflight)", () => {
it("warns for RAG-on + CLI-only chain + no dedicated embed endpoint — the index-never-populates misconfig", () => {
expect(shouldWarnRagEmbedUnavailable({ LOOPOVER_REVIEW_RAG: "true", AI_PROVIDER: "claude-code" })).toBe(true);
expect(shouldWarnRagEmbedUnavailable({ LOOPOVER_REVIEW_RAG: "1", AI_PROVIDER: "claude-code,codex" })).toBe(true);
});

it("stays silent when RAG is off (default), regardless of the chain", () => {
expect(shouldWarnRagEmbedUnavailable({ AI_PROVIDER: "claude-code" })).toBe(false);
expect(shouldWarnRagEmbedUnavailable({ LOOPOVER_REVIEW_RAG: "false", AI_PROVIDER: "codex" })).toBe(false);
});

it("stays silent when AI_EMBED_BASE_URL provides the dedicated embed endpoint", () => {
expect(
shouldWarnRagEmbedUnavailable({ LOOPOVER_REVIEW_RAG: "true", AI_PROVIDER: "claude-code", AI_EMBED_BASE_URL: "http://ollama:11434/v1" }),
).toBe(false);
});

it("stays silent when the chain contains ANY embed-capable (non-CLI) provider — the chain falls through *_no_embed to it", () => {
expect(shouldWarnRagEmbedUnavailable({ LOOPOVER_REVIEW_RAG: "true", AI_PROVIDER: "claude-code,ollama" })).toBe(false);
expect(shouldWarnRagEmbedUnavailable({ LOOPOVER_REVIEW_RAG: "true", AI_PROVIDER: "ollama" })).toBe(false);
});

it("stays silent with no AI configured at all — that absence is already loud for its own reason", () => {
expect(shouldWarnRagEmbedUnavailable({ LOOPOVER_REVIEW_RAG: "true" })).toBe(false);
});
});
Loading