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
4 changes: 4 additions & 0 deletions .loopover.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,10 @@ review:
# claude_effort: null # Overrides CLAUDE_AI_EFFORT for this repo. String or null. Default (env unset): medium.
# codex_model: null # Overrides CODEX_AI_MODEL for this repo. String or null.
# codex_effort: null # Overrides CODEX_AI_EFFORT for this repo. String or null. Default (env unset): medium.
# claude_timeout_ms: null # Overrides CLAUDE_AI_TIMEOUT_MS for this repo. Positive integer (ms) or null. (#8364)
# codex_timeout_ms: null # Overrides CODEX_AI_TIMEOUT_MS for this repo. Positive integer (ms) or null. (#8364)
# claude_first_output_timeout_ms: null # Overrides CLAUDE_AI_FIRST_OUTPUT_TIMEOUT_MS for this repo. Positive integer (ms) or null. (#8364)
# codex_first_output_timeout_ms: null # Overrides CODEX_AI_FIRST_OUTPUT_TIMEOUT_MS for this repo. Positive integer (ms) or null. (#8364)
# ollama_model: null # Overrides OLLAMA_AI_MODEL for this repo's ollama reviewer. String or null. (#3902)
# openai_model: null # Overrides OPENAI_AI_MODEL for this repo's openai reviewer. String or null. (#3902)
# openai_compatible_model: null # Overrides OPENAI_COMPATIBLE_AI_MODEL for this repo. String or null. (#3902)
Expand Down
4 changes: 4 additions & 0 deletions config/examples/loopover.full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,10 @@ review:
# claude_effort: null # Overrides CLAUDE_AI_EFFORT for this repo. String or null. Default (env unset): medium.
# codex_model: null # Overrides CODEX_AI_MODEL for this repo. String or null.
# codex_effort: null # Overrides CODEX_AI_EFFORT for this repo. String or null. Default (env unset): medium.
# claude_timeout_ms: null # Overrides CLAUDE_AI_TIMEOUT_MS for this repo. Positive integer (ms) or null. (#8364)
# codex_timeout_ms: null # Overrides CODEX_AI_TIMEOUT_MS for this repo. Positive integer (ms) or null. (#8364)
# claude_first_output_timeout_ms: null # Overrides CLAUDE_AI_FIRST_OUTPUT_TIMEOUT_MS for this repo. Positive integer (ms) or null. (#8364)
# codex_first_output_timeout_ms: null # Overrides CODEX_AI_FIRST_OUTPUT_TIMEOUT_MS for this repo. Positive integer (ms) or null. (#8364)
# ollama_model: null # Overrides OLLAMA_AI_MODEL for this repo's ollama reviewer. String or null. (#3902)
# openai_model: null # Overrides OPENAI_AI_MODEL for this repo's openai reviewer. String or null. (#3902)
# openai_compatible_model: null # Overrides OPENAI_COMPATIBLE_AI_MODEL for this repo. String or null. (#3902)
Expand Down
61 changes: 51 additions & 10 deletions packages/loopover-engine/src/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -999,11 +999,12 @@ export const EMPTY_AUTO_REVIEW_CONFIG: AutoReviewConfig = {
autoPauseAfterReviewedCommits: null,
};

/** Per-repo self-host reviewer model/effort overrides under `review.ai_model`. Each field independently overrides
* the matching global env var (CLAUDE_AI_MODEL / CLAUDE_AI_EFFORT / CODEX_AI_MODEL / CODEX_AI_EFFORT) for THIS
* repo only — it never widens what the operator's own env already permits, only narrows/redirects it, so a
* compromised repo config can change which model reviews it but not grant itself a new credential or provider.
* (#selfhost-ai-model-override) */
/** Per-repo self-host reviewer model/effort/timeout overrides under `review.ai_model`. Each field independently
* overrides the matching global env var (CLAUDE_AI_MODEL / CLAUDE_AI_EFFORT / CODEX_AI_MODEL / CODEX_AI_EFFORT /
* CLAUDE_AI_TIMEOUT_MS / CODEX_AI_TIMEOUT_MS / CLAUDE_AI_FIRST_OUTPUT_TIMEOUT_MS / CODEX_AI_FIRST_OUTPUT_TIMEOUT_MS)
* for THIS repo only — it never widens what the operator's own env already permits, only narrows/redirects it,
* so a compromised repo config can change which model reviews it but not grant itself a new credential or
* provider. (#selfhost-ai-model-override, #8364) */
export type SelfHostAiModelConfig = {
/** `review.ai_model.claude_model`: overrides CLAUDE_AI_MODEL for this repo's claude-code reviewer. null (default) ⇒ the operator's global env var, then the provider's own default. */
claudeModel: string | null;
Expand All @@ -1013,6 +1014,14 @@ export type SelfHostAiModelConfig = {
codexModel: string | null;
/** `review.ai_model.codex_effort`: overrides CODEX_AI_EFFORT for this repo's codex reviewer. null (default) ⇒ the operator's global env var, then "medium". */
codexEffort: string | null;
/** `review.ai_model.claude_timeout_ms` (#8364): overrides CLAUDE_AI_TIMEOUT_MS for this repo's claude-code reviewer. null (default) ⇒ the operator's global env var, then the effort-based ladder. */
claudeTimeoutMs: number | null;
/** `review.ai_model.codex_timeout_ms` (#8364): overrides CODEX_AI_TIMEOUT_MS for this repo's codex reviewer. null (default) ⇒ the operator's global env var, then the effort-based ladder. */
codexTimeoutMs: number | null;
/** `review.ai_model.claude_first_output_timeout_ms` (#8364): overrides CLAUDE_AI_FIRST_OUTPUT_TIMEOUT_MS for this repo's claude-code reviewer. null (default) ⇒ the operator's global env var, then the provider default. */
claudeFirstOutputTimeoutMs: number | null;
/** `review.ai_model.codex_first_output_timeout_ms` (#8364): overrides CODEX_AI_FIRST_OUTPUT_TIMEOUT_MS for this repo's codex reviewer. null (default) ⇒ the operator's global env var, then the provider default. */
codexFirstOutputTimeoutMs: number | null;
/** `review.ai_model.ollama_model` (#3902): overrides OLLAMA_AI_MODEL for this repo's ollama reviewer. null (default) ⇒ the operator's global env var, then the provider's own default. */
ollamaModel: string | null;
/** `review.ai_model.openai_model` (#3902): overrides OPENAI_AI_MODEL for this repo's openai reviewer. null (default) ⇒ the operator's global env var, then the provider's own default. */
Expand All @@ -1028,6 +1037,10 @@ export const EMPTY_SELF_HOST_AI_MODEL_CONFIG: SelfHostAiModelConfig = {
claudeEffort: null,
codexModel: null,
codexEffort: null,
claudeTimeoutMs: null,
codexTimeoutMs: null,
claudeFirstOutputTimeoutMs: null,
codexFirstOutputTimeoutMs: null,
ollamaModel: null,
openaiModel: null,
openaiCompatibleModel: null,
Expand Down Expand Up @@ -3194,6 +3207,10 @@ function overlaySelfHostAiModelConfig(base: SelfHostAiModelConfig, override: Sel
claudeEffort: pickOverlayNullable(override.claudeEffort, base.claudeEffort),
codexModel: pickOverlayNullable(override.codexModel, base.codexModel),
codexEffort: pickOverlayNullable(override.codexEffort, base.codexEffort),
claudeTimeoutMs: pickOverlayNullable(override.claudeTimeoutMs, base.claudeTimeoutMs),
codexTimeoutMs: pickOverlayNullable(override.codexTimeoutMs, base.codexTimeoutMs),
claudeFirstOutputTimeoutMs: pickOverlayNullable(override.claudeFirstOutputTimeoutMs, base.claudeFirstOutputTimeoutMs),
codexFirstOutputTimeoutMs: pickOverlayNullable(override.codexFirstOutputTimeoutMs, base.codexFirstOutputTimeoutMs),
ollamaModel: pickOverlayNullable(override.ollamaModel, base.ollamaModel),
openaiModel: pickOverlayNullable(override.openaiModel, base.openaiModel),
openaiCompatibleModel: pickOverlayNullable(override.openaiCompatibleModel, base.openaiCompatibleModel),
Expand Down Expand Up @@ -3383,18 +3400,22 @@ function selfHostAiModelPresent(config: SelfHostAiModelConfig): boolean {
config.claudeEffort !== null ||
config.codexModel !== null ||
config.codexEffort !== null ||
config.claudeTimeoutMs !== null ||
config.codexTimeoutMs !== null ||
config.claudeFirstOutputTimeoutMs !== null ||
config.codexFirstOutputTimeoutMs !== null ||
config.ollamaModel !== null ||
config.openaiModel !== null ||
config.openaiCompatibleModel !== null ||
config.anthropicModel !== null
);
}

/** Parse `review.ai_model` — per-repo self-host reviewer model/effort overrides. Values are opaque, bounded,
* public-safe strings (like `review.tone`) — never validated against a fixed model/effort enum here, so this
* parser never drifts from the provider's own effort allowlist (`src/selfhost/ai.ts`); an invalid effort value
* degrades the SAME way an invalid env-sourced one already does (falls back to "medium" at resolve time).
* (#selfhost-ai-model-override) */
/** Parse `review.ai_model` — per-repo self-host reviewer model/effort/timeout overrides. Model/effort values are
* opaque, bounded, public-safe strings (like `review.tone`) — never validated against a fixed model/effort enum
* here, so this parser never drifts from the provider's own effort allowlist (`src/selfhost/ai.ts`); an invalid
* effort value degrades the SAME way an invalid env-sourced one already does (falls back to "medium" at resolve
* time). Timeout fields are positive whole-number milliseconds (#8364). (#selfhost-ai-model-override) */
function parseSelfHostAiModelConfig(value: JsonValue | undefined, warnings: string[]): SelfHostAiModelConfig {
if (value === undefined || value === null) return { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG };
if (typeof value !== "object" || Array.isArray(value)) {
Expand All @@ -3407,6 +3428,18 @@ function parseSelfHostAiModelConfig(value: JsonValue | undefined, warnings: stri
claudeEffort: parsePublicSafeText(record.claude_effort, "review.ai_model.claude_effort", warnings),
codexModel: parsePublicSafeText(record.codex_model, "review.ai_model.codex_model", warnings),
codexEffort: parsePublicSafeText(record.codex_effort, "review.ai_model.codex_effort", warnings),
claudeTimeoutMs: normalizeOptionalPositiveInteger(record.claude_timeout_ms, "review.ai_model.claude_timeout_ms", warnings),
codexTimeoutMs: normalizeOptionalPositiveInteger(record.codex_timeout_ms, "review.ai_model.codex_timeout_ms", warnings),
claudeFirstOutputTimeoutMs: normalizeOptionalPositiveInteger(
record.claude_first_output_timeout_ms,
"review.ai_model.claude_first_output_timeout_ms",
warnings,
),
codexFirstOutputTimeoutMs: normalizeOptionalPositiveInteger(
record.codex_first_output_timeout_ms,
"review.ai_model.codex_first_output_timeout_ms",
warnings,
),
ollamaModel: parsePublicSafeText(record.ollama_model, "review.ai_model.ollama_model", warnings),
openaiModel: parsePublicSafeText(record.openai_model, "review.ai_model.openai_model", warnings),
openaiCompatibleModel: parsePublicSafeText(record.openai_compatible_model, "review.ai_model.openai_compatible_model", warnings),
Expand Down Expand Up @@ -3916,6 +3949,14 @@ export function reviewConfigToJson(review: FocusManifestReviewConfig): JsonValue
if (review.aiModel.claudeEffort !== null) aiModel.claude_effort = review.aiModel.claudeEffort;
if (review.aiModel.codexModel !== null) aiModel.codex_model = review.aiModel.codexModel;
if (review.aiModel.codexEffort !== null) aiModel.codex_effort = review.aiModel.codexEffort;
if (review.aiModel.claudeTimeoutMs !== null) aiModel.claude_timeout_ms = review.aiModel.claudeTimeoutMs;
if (review.aiModel.codexTimeoutMs !== null) aiModel.codex_timeout_ms = review.aiModel.codexTimeoutMs;
if (review.aiModel.claudeFirstOutputTimeoutMs !== null) {
aiModel.claude_first_output_timeout_ms = review.aiModel.claudeFirstOutputTimeoutMs;
}
if (review.aiModel.codexFirstOutputTimeoutMs !== null) {
aiModel.codex_first_output_timeout_ms = review.aiModel.codexFirstOutputTimeoutMs;
}
if (review.aiModel.ollamaModel !== null) aiModel.ollama_model = review.aiModel.ollamaModel;
if (review.aiModel.openaiModel !== null) aiModel.openai_model = review.aiModel.openaiModel;
if (review.aiModel.openaiCompatibleModel !== null) aiModel.openai_compatible_model = review.aiModel.openaiCompatibleModel;
Expand Down
10 changes: 7 additions & 3 deletions src/queue/ai-review-orchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,13 +690,17 @@ export async function runAiReviewForAdvisory(
onMerge: args.settings.aiReviewOnMerge ?? undefined,
reviewers: args.settings.aiReviewReviewers ?? undefined,
securityFocus: args.reviewSecurityFocus === true,
// Self-host per-repo model/effort override (#selfhost-ai-model-override): absent/null fields fall through
// runLoopOverAiReview -> runWorkersOpinion -> the self-host provider's own global-env/hardcoded default,
// exactly as if review.ai_model had never been set.
// Self-host per-repo model/effort/timeout override (#selfhost-ai-model-override, #8364): absent/null
// fields fall through runLoopOverAiReview -> runWorkersOpinion -> the self-host provider's own
// global-env/hardcoded default, exactly as if review.ai_model had never been set.
claudeModel: args.reviewSelfHostAiModel?.claudeModel ?? null,
claudeEffort: args.reviewSelfHostAiModel?.claudeEffort ?? null,
codexModel: args.reviewSelfHostAiModel?.codexModel ?? null,
codexEffort: args.reviewSelfHostAiModel?.codexEffort ?? null,
claudeTimeoutMs: args.reviewSelfHostAiModel?.claudeTimeoutMs ?? null,
codexTimeoutMs: args.reviewSelfHostAiModel?.codexTimeoutMs ?? null,
claudeFirstOutputTimeoutMs: args.reviewSelfHostAiModel?.claudeFirstOutputTimeoutMs ?? null,
codexFirstOutputTimeoutMs: args.reviewSelfHostAiModel?.codexFirstOutputTimeoutMs ?? null,
ollamaModel: args.reviewSelfHostAiModel?.ollamaModel ?? null,
openaiModel: args.reviewSelfHostAiModel?.openaiModel ?? null,
openaiCompatibleModel: args.reviewSelfHostAiModel?.openaiCompatibleModel ?? null,
Expand Down
15 changes: 10 additions & 5 deletions src/review/ai-review-cache-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import { sha256Hex } from "../utils/crypto";
// gained an `impactMap` member. Bumped v3→v4 (#3902): `selfHostAiModelOverride` gained ollamaModel/openaiModel/
// openaiCompatibleModel/anthropicModel members. Bumped v4→v5: added a top-level `body` member (the PR
// description is threaded into the reviewer prompt exactly like `title`, but was never fingerprinted -- an
// edited-description webhook with an unchanged head SHA silently replayed the pre-edit review). Every prior
// cached review's fingerprint was computed without that key, so bumping the version guarantees a clean cache
// miss on the first review after upgrade rather than silently reusing a hash computed under a different payload
// shape.
export const AI_REVIEW_CACHE_INPUT_VERSION = "ai-review-input:v5";
// edited-description webhook with an unchanged head SHA silently replayed the pre-edit review). Bumped v5→v6
// (#8364): `selfHostAiModelOverride` gained claudeTimeoutMs/codexTimeoutMs/claudeFirstOutputTimeoutMs/
// codexFirstOutputTimeoutMs members. Every prior cached review's fingerprint was computed without those keys,
// so bumping the version guarantees a clean cache miss on the first review after upgrade rather than silently
// reusing a hash computed under a different payload shape.
export const AI_REVIEW_CACHE_INPUT_VERSION = "ai-review-input:v6";

// #regate-churn (root cause, confirmed in production): this fingerprint USED to also hash the PR's live
// `baseSha`, on the theory that a rebase/retarget can change the diff GitHub reports for an otherwise-unchanged
Expand Down Expand Up @@ -181,6 +182,10 @@ export async function aiReviewCacheInputFingerprint(input: AiReviewCacheInput):
claudeEffort: input.selfHostAiModelOverride.claudeEffort ?? null,
codexModel: input.selfHostAiModelOverride.codexModel ?? null,
codexEffort: input.selfHostAiModelOverride.codexEffort ?? null,
claudeTimeoutMs: input.selfHostAiModelOverride.claudeTimeoutMs ?? null,
codexTimeoutMs: input.selfHostAiModelOverride.codexTimeoutMs ?? null,
claudeFirstOutputTimeoutMs: input.selfHostAiModelOverride.claudeFirstOutputTimeoutMs ?? null,
codexFirstOutputTimeoutMs: input.selfHostAiModelOverride.codexFirstOutputTimeoutMs ?? null,
ollamaModel: input.selfHostAiModelOverride.ollamaModel ?? null,
openaiModel: input.selfHostAiModelOverride.openaiModel ?? null,
openaiCompatibleModel: input.selfHostAiModelOverride.openaiCompatibleModel ?? null,
Expand Down
Loading
Loading