diff --git a/src/selfhost/ai.ts b/src/selfhost/ai.ts index 88254df71..8aa076351 100644 --- a/src/selfhost/ai.ts +++ b/src/selfhost/ai.ts @@ -609,6 +609,25 @@ function buildAiUsage(fields: { return usage; } +/** Classify an AI_EMBED/AI_VISION/AI_ADVISORY base URL into `createOpenAiCompatibleAi`'s existing + * `providerName` set (#ai-usage-provider-attribution): unlike the main review chain (an explicit + * `AI_PROVIDER` selector), these auxiliary bindings accept ANY openai-compatible endpoint with no provider + * knob of their own, so the configured base URL is the only signal available for attributing `ai_usage_events` + * rows to a real provider instead of leaving them permanently unattributed. Recognizes the bundled + * docker-compose `ollama` service and OpenAI's own endpoint; anything else (vLLM, LM Studio, a custom + * hostname) is the honest generic "openai-compatible" bucket rather than a guessed, possibly-wrong label. */ +export function providerNameFromBaseUrl(baseUrl: string | undefined): "ollama" | "openai" | "openai-compatible" { + let hostname = ""; + try { + hostname = baseUrl ? new URL(baseUrl).hostname : ""; + } catch { + return "openai-compatible"; + } + if (hostname === "api.openai.com") return "openai"; + if (hostname.includes("ollama")) return "ollama"; + return "openai-compatible"; +} + 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; diff --git a/src/server.ts b/src/server.ts index 754c88547..a263373cf 100644 --- a/src/server.ts +++ b/src/server.ts @@ -20,6 +20,7 @@ import { createSelfHostAi, isAiProviderHealthy, markAiProviderUnhealthyAtBoot, + providerNameFromBaseUrl, resolveAiReviewerPlan, resolveProviderNames, resolveRequiredCliProviders, @@ -471,6 +472,7 @@ async function main(): Promise { baseUrl: process.env.AI_EMBED_BASE_URL, apiKey: process.env.AI_EMBED_API_KEY ?? process.env.OPENAI_API_KEY, embedModel: process.env.AI_EMBED_MODEL, + providerName: providerNameFromBaseUrl(process.env.AI_EMBED_BASE_URL), }) : undefined; if (embedAi) @@ -491,6 +493,7 @@ async function main(): Promise { baseUrl: process.env.AI_VISION_BASE_URL, apiKey: process.env.AI_VISION_API_KEY ?? process.env.OPENAI_API_KEY, model: process.env.AI_VISION_MODEL, + providerName: providerNameFromBaseUrl(process.env.AI_VISION_BASE_URL), }) : undefined; if (visionAi) @@ -513,6 +516,7 @@ async function main(): Promise { baseUrl: process.env.AI_ADVISORY_BASE_URL, apiKey: process.env.AI_ADVISORY_API_KEY, model: process.env.AI_ADVISORY_MODEL, + providerName: providerNameFromBaseUrl(process.env.AI_ADVISORY_BASE_URL), }) : undefined; if (advisoryAi) diff --git a/test/unit/selfhost-ai.test.ts b/test/unit/selfhost-ai.test.ts index befaaed2a..2d325c85d 100644 --- a/test/unit/selfhost-ai.test.ts +++ b/test/unit/selfhost-ai.test.ts @@ -2,7 +2,7 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from " import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { assertNoLegacySharedAiEnv, buildProvider, claudeErrorStatus, codexErrorFromStdout, createAnthropicAi, createChainAi, createClaudeCodeAi, createCodexAi, createOpenAiCompatibleAi, createSelfHostAi, extractCliText, extractCliUsage, isAiProviderHealthy, markAiProviderUnhealthyAtBoot, resetAiProviderCircuitBreakerForTest, resetAiProviderHealthForTest, resolveAiReviewerPlan, resolveClaudeCliTimeoutMs, resolveClaudeFirstOutputTimeoutMs, resolveCodexAuthPath, resolveCodexCliTimeoutMs, resolveCodexEffort, resolveCodexFirstOutputTimeoutMs, resolveEffort, resolveModel, resolveProviderNames, resolveRequiredCliProviders, resolveSubscriptionCliPath, redactSecrets, routeProviders, shouldMarkAiProviderUnhealthyAtBoot, subscriptionCliEnv, withAdvisoryAiEnv, __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, subscriptionCliEnv, withAdvisoryAiEnv, __selfHostAiInternals } from "../../src/selfhost/ai"; import { labelSelfHostReviewerModel, labelSelfHostReviewerModels } from "../../src/selfhost/ai-config"; import { renderMetrics, resetMetrics } from "../../src/selfhost/metrics"; @@ -215,6 +215,15 @@ describe("createOpenAiCompatibleAi (#979)", () => { expect(out).toEqual({ data: [[0.1]], usage: { model: "bge-m3", inputTokens: 7, totalTokens: 7 } }); }); + it("providerNameFromBaseUrl classifies the bundled Ollama service, OpenAI's endpoint, and any other host generically (#ai-usage-provider-attribution)", () => { + expect(providerNameFromBaseUrl("http://ollama:11434/v1")).toBe("ollama"); + expect(providerNameFromBaseUrl("https://my-ollama-box.internal:11434/v1")).toBe("ollama"); + expect(providerNameFromBaseUrl("https://api.openai.com/v1")).toBe("openai"); + expect(providerNameFromBaseUrl("https://my-vllm-box.internal/v1")).toBe("openai-compatible"); + expect(providerNameFromBaseUrl(undefined)).toBe("openai-compatible"); + expect(providerNameFromBaseUrl("not a url")).toBe("openai-compatible"); + }); + it("buildAiUsage omits every undefined field and includes every defined one (direct unit test — costUsd/effort/an-undefined-model are never both exercised through the 3 real call sites)", () => { const { buildAiUsage } = __selfHostAiInternals; expect(buildAiUsage({})).toEqual({});