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
19 changes: 10 additions & 9 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -450,13 +450,14 @@ REDIS_URL=redis://redis:6379 # REQUIRED for the self-host review
# Anthropic settings cannot be mixed up.
#
# AI_PROVIDER=ollama # ollama | openai-compatible | openai | anthropic | claude-code |
# # codex. A COMMA-LIST of TWO providers is a DUAL reviewer pair
# # (e.g. "claude-code,codex") combined per AI_COMBINE below; for a
# # single provider it's just that one (extra entries are fallbacks).
# AI_COMBINE=synthesis # how two reviewers decide (#dual-ai-combiner): single | consensus |
# # synthesis. consensus = block only when BOTH flag a defect (lone
# # flag → hold). synthesis (default for two) = both review, then ONE
# # merged decision. single = one reviewer's verdict (auto when 1).
# # codex. A comma-list is a FALLBACK chain by default:
# # AI_PROVIDER=codex,claude-code runs Codex first and only spends
# # Claude Code tokens if Codex fails/exhausts.
# AI_DUAL_REVIEW=0 # opt-in only: set to 1/true/yes/on when the first two providers
# # should run as independent reviewers instead of fallback.
# AI_COMBINE=synthesis # dual-review mode only: single | consensus | synthesis.
# # consensus = block only when BOTH flag a defect (lone flag → hold).
# # synthesis = both review, then ONE merged decision.
# AI_ON_MERGE=either # synthesis merge rule: either (block if EITHER reviewer flags) |
# # both (block only when both do). Ignored unless AI_COMBINE=synthesis.
# AI_DAILY_NEURON_BUDGET=10000000 # daily spend cap (Cloudflare Workers AI "neurons") shared by AI
Expand Down Expand Up @@ -492,7 +493,7 @@ REDIS_URL=redis://redis:6379 # REQUIRED for the self-host review
# Claude Code subscription reviewer (AI_PROVIDER=claude-code).
# CLAUDE_CODE_OAUTH_TOKEN= # from `claude setup-token`
# CLAUDE_AI_MODEL=claude-sonnet-4-6 # any `claude` CLI model id/alias, e.g. sonnet | opus | claude-opus-4-8
# CLAUDE_AI_EFFORT=high # low | medium | high | xhigh | max
# CLAUDE_AI_EFFORT=medium # low | medium | high | xhigh | max
# CLAUDE_AI_TIMEOUT_MS= # override CLI timeout in ms; unset scales by effort (low/medium 120s, high 240s, xhigh 360s, max 600s)
#
# Codex (ChatGPT subscription) reviewer is fail-closed by default for self-host PR review: `codex exec` stores its
Expand All @@ -501,7 +502,7 @@ REDIS_URL=redis://redis:6379 # REQUIRED for the self-host review
# GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER=1
# Do NOT set CODEX_HOME for the app container; the provider rejects it so credentials are not advertised in env.
# CODEX_AI_MODEL=gpt-5.5 # omit to let the Codex account default choose; set explicitly for repeatable reviews
# CODEX_AI_EFFORT=high # low | medium | high | xhigh. `max` is accepted and maps to xhigh.
# CODEX_AI_EFFORT=medium # low | medium | high | xhigh. `max` is accepted and maps to xhigh.
# CODEX_AI_TIMEOUT_MS= # override CLI timeout in ms; unset scales by effort (low/medium 120s, high 240s, xhigh 360s)
# # Codex service speed is standard by default. No fast/priority tier is requested by this stack.
# AI_EMBED_MODEL=nomic-embed-text:latest # embedding model for RAG (openai-compatible /embeddings). Its output
Expand Down
13 changes: 9 additions & 4 deletions apps/gittensory-ui/src/lib/selfhost-env-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ export type SelfHostEnvReferenceRow = {
export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
{
name: "AI_COMBINE",
firstReference: "src/selfhost/ai.ts:982",
firstReference: "src/selfhost/ai.ts:1000",
},
{
name: "AI_DUAL_REVIEW",
firstReference: "src/selfhost/ai.ts:975",
},
{
name: "AI_EMBED_API_KEY",
Expand All @@ -23,7 +27,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
},
{
name: "AI_ON_MERGE",
firstReference: "src/selfhost/ai.ts:984",
firstReference: "src/selfhost/ai.ts:1002",
},
{
name: "AI_PROVIDER",
Expand Down Expand Up @@ -386,11 +390,12 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
"| Name | First reference |",
"| --- | --- |",
"| `AI_COMBINE` | `src/selfhost/ai.ts:982` |",
"| `AI_COMBINE` | `src/selfhost/ai.ts:1000` |",
"| `AI_DUAL_REVIEW` | `src/selfhost/ai.ts:975` |",
"| `AI_EMBED_API_KEY` | `src/server.ts:440` |",
"| `AI_EMBED_BASE_URL` | `src/server.ts:437` |",
"| `AI_EMBED_MODEL` | `src/selfhost/ai.ts:872` |",
"| `AI_ON_MERGE` | `src/selfhost/ai.ts:984` |",
"| `AI_ON_MERGE` | `src/selfhost/ai.ts:1002` |",
"| `AI_PROVIDER` | `src/selfhost/ai-config.ts:43` |",
"| `ANTHROPIC_AI_BASE_URL` | `src/selfhost/ai.ts:876` |",
"| `ANTHROPIC_AI_MODEL` | `src/selfhost/ai.ts:85` |",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,26 @@ OPENAI_COMPATIBLE_AI_MODEL=llama3.1`}

<h2>Fallback and dual review</h2>
<p>
A comma-list can be a fallback chain or a two-reviewer plan. With two available providers,
<code>AI_COMBINE</code> controls how decisions are combined.
A comma-list is a fallback chain by default. Use this for subscription CLIs when you want
Codex first and Claude Code only when Codex is unavailable or out of tokens.
</p>
<CodeBlock
filename=".env"
filename=".env — fallback chain"
code={`AI_PROVIDER=codex,claude-code
CODEX_AI_EFFORT=medium
CLAUDE_AI_EFFORT=medium
CLAUDE_CODE_OAUTH_TOKEN=
GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER=1`}
/>
<p>
Set <code>AI_DUAL_REVIEW=1</code> only when you want the first two providers to run as
independent reviewers on every PR. In dual-review mode, <code>AI_COMBINE</code> controls how
decisions are combined.
</p>
<CodeBlock
filename=".env — dual review"
code={`AI_PROVIDER=anthropic,ollama
AI_DUAL_REVIEW=1
AI_COMBINE=synthesis
AI_ON_MERGE=either`}
/>
Expand All @@ -95,7 +109,7 @@ AI_ON_MERGE=either`}
{
title: "single",
description:
"One reviewer verdict. This is the automatic mode when one provider is configured.",
"One reviewer verdict. This is the automatic mode for one provider or a fallback chain.",
},
{
title: "consensus",
Expand Down
20 changes: 13 additions & 7 deletions apps/gittensory-ui/src/routes/docs.self-hosting-quickstart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,28 +64,34 @@ function SelfHostingQuickstart() {

<h2>2. Choose your AI provider (optional)</h2>
<p>
Skip this step for a fully deterministic review (no AI). Otherwise uncomment ONE of the
three blocks below in <code>.env.selfhost.example</code> — they're mutually exclusive, each
sets its own <code>AI_PROVIDER</code>. The self-host image bundles both CLIs by default;
credentials and provider choice are runtime-only.
Skip this step for a fully deterministic review (no AI). Otherwise set{" "}
<code>AI_PROVIDER</code> to one provider or a fallback chain. The self-host image bundles
both CLIs by default; credentials and provider choice are runtime-only.
</p>
<CodeBlock
filename=".env — Claude Code only"
code={`AI_PROVIDER=claude-code
CLAUDE_AI_EFFORT=medium
CLAUDE_CODE_OAUTH_TOKEN= # from \`claude setup-token\``}
/>
<CodeBlock
filename=".env — Codex only"
code={`AI_PROVIDER=codex
CODEX_AI_EFFORT=medium
GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER=1 # required opt-in; see Callout below`}
/>
<CodeBlock
filename=".env — both, synthesized into one decision"
code={`AI_PROVIDER=claude-code,codex
AI_COMBINE=synthesis
filename=".env — Codex primary, Claude Code fallback"
code={`AI_PROVIDER=codex,claude-code
CODEX_AI_EFFORT=medium
CLAUDE_AI_EFFORT=medium
CLAUDE_CODE_OAUTH_TOKEN=
GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER=1`}
/>
<p>
Set <code>AI_DUAL_REVIEW=1</code> only when you deliberately want the first two providers to
run as independent reviewers instead of a fallback chain.
</p>
<Callout variant="warn" title="Codex is fail-closed by default">
Codex stores its OAuth credential in <code>auth.json</code> on the same filesystem that
prompt-influenced reviews can read, so it requires explicit opt-in (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,10 @@ SELFHOST_SMOKE_EXPECT_EVENTS="selfhost_ai_provider" \\
SELFHOST_SMOKE_FORBID_EVENTS="selfhost_ai_cli_missing" \\
./scripts/smoke-selfhost.sh gittensory:rc-candidate

# Both, synthesized
SELFHOST_SMOKE_EXTRA_ENV="AI_PROVIDER=claude-code,codex
AI_COMBINE=synthesis
# Codex primary, Claude Code fallback
SELFHOST_SMOKE_EXTRA_ENV="AI_PROVIDER=codex,claude-code
CODEX_AI_EFFORT=medium
CLAUDE_AI_EFFORT=medium
CLAUDE_CODE_OAUTH_TOKEN=\${TEST_CLAUDE_TOKEN}
GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER=1" \\
SELFHOST_SMOKE_EXPECT_EVENTS="selfhost_ai_provider" \\
Expand Down
10 changes: 9 additions & 1 deletion grafana/dashboards/gittensory.json
Original file line number Diff line number Diff line change
Expand Up @@ -1260,7 +1260,7 @@
},
{
"type": "timeseries",
"title": "AI requests by model + effort (last 1h)",
"title": "AI requests + fallbacks (last 1h)",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
Expand All @@ -1280,6 +1280,14 @@
},
"expr": "sum by (model, effort) (increase(gittensory_ai_requests_total[1h]))",
"legendFormat": "{{model}} \u00b7 {{effort}}"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "sum by (primary, fallback) (increase(gittensory_ai_review_model_fallback_total[1h]))",
"legendFormat": "fallback {{primary}}\u2192{{fallback}}"
}
],
"fieldConfig": {
Expand Down
15 changes: 9 additions & 6 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,15 @@ declare global {
/** Optional Cloudflare AI Gateway id for legacy env.AI-compatible adapters. Self-host review execution should
* prefer provider-specific AI_* configuration instead. */
AI_GATEWAY_ID?: string;
/** Self-host AI provider selection + dual-review config (#dual-ai-combiner). `AI_PROVIDER` is a comma list of
* providers (claude-code, codex, anthropic, ollama, …); `AI_COMBINE` picks single|consensus|synthesis (default
* synthesis for two); `AI_ON_MERGE` is the synthesis rule either|both. Provider-specific model/effort/timeout
* vars keep Claude/Codex/OpenAI/Ollama/Anthropic config explicit. `AI_REVIEW_PLAN` is the resolved plan
* (computed from these at boot in server.ts and read at the review call site); undefined on cloud. */
/** Self-host AI provider selection + reviewer config (#dual-ai-combiner). `AI_PROVIDER` is a comma list of
* providers (claude-code, codex, anthropic, ollama, ...). By default, the first provider is the reviewer and
* the first distinct later provider is its fallback; `AI_DUAL_REVIEW=1` makes the first two providers run as
* independent reviewers. In dual mode, `AI_COMBINE` picks single|consensus|synthesis and `AI_ON_MERGE` is the
* synthesis rule either|both. Provider-specific model/effort/timeout vars keep Claude/Codex/OpenAI/Ollama/
* Anthropic config explicit. `AI_REVIEW_PLAN` is the resolved plan (computed from these at boot in server.ts
* and read at the review call site); undefined on cloud. */
AI_PROVIDER?: string;
AI_DUAL_REVIEW?: string;
AI_COMBINE?: string;
AI_ON_MERGE?: string;
CLAUDE_AI_MODEL?: string;
Expand All @@ -80,7 +83,7 @@ declare global {
ANTHROPIC_AI_BASE_URL?: string;
ANTHROPIC_AI_MODEL?: string;
AI_REVIEW_PLAN?: {
reviewers: Array<{ model: string }>;
reviewers: Array<{ model: string; fallback?: string | null | undefined }>;
combine: import("./services/ai-review").CombineStrategy;
onMerge?: import("./services/ai-review").OnMerge | undefined;
};
Expand Down
11 changes: 7 additions & 4 deletions src/review/ai-review-cache-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,12 @@ export type AiReviewCacheInput = {
reviewerPlan:
| {
combine?: string | null | undefined;
reviewers?: readonly { model?: string | null | undefined }[] | undefined;
reviewers?: readonly { model?: string | null | undefined; fallback?: string | null | undefined }[] | undefined;
}
| null
| undefined;
// reviewerPlan only names WHICH self-host provider(s) are active (e.g. "claude-code") -- it does not carry that
// provider's own model/effort/timeout/base-url, which are resolved separately at review-call time (see
// reviewerPlan only names WHICH self-host provider(s) are active (e.g. "codex" with fallback "claude-code") --
// it does not carry that provider's own model/effort/timeout/base-url, which are resolved separately at review-call time (see
// src/selfhost/ai.ts's buildProvider). Fingerprint those too so switching a provider's underlying model or
// endpoint (while the provider name/plan stays the same) forces a cache miss instead of reusing a review
// produced against a different configuration. Deliberately excludes API keys (secrets, and irrelevant to output).
Expand Down Expand Up @@ -119,7 +119,10 @@ export async function aiReviewCacheInputFingerprint(input: AiReviewCacheInput):
reviewerPlan: input.reviewerPlan
? {
combine: input.reviewerPlan.combine ?? null,
reviewers: (input.reviewerPlan.reviewers ?? []).map((reviewer) => reviewer.model ?? null),
reviewers: (input.reviewerPlan.reviewers ?? []).map((reviewer) => ({
model: reviewer.model ?? null,
fallback: reviewer.fallback ?? null,
})),
}
: null,
selfHostProviderConfig: input.selfHostProviderConfig
Expand Down
10 changes: 8 additions & 2 deletions src/selfhost/ai-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,17 @@ export function labelSelfHostReviewerModel(
}

export function labelSelfHostReviewerModels(
reviewers: ReadonlyArray<{ model: string }>,
reviewers: ReadonlyArray<{ model: string; fallback?: string | null | undefined }>,
env: Record<string, string | undefined>,
): string {
return reviewers
.map((reviewer) => labelSelfHostReviewerModel(reviewer.model, env))
.map((reviewer) => {
const primary = labelSelfHostReviewerModel(reviewer.model, env);
const fallback = reviewer.fallback?.trim()
? labelSelfHostReviewerModel(reviewer.fallback, env)
: "";
return fallback ? `${primary}->${fallback}` : primary;
})
.join("+");
}

Expand Down
44 changes: 31 additions & 13 deletions src/selfhost/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,12 @@ function defaultOpenAiCompatibleModel(name: string): string {

const VALID_CLAUDE_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
const VALID_CODEX_EFFORTS = new Set(["low", "medium", "high", "xhigh"]);
/** Map `CLAUDE_AI_EFFORT` to a `claude --effort` level. Defaults to "high" — the engine wants a substantive
* review, not a fast shallow one — and falls back to "high" for any unset or unrecognized value so a typo can't
* silently downgrade reviews. The CLI clamps a level above the model's own ceiling (e.g. xhigh on Sonnet) down. */
/** Map `CLAUDE_AI_EFFORT` to a `claude --effort` level. Defaults to "medium" so subscription fallback preserves
* enough reasoning depth for reviews without burning high-effort tokens on every PR. A typo falls back to medium
* instead of silently disabling the reviewer; operators can still raise important repos to high/xhigh/max. */
export function resolveEffort(configured: string | undefined): string {
const level = (configured ?? "").trim().toLowerCase();
return VALID_CLAUDE_EFFORTS.has(level) ? level : "high";
return VALID_CLAUDE_EFFORTS.has(level) ? level : "medium";
}

/** Map `CODEX_AI_EFFORT` to Codex reasoning effort. Codex currently supports xhigh as its top level, so a
Expand All @@ -117,7 +117,7 @@ export function resolveCodexEffort(configured: string | undefined): string {
const level = (configured ?? "").trim().toLowerCase();
if (VALID_CODEX_EFFORTS.has(level)) return level;
if (level === "max") return "xhigh";
return "high";
return "medium";
}

// Per-effort subprocess timeout (ms) for the subscription CLIs. A higher effort legitimately runs longer, so the
Expand Down Expand Up @@ -943,8 +943,8 @@ export function resolveRequiredCliProviders(env: Record<string, string | undefin
}

/** Select the self-host AI provider(s) from AI_PROVIDER and wrap them in the name-aware router. A comma-separated
* list of TWO+ providers is addressable by name for dual review (see `routeProviders`) and otherwise falls back
* through them in order; a SINGLE provider is wrapped the same way — NOT returned bare — so a reviewer-plan address
* list is addressable by name for configured reviewers (see `routeProviders`) and otherwise falls back through
* providers in order; a SINGLE provider is wrapped the same way — NOT returned bare — so a reviewer-plan address
* that names the provider (`{ model: "claude-code" }`, the single-provider plan from `resolveAiReviewerPlan`)
* resolves to that provider's own default model instead of reaching it verbatim as `claude --model claude-code`
* (a 404 that broke every review on a single-provider self-host, #1610). Returns undefined when unconfigured or no
Expand All @@ -957,17 +957,35 @@ export function createSelfHostAi(env: Record<string, string | undefined>): SelfH

const COMBINE_STRATEGIES = new Set<CombineStrategy>(["single", "consensus", "synthesis"]);
const ON_MERGE_RULES = new Set<OnMerge>(["either", "both"]);
const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]);

/** Resolve the self-host dual-review plan from env: the credentialed providers become the reviewer(s), `AI_COMBINE`
* the strategy (default `synthesis` for two — "both review, one synthesized decision"), `AI_ON_MERGE` the
* synthesis rule. Returns undefined when no provider is configured (cloud, or AI off) so ai-review keeps its
* byte-identical Workers-AI consensus default; one provider ⇒ `single`; two+ ⇒ the configured strategy over the
* first two. The result is attached to the self-host env at boot and passed to runGittensoryAiReview. */
function enabledEnvFlag(value: string | undefined): boolean {
return TRUE_ENV_VALUES.has((value ?? "").trim().toLowerCase());
}

/** Resolve the self-host review plan from env. By default, `AI_PROVIDER=a,b` means one reviewer using `a` with
* `b` as the per-review fallback, so a Codex quota/auth outage can fall through to Claude Code without paying
* for two simultaneous reviewers. `AI_DUAL_REVIEW=1` opts back into the explicit two-reviewer mode where the
* first two providers run independently and `AI_COMBINE` / `AI_ON_MERGE` decide how to merge them. */
export function resolveAiReviewerPlan(
env: Record<string, string | undefined>,
): { reviewers: Array<{ model: string }>; combine: CombineStrategy; onMerge: OnMerge | undefined } | undefined {
): { reviewers: Array<{ model: string; fallback?: string | null | undefined }>; combine: CombineStrategy; onMerge: OnMerge | undefined } | undefined {
const names = resolveProviderNames(env);
if (names.length === 0) return undefined;
if (!enabledEnvFlag(env.AI_DUAL_REVIEW)) {
const primary = names[0] as string;
const fallback = names.find((name) => name !== primary);
return {
reviewers: [
{
model: primary,
...(fallback ? { fallback } : {}),
},
],
combine: "single",
onMerge: undefined,
};
}
if (names.length === 1) return { reviewers: [{ model: names[0] as string }], combine: "single", onMerge: undefined };
// Fail loud when the two SLOTS the dual-review plan actually uses (the first two names) are the same
// provider: routeProviders' `byName` map collapses duplicate provider names to one runtime instance, so
Expand Down
Loading
Loading