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
26 changes: 25 additions & 1 deletion packages/gittensory-engine/src/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,13 +603,25 @@ 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.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. */
openaiModel: string | null;
/** `review.ai_model.openai_compatible_model` (#3902): overrides OPENAI_COMPATIBLE_AI_MODEL for this repo's openai-compatible reviewer. null (default) ⇒ the operator's global env var, then the provider's own default. */
openaiCompatibleModel: string | null;
/** `review.ai_model.anthropic_model` (#3902): overrides ANTHROPIC_AI_MODEL for this repo's anthropic (BYOK Messages API) reviewer. null (default) ⇒ the operator's global env var, then the provider's own default. */
anthropicModel: string | null;
};

export const EMPTY_SELF_HOST_AI_MODEL_CONFIG: SelfHostAiModelConfig = {
claudeModel: null,
claudeEffort: null,
codexModel: null,
codexEffort: null,
ollamaModel: null,
openaiModel: null,
openaiCompatibleModel: null,
anthropicModel: null,
};

/** Per-repo before/after screenshot-capture config under `review.visual` (#3609 / #3610). Generic by design —
Expand Down Expand Up @@ -2101,7 +2113,11 @@ function selfHostAiModelPresent(config: SelfHostAiModelConfig): boolean {
config.claudeModel !== null ||
config.claudeEffort !== null ||
config.codexModel !== null ||
config.codexEffort !== null
config.codexEffort !== null ||
config.ollamaModel !== null ||
config.openaiModel !== null ||
config.openaiCompatibleModel !== null ||
config.anthropicModel !== null
);
}

Expand All @@ -2122,6 +2138,10 @@ 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),
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),
anthropicModel: parsePublicSafeText(record.anthropic_model, "review.ai_model.anthropic_model", warnings),
};
}

Expand Down Expand Up @@ -2506,6 +2526,10 @@ 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.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;
if (review.aiModel.anthropicModel !== null) aiModel.anthropic_model = review.aiModel.anthropicModel;
out.ai_model = aiModel;
}
if (visualConfigPresent(review.visual)) {
Expand Down
4 changes: 4 additions & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7089,6 +7089,10 @@ export async function runAiReviewForAdvisory(
claudeEffort: args.reviewSelfHostAiModel?.claudeEffort ?? null,
codexModel: args.reviewSelfHostAiModel?.codexModel ?? null,
codexEffort: args.reviewSelfHostAiModel?.codexEffort ?? null,
ollamaModel: args.reviewSelfHostAiModel?.ollamaModel ?? null,
openaiModel: args.reviewSelfHostAiModel?.openaiModel ?? null,
openaiCompatibleModel: args.reviewSelfHostAiModel?.openaiCompatibleModel ?? null,
anthropicModel: args.reviewSelfHostAiModel?.anthropicModel ?? null,
// Inline comments (#inline-comments): ask the model for line-anchored findings only when the operator flag,
// the cutover allowlist, AND the per-repo manifest toggle all pass. Otherwise the prompt is byte-identical.
inlineFindings: inlineFindingsRequested,
Expand Down
13 changes: 9 additions & 4 deletions src/review/ai-review-cache-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import type {
import { sha256Hex } from "../utils/crypto";

// Bumped v1→v2 (#2995): `features` gained a `cultureProfile` member. Bumped v2→v3 (#2182-#2186): `features`
// gained an `impactMap` member. 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:v3";
// gained an `impactMap` member. Bumped v3→v4 (#3902): `selfHostAiModelOverride` gained ollamaModel/openaiModel/
// openaiCompatibleModel/anthropicModel members. 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:v4";

// #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 @@ -172,6 +173,10 @@ export async function aiReviewCacheInputFingerprint(input: AiReviewCacheInput):
claudeEffort: input.selfHostAiModelOverride.claudeEffort ?? null,
codexModel: input.selfHostAiModelOverride.codexModel ?? null,
codexEffort: input.selfHostAiModelOverride.codexEffort ?? null,
ollamaModel: input.selfHostAiModelOverride.ollamaModel ?? null,
openaiModel: input.selfHostAiModelOverride.openaiModel ?? null,
openaiCompatibleModel: input.selfHostAiModelOverride.openaiCompatibleModel ?? null,
anthropicModel: input.selfHostAiModelOverride.anthropicModel ?? null,
}
: null,
profile: input.profile ?? null,
Expand Down
29 changes: 27 additions & 2 deletions src/selfhost/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ interface AiRunOptions {
claudeEffort?: string;
codexModel?: string;
codexEffort?: string;
// Same override mechanism, extended to the HTTP-API providers (#3902) -- ollama/openai/openai-compatible/
// anthropic previously had no way to see a per-repo override at all (their model was resolved ONCE from the
// global env var at buildProvider() construction time, before any repo was known). Read per-call, same
// priority as above: repo override > global env var > this file's own default.
ollamaModel?: string;
openaiModel?: string;
openaiCompatibleModel?: string;
anthropicModel?: string;
}
/** A chat completion (`response`) or an embedding result (`data`). Both optional: the core reads whichever it
* asked for (extractAiText → `response`, embedTexts → `data`), each defensive about the other being absent.
Expand Down Expand Up @@ -197,13 +205,26 @@ export function resolveCodexFirstOutputTimeoutMs(env: Record<string, string | un
return 30_000;
}

/** Read the per-call repo override matching this provider variant (#3902) -- ollama/openai/openai-compatible
* each have their OWN `.gittensory.yml` field, so a bare `options.model`-style single field would collide
* across variants sharing this one function. `firstConfigured` gives the repo override priority over the
* construction-time-resolved `opts.model` (itself already env-var > undefined), matching the same repo-override
* > global-env-var priority `configuredClaudeModel`/`configuredCodexModel` already enforce for the CLI providers. */
function resolveOpenAiCompatibleRepoOverride(providerName: string, options: AiRunOptions): string | undefined {
if (providerName === "ollama") return options.ollamaModel;
if (providerName === "openai") return options.openaiModel;
return options.openaiCompatibleModel;
}

/** OpenAI-compatible endpoint (Ollama's /v1, OpenAI, vLLM, LM Studio, …) — chat + embeddings. */
export function createOpenAiCompatibleAi(opts: {
baseUrl: string;
apiKey?: string | undefined;
model?: string | undefined;
defaultModel?: string | undefined;
embedModel?: string | undefined;
/** Which `.gittensory.yml` `review.ai_model` field this instance's per-call override reads from (#3902). */
providerName?: "ollama" | "openai" | "openai-compatible" | undefined;
}): SelfHostAi {
const base = opts.baseUrl.replace(/\/+$/, "");
const headers = (): Record<string, string> => ({ "content-type": "application/json", ...(opts.apiKey ? { authorization: `Bearer ${opts.apiKey}` } : {}) });
Expand All @@ -222,7 +243,8 @@ export function createOpenAiCompatibleAi(opts: {
const json = (await res.json()) as { data?: Array<{ embedding: number[] }> };
return { data: (json.data ?? []).map((d) => d.embedding) };
}
const resolvedModel = resolveModel(opts.model, model, opts.defaultModel ?? DEFAULT_OPENAI_COMPATIBLE_CHAT_MODEL);
const repoOverride = opts.providerName ? resolveOpenAiCompatibleRepoOverride(opts.providerName, options) : undefined;
const resolvedModel = resolveModel(firstConfigured(repoOverride, opts.model), model, opts.defaultModel ?? DEFAULT_OPENAI_COMPATIBLE_CHAT_MODEL);
const res = await fetch(`${base}/chat/completions`, {
method: "POST",
headers: headers(),
Expand Down Expand Up @@ -255,7 +277,9 @@ export function createAnthropicAi(opts: { apiKey: string; model?: string | undef
.map((m) => m.content)
.join("\n\n") || undefined;
const messages = msgs.filter((m) => m.role !== "system").map((m) => ({ role: m.role === "assistant" ? "assistant" : "user", content: m.content }));
const resolvedModel = resolveModel(opts.model, model, "claude-sonnet-5");
// Repo override > construction-time env-resolved opts.model (#3902), same priority as the OpenAI-compatible
// providers above and the CLI providers' claudeModel/codexModel.
const resolvedModel = resolveModel(firstConfigured(options.anthropicModel, opts.model), model, "claude-sonnet-5");
const res = await fetch(`${base}/v1/messages`, {
method: "POST",
headers: { "content-type": "application/json", "x-api-key": opts.apiKey, "anthropic-version": "2023-06-01" },
Expand Down Expand Up @@ -1070,6 +1094,7 @@ export function buildProvider(name: string, env: Record<string, string | undefin
model: configuredOpenAiCompatibleModel(name, env),
defaultModel: defaultOpenAiCompatibleModel(name),
embedModel: env.AI_EMBED_MODEL,
providerName: name,
});
case "anthropic": {
const apiKey = env.ANTHROPIC_API_KEY;
Expand Down
30 changes: 26 additions & 4 deletions src/services/ai-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,15 @@ export type GittensoryAiReviewInput = {
claudeEffort?: string | null | undefined;
codexModel?: string | null | undefined;
codexEffort?: string | null | undefined;
/**
* Same override mechanism, extended to the HTTP-API self-host providers (#3902): overrides
* OLLAMA_AI_MODEL/OPENAI_AI_MODEL/OPENAI_COMPATIBLE_AI_MODEL/ANTHROPIC_AI_MODEL for THIS repo. A hosted
* (Workers-AI) `env.AI` ignores these fields entirely. Absent/null ⇒ byte-identical to today.
*/
ollamaModel?: string | null | undefined;
openaiModel?: string | null | undefined;
openaiCompatibleModel?: string | null | undefined;
anthropicModel?: string | null | undefined;
/**
* `.gittensory.yml` `review.path_instructions` (#review-path-instructions), pre-resolved by the caller to the
* entries whose glob matched THIS PR's changed files (via `resolveReviewPathInstructions`) — a ready-to-append
Expand Down Expand Up @@ -797,10 +806,11 @@ function buildRepoInstructionsSystemAppend(repoInstructions: string | null | und

/** Correlation + per-repo override context forwarded to `env.AI.run`'s options. `jobId`/`repoFullName`/
* `pullNumber` (#codex-timeout-fields) are purely observational — a self-host provider-failure log, never read
* by any provider's own request logic. `claudeModel`/`claudeEffort`/`codexModel`/`codexEffort`
* (#selfhost-ai-model-override) are the exception: the self-host claude-code/codex providers DO read their
* matching pair to pick the model/effort for THIS repo, taking priority over that provider's global env var.
* Both self-host-only; a hosted (Workers-AI) `env.AI` ignores every field here. */
* by any provider's own request logic. `claudeModel`/`claudeEffort`/`codexModel`/`codexEffort` and
* `ollamaModel`/`openaiModel`/`openaiCompatibleModel`/`anthropicModel` (#selfhost-ai-model-override, #3902) are
* the exception: the matching self-host provider DOES read its own field to pick the model (+ effort, for the
* CLI providers) for THIS repo, taking priority over that provider's global env var. All self-host-only; a
* hosted (Workers-AI) `env.AI` ignores every field here. */
type AiRunCorrelation = {
jobId?: string | undefined;
repoFullName?: string | undefined;
Expand All @@ -809,6 +819,10 @@ type AiRunCorrelation = {
claudeEffort?: string | undefined;
codexModel?: string | undefined;
codexEffort?: string | undefined;
ollamaModel?: string | undefined;
openaiModel?: string | undefined;
openaiCompatibleModel?: string | undefined;
anthropicModel?: string | undefined;
};

/** One reviewer opinion (whichever provider `env.AI` resolves to — self-host Codex/Claude Code/etc, or the
Expand Down Expand Up @@ -864,6 +878,10 @@ async function runWorkersOpinion(
...(correlation?.claudeEffort !== undefined ? { claudeEffort: correlation.claudeEffort } : {}),
...(correlation?.codexModel !== undefined ? { codexModel: correlation.codexModel } : {}),
...(correlation?.codexEffort !== undefined ? { codexEffort: correlation.codexEffort } : {}),
...(correlation?.ollamaModel !== undefined ? { ollamaModel: correlation.ollamaModel } : {}),
...(correlation?.openaiModel !== undefined ? { openaiModel: correlation.openaiModel } : {}),
...(correlation?.openaiCompatibleModel !== undefined ? { openaiCompatibleModel: correlation.openaiCompatibleModel } : {}),
...(correlation?.anthropicModel !== undefined ? { anthropicModel: correlation.anthropicModel } : {}),
attempt,
},
extra,
Expand Down Expand Up @@ -1865,6 +1883,10 @@ export async function runGittensoryAiReview(
claudeEffort: input.claudeEffort ?? undefined,
codexModel: input.codexModel ?? undefined,
codexEffort: input.codexEffort ?? undefined,
ollamaModel: input.ollamaModel ?? undefined,
openaiModel: input.openaiModel ?? undefined,
openaiCompatibleModel: input.openaiCompatibleModel ?? undefined,
anthropicModel: input.anthropicModel ?? undefined,
};
if (input.providerKey) {
const outcome = await runProviderReview(
Expand Down
11 changes: 11 additions & 0 deletions test/unit/ai-review-cache-input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,17 @@ describe("aiReviewCacheInputFingerprint", () => {
expect(effortChanged).not.toBe(original);
});

it("changes when the PER-REPO review.ai_model override gains an ollama/openai/openai-compatible/anthropic model (#3902)", async () => {
const original = await aiReviewCacheInputFingerprint(baseInput());
for (const key of ["ollamaModel", "openaiModel", "openaiCompatibleModel", "anthropicModel"] as const) {
const changed = await aiReviewCacheInputFingerprint({
...baseInput(),
selfHostAiModelOverride: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null, [key]: "repo-override-model" },
});
expect(changed, key).not.toBe(original);
}
});

it("normalizes an absent self-host provider config the same whether omitted or explicitly empty", async () => {
const nullConfig = await aiReviewCacheInputFingerprint({ ...baseInput(), selfHostProviderConfig: null });
const emptyConfig = await aiReviewCacheInputFingerprint({ ...baseInput(), selfHostProviderConfig: {} });
Expand Down
24 changes: 23 additions & 1 deletion test/unit/focus-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3575,14 +3575,18 @@ describe("review.auto_review (#1954 / #2038–#2041)", () => {
});

describe("review.ai_model (#selfhost-ai-model-override)", () => {
it("parses all four knobs, marks present, and round-trips", () => {
it("parses all eight knobs, marks present, and round-trips", () => {
const m = parseFocusManifest({
review: {
ai_model: {
claude_model: "claude-opus-4-8",
claude_effort: "high",
codex_model: "gpt-5.5-pro",
codex_effort: "xhigh",
ollama_model: "llama3.3",
openai_model: "gpt-5.5",
openai_compatible_model: "qwen2.5-coder",
anthropic_model: "claude-opus-4-8",
},
},
});
Expand All @@ -3591,11 +3595,29 @@ describe("review.ai_model (#selfhost-ai-model-override)", () => {
claudeEffort: "high",
codexModel: "gpt-5.5-pro",
codexEffort: "xhigh",
ollamaModel: "llama3.3",
openaiModel: "gpt-5.5",
openaiCompatibleModel: "qwen2.5-coder",
anthropicModel: "claude-opus-4-8",
});
expect(m.review.present).toBe(true);
expect(parseFocusManifest({ review: reviewConfigToJson(m.review) }).review.aiModel).toEqual(m.review.aiModel);
});

it("parses each of the four HTTP-API provider knobs independently (#3902)", () => {
for (const [key, camelKey] of [
["ollama_model", "ollamaModel"],
["openai_model", "openaiModel"],
["openai_compatible_model", "openaiCompatibleModel"],
["anthropic_model", "anthropicModel"],
] as const) {
const m = parseFocusManifest({ review: { ai_model: { [key]: "some-model" } } });
expect(m.review.aiModel).toEqual({ ...EMPTY_SELF_HOST_AI_MODEL_CONFIG, [camelKey]: "some-model" });
expect(m.review.present).toBe(true);
expect(reviewConfigToJson(m.review)).toEqual({ ai_model: { [key]: "some-model" } });
}
});

it("absent/null ai_model yields the empty defaults and does not mark review present on its own", () => {
expect(parseFocusManifest({}).review.aiModel).toEqual({ ...EMPTY_SELF_HOST_AI_MODEL_CONFIG });
expect(parseFocusManifest({ review: { ai_model: null } }).review.aiModel).toEqual({ ...EMPTY_SELF_HOST_AI_MODEL_CONFIG });
Expand Down
Loading
Loading