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
71 changes: 48 additions & 23 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,7 @@ import type { LocalBranchAnalysisInput } from "../signals/local-branch";
import {
callAiProvider,
clampNumber,
coerceAiUsage,
DEFAULT_BYOK_DAILY_REPO_LIMIT,
hasPublicReviewAssessment,
utcDayStartIso,
Expand Down Expand Up @@ -6827,11 +6828,21 @@ type SelfHostVisionRunner = { run?: (model: string, options: Record<string, unkn
* (AI_VISION_MODEL) over whatever string is passed here (see `resolveModel` in `selfhost/ai.ts`). Fail-safe
* on every path, exactly like `callAiProvider`'s BYOK sibling: no binding / no `.run` / a thrown error / an
* unparseable response all degrade to `null`, never a thrown error reaching the caller. */
async function runSelfHostVisualVision(env: Env, system: string, user: string, images: readonly AiContentBlock[]): Promise<string | null> {
/** Label recorded for a self-host vision call when the provider reports no usage/model at all (e.g. a
* malformed/non-JSON response) -- mirrors the same static-fallback convention as the other advisory AI
* features (WORKERS_SLOP_MODELS et al.), since there is no per-repo config field for this binding's model. */
const SELF_HOST_VISION_MODEL_FALLBACK = "ollama:visual-vision";

async function runSelfHostVisualVision(
env: Env,
system: string,
user: string,
images: readonly AiContentBlock[],
): Promise<{ text: string | null; usage?: AiReviewActualUsage | undefined }> {
const ai = env.AI_VISION as unknown as SelfHostVisionRunner | undefined;
if (!ai || typeof ai.run !== "function") return null;
if (!ai || typeof ai.run !== "function") return { text: null };
try {
const result = (await ai.run("visual-vision", {
const result = await ai.run("visual-vision", {
messages: [
{ role: "system", content: system },
{ role: "user", content: [{ type: "text", text: user }, ...images] },
Expand All @@ -6842,10 +6853,11 @@ async function runSelfHostVisualVision(env: Env, system: string, user: string, i
// concurrent load faster than the embed model does, degrading to latency collapse rather than a clean
// OOM. Ignored by every non-Ollama provider (embeddings, subscription CLIs, Anthropic).
providerOptions: { num_ctx: 4096 },
})) as { response?: string } | null;
return result?.response?.trim() || null;
});
const text = (result as { response?: string } | null)?.response?.trim() || null;
return { text, usage: coerceAiUsage(result) };
} catch {
return null;
return { text: null };
}
}

Expand Down Expand Up @@ -6976,22 +6988,25 @@ export async function runVisualVisionForAdvisory(
return;
}
} else {
visionText = await runSelfHostVisualVision(env, VISUAL_VISION_SYSTEM_PROMPT, buildVisualVisionUserPrompt(visionGate.routes), images);
const selfHostResult = await runSelfHostVisualVision(env, VISUAL_VISION_SYSTEM_PROMPT, buildVisualVisionUserPrompt(visionGate.routes), images);
visionText = selfHostResult.text;
visionUsage = selfHostResult.usage;
if (!visionText) {
await recordVisualVisionUsage(env, args, null, "ok", "no usable output", visionUsage);
return;
}
}
if (!visionText) return;
const visionFindings = parseVisualVisionResponse(visionText);
const findings = buildVisualRegressionFindings(visionFindings);
args.advisory.findings.push(...findings);
if (visionProviderKey) {
await recordVisualVisionUsage(
env,
args,
visionProviderKey,
"ok",
findings.length > 0 ? `advisory findings (${findings.length})` : "no usable output",
visionUsage,
);
}
await recordVisualVisionUsage(
env,
args,
visionProviderKey,
"ok",
findings.length > 0 ? `advisory findings (${findings.length})` : "no usable output",
visionUsage,
);
} catch (error) {
console.log(
JSON.stringify({
Expand All @@ -7007,7 +7022,7 @@ export async function runVisualVisionForAdvisory(
async function recordVisualVisionUsage(
env: Env,
args: { repoFullName: string; pr: { number: number }; author: string | null },
providerKey: { provider: string },
providerKey: { provider: string } | null,
status: string,
detail: string,
usage?: AiReviewActualUsage | undefined,
Expand All @@ -7016,7 +7031,7 @@ async function recordVisualVisionUsage(
feature: "visual_vision",
actor: args.author ?? null,
route: "github_app.visual_vision",
model: `byok:${providerKey.provider}`,
model: providerKey ? `byok:${providerKey.provider}` : (usage?.model ?? SELF_HOST_VISION_MODEL_FALLBACK),
status,
estimatedNeurons: 0,
provider: usage?.provider,
Expand All @@ -7033,7 +7048,7 @@ async function recordVisualVisionUsage(
async function recordScreenshotTableVisionUsage(
env: Env,
args: { repoFullName: string; pr: { number: number }; author: string | null },
providerKey: { provider: string },
providerKey: { provider: string } | null,
status: string,
detail: string,
usage?: AiReviewActualUsage | undefined,
Expand All @@ -7042,7 +7057,7 @@ async function recordScreenshotTableVisionUsage(
feature: "screenshot_table_vision",
actor: args.author ?? null,
route: "github_app.screenshot_table_vision",
model: `byok:${providerKey.provider}`,
model: providerKey ? `byok:${providerKey.provider}` : (usage?.model ?? SELF_HOST_VISION_MODEL_FALLBACK),
status,
estimatedNeurons: 0,
provider: usage?.provider,
Expand Down Expand Up @@ -7149,7 +7164,17 @@ export async function runScreenshotTableVisionForAdvisory(
visionUsage,
);
} else {
visionText = await runSelfHostVisualVision(env, SCREENSHOT_TABLE_VISION_SYSTEM_PROMPT, userPrompt, images);
const selfHostResult = await runSelfHostVisualVision(env, SCREENSHOT_TABLE_VISION_SYSTEM_PROMPT, userPrompt, images);
visionText = selfHostResult.text;
visionUsage = selfHostResult.usage;
await recordScreenshotTableVisionUsage(
env,
args,
null,
"ok",
visionText ? `advisory findings check (${gate.pairCount} pairs)` : "no usable output",
visionUsage,
);
}
if (visionText) {
const parsed = parseScreenshotTableVisionResponse(visionText, gate.pairCount);
Expand Down
48 changes: 44 additions & 4 deletions src/review/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
// fail-safe on a missing vector/inference adapter ("no vector index → no RAG", "no AI → no context"), so the
// modules NEVER throw — they degrade to no-context. Storage (D1 `DB`) is always present (the Worker cannot run
// without it); its wrapper is a thin pass-through with the prepare→bind→all/first/run + batch surface RAG uses.
import { recordAiUsageEvent } from "../db/repositories";
import { coerceAiUsage } from "../services/ai-review";
import { ragDimensionsFromEnv, ragEmbedBatchFromEnv, type InferenceAdapter, type RagInfra, type StorageAdapter, type VectorAdapter } from "./rag";

// ── Storage (D1 → StorageAdapter). Always present. A thin pass-through over `env.DB` — structurally the
Expand Down Expand Up @@ -54,9 +56,47 @@ export function reviewVectorAdapter(vectorize: Vectorize): VectorAdapter {

// ── Inference (the Ai-shaped adapter → InferenceAdapter). Feature-gated. Mirrors `ai.run(model, options)`;
// the cast bridges the overloaded `run` signature to the portable single-signature shape. `ai` is
// Workers AI historically, and on self-host is the generic provider router (src/selfhost/ai.ts). ──
export function reviewInferenceAdapter(ai: Ai): InferenceAdapter {
return { run: (model, options) => (ai as unknown as { run(m: string, o: Record<string, unknown>): Promise<unknown> }).run(model, options) };
// Workers AI historically, and on self-host is the generic provider router (src/selfhost/ai.ts).
//
// Also records every embedding call under the `embeddings` feature (2026-07 fix — previously
// RAG/embedding calls were never recorded in `ai_usage_events` at all). Self-host's `createOpenAiCompatibleAi`
// returns real `usage` (provider/model/tokens) for embeddings, so this is recorded for free via
// `coerceAiUsage`; Workers AI's binding has no such `usage` field, so the call is still recorded (feature +
// the model actually requested), just without token/cost detail. ──
export function reviewInferenceAdapter(env: Env, ai: Ai): InferenceAdapter {
const runner = ai as unknown as { run(m: string, o: Record<string, unknown>): Promise<unknown> };
return {
run: async (model, options) => {
try {
const result = await runner.run(model, options);
const usage = coerceAiUsage(result);
await recordAiUsageEvent(env, {
feature: "embeddings",
route: "review.embeddings",
model: usage?.model ?? model,
provider: usage?.provider,
effort: usage?.effort,
status: "ok",
estimatedNeurons: 0,
inputTokens: usage?.inputTokens,
outputTokens: usage?.outputTokens,
totalTokens: usage?.totalTokens,
costUsd: usage?.costUsd,
});
return result;
} catch (error) {
await recordAiUsageEvent(env, {
feature: "embeddings",
route: "review.embeddings",
model,
status: "error",
estimatedNeurons: 0,
detail: error instanceof Error ? error.message : "embedding_failed",
});
throw error;
}
},
};
}

/** The infra bundle the ported review modules accept (`RagInfra`). Built from `Env`:
Expand All @@ -75,6 +115,6 @@ export function createReviewAdapters(env: Env): RagInfra {
// Embeddings use the DEDICATED embed provider (env.AI_EMBED) when configured — keeping the review chat chain
// frontier-only — and fall back to env.AI otherwise (byte-identical to before).
const embedAi = env.AI_EMBED ?? env.AI;
if (embedAi) infra.inference = reviewInferenceAdapter(embedAi);
if (embedAi) infra.inference = reviewInferenceAdapter(env, embedAi);
return infra;
}
52 changes: 47 additions & 5 deletions src/selfhost/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,11 +295,12 @@ export function createOpenAiCompatibleAi(opts: {
async run(model, options) {
// Embedding request — the core's embedTexts passes { text: string[] }; route to /embeddings (for RAG).
if (Array.isArray(options.text)) {
if (options.text.length === 0) return { data: [] };
const embedModel = opts.embedModel ?? "bge-m3";
if (options.text.length === 0) return { data: [], usage: buildAiUsage({ provider: opts.providerName, model: embedModel, inputTokens: 0, totalTokens: 0 }) };
const res = await fetch(`${base}/embeddings`, {
method: "POST",
headers: headers(),
body: JSON.stringify({ model: opts.embedModel ?? "bge-m3", input: options.text }),
body: JSON.stringify({ model: embedModel, input: options.text }),
signal: AbortSignal.timeout(120_000),
});
// #4996: the error previously carried only the status code, with no detail from the response body --
Expand All @@ -311,8 +312,20 @@ export function createOpenAiCompatibleAi(opts: {
const detail = await res.text().then((t) => t.slice(0, 300)).catch(() => "");
throw new Error(detail ? `ai_embed_http_${res.status}: ${detail}` : `ai_embed_http_${res.status}`);
}
const json = (await res.json()) as { data?: Array<{ embedding: number[] }> };
return { data: (json.data ?? []).map((d) => d.embedding) };
// #ai-usage-embeddings: an OpenAI-compatible /embeddings response can carry a `usage` object
// (Ollama does); surface it so the RAG inference adapter (src/review/adapters.ts) can record real
// embedding usage instead of never tracking embeddings at all. Absent on providers that don't
// report it — inputTokens/totalTokens simply stay undefined, same fail-open shape as the chat path.
const json = (await res.json()) as { data?: Array<{ embedding: number[] }>; usage?: { prompt_tokens?: number; total_tokens?: number } };
return {
data: (json.data ?? []).map((d) => d.embedding),
usage: buildAiUsage({
provider: opts.providerName,
model: embedModel,
inputTokens: json.usage?.prompt_tokens,
totalTokens: json.usage?.total_tokens ?? json.usage?.prompt_tokens,
}),
};
}
const repoOverride = opts.providerName ? resolveOpenAiCompatibleRepoOverride(opts.providerName, options) : undefined;
const resolvedModel = resolveModel(firstConfigured(repoOverride, opts.model), model, opts.defaultModel ?? DEFAULT_OPENAI_COMPATIBLE_CHAT_MODEL);
Expand All @@ -331,7 +344,11 @@ export function createOpenAiCompatibleAi(opts: {
if (!res.ok) throw new Error(`ai_http_${res.status}`);
const data = (await res.json()) as { choices?: Array<{ message?: { content?: string } }> };
const usage = extractCliUsage(JSON.stringify(data));
return { response: data.choices?.[0]?.message?.content ?? "", usage: { ...usage, model: usage.model ?? resolvedModel } };
// #ai-usage-provider-attribution: extractCliUsage parses CLI-subprocess-style stdout markers, which an
// HTTP JSON chat-completions response never contains, so this path never has a real provider to read --
// populate it from this adapter's own configured providerName (e.g. "ollama"), which every caller routed
// through env.AI_ADVISORY/AI_EMBED/AI_VISION previously had no way to attribute at all.
return { response: data.choices?.[0]?.message?.content ?? "", usage: buildAiUsage({ ...usage, model: usage.model ?? resolvedModel, provider: opts.providerName }) };
},
};
}
Expand Down Expand Up @@ -569,6 +586,29 @@ export type AiUsage = CliUsage & {
effort?: string;
};

/** Build an `AiUsage` object, omitting any field whose value is `undefined` -- `exactOptionalPropertyTypes`
* forbids assigning `undefined` to an optional property explicitly, it must simply be absent (mirrors the
* same pattern in src/review/adapters.ts's own `createReviewAdapters`). */
function buildAiUsage(fields: {
provider?: string | undefined;
model?: string | undefined;
inputTokens?: number | undefined;
outputTokens?: number | undefined;
totalTokens?: number | undefined;
costUsd?: number | undefined;
effort?: string | undefined;
}): AiUsage {
const usage: AiUsage = {};
if (fields.provider !== undefined) usage.provider = fields.provider;
if (fields.model !== undefined) usage.model = fields.model;
if (fields.inputTokens !== undefined) usage.inputTokens = fields.inputTokens;
if (fields.outputTokens !== undefined) usage.outputTokens = fields.outputTokens;
if (fields.totalTokens !== undefined) usage.totalTokens = fields.totalTokens;
if (fields.costUsd !== undefined) usage.costUsd = fields.costUsd;
if (fields.effort !== undefined) usage.effort = fields.effort;
return usage;
}

const INPUT_TOKEN_KEYS = ["input_tokens", "inputTokens", "prompt_tokens", "promptTokens"] as const;
const OUTPUT_TOKEN_KEYS = ["output_tokens", "outputTokens", "completion_tokens", "completionTokens"] as const;
const TOTAL_TOKEN_KEYS = ["total_tokens", "totalTokens"] as const;
Expand Down Expand Up @@ -1406,3 +1446,5 @@ export function resolveAiReviewerPlan(
export function withAdvisoryAiEnv(env: Env, useAdvisory: boolean): Env {
return useAdvisory && env.AI_ADVISORY ? { ...env, AI: env.AI_ADVISORY } : env;
}

export const __selfHostAiInternals = { buildAiUsage };
5 changes: 4 additions & 1 deletion src/services/ai-e2e-test-gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,10 @@ async function record(
actor: input.actor ?? null,
route: "github_app.ai_e2e_test_gen",
// `byok:<provider>` so countByokAiEventsForRepoSince (model LIKE 'byok:%') counts it toward the cap.
model: input.providerKey ? `byok:${input.providerKey.provider}` : E2E_TEST_GEN_MODELS.join("+"),
// Non-BYOK: prefer the REAL model the provider reported (usage.model, populated for self-host CLI/HTTP
// providers including advisory-routed Ollama calls) over the hardcoded fallback label, which otherwise
// misrepresented every advisory-routed call as a legacy Workers-AI model id it never actually ran on.
model: input.providerKey ? `byok:${input.providerKey.provider}` : (usage?.model ?? E2E_TEST_GEN_MODELS.join("+")),
status,
estimatedNeurons,
provider: usage?.provider,
Expand Down
5 changes: 4 additions & 1 deletion src/services/ai-slop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,10 @@ async function record(
actor: input.actor ?? null,
route: "github_app.ai_slop",
// `byok:<provider>` so countByokAiEventsForRepoSince (model LIKE 'byok:%') counts it toward the cap.
model: input.providerKey ? `byok:${input.providerKey.provider}` : WORKERS_SLOP_MODELS.join("+"),
// Non-BYOK: prefer the REAL model the provider reported (usage.model, populated for self-host CLI/HTTP
// providers including advisory-routed Ollama calls) over the hardcoded fallback label, which otherwise
// misrepresented every advisory-routed call as a legacy Workers-AI model id it never actually ran on.
model: input.providerKey ? `byok:${input.providerKey.provider}` : (usage?.model ?? WORKERS_SLOP_MODELS.join("+")),
status,
estimatedNeurons,
provider: usage?.provider,
Expand Down
Loading
Loading