diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index cd7e8a12b..b1b43b522 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -42,7 +42,13 @@ export const dynamic = "force-dynamic"; const overrideSchema = z .object({ provider: z - .enum(["OPENAI", "ANTHROPIC", "LOCAL", "CHATGPT_OAUTH"]) + .enum([ + "OPENAI", + "ANTHROPIC", + "LOCAL", + "OPENAI_COMPATIBLE", + "CHATGPT_OAUTH", + ]) .optional() .nullable(), model: z.string().min(1).max(120).optional().nullable(), @@ -50,6 +56,12 @@ const overrideSchema = z anthropicKey: z.string().min(1).max(500).optional().nullable(), localKey: z.string().min(1).max(500).optional().nullable(), openaiKey: z.string().min(1).max(500).optional().nullable(), + // v1.33.1 (#470) — the OpenAI-compatible gateway's own fields, kept + // separate from `baseUrl` / `openaiKey` so testing an unsaved gateway + // config can never reach the pinned OpenAI arm and vice versa. + compatBaseUrl: z.string().url().max(2048).optional().nullable(), + compatKey: z.string().min(1).max(500).optional().nullable(), + compatModel: z.string().min(1).max(120).optional().nullable(), }) .strict(); diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts index 6f654ae14..4fc45e32a 100644 --- a/src/app/api/user/ai-provider/route.ts +++ b/src/app/api/user/ai-provider/route.ts @@ -10,7 +10,15 @@ import { annotate } from "@/lib/logging/context"; export const dynamic = "force-dynamic"; -const ALLOWED = new Set(["OPENAI", "ANTHROPIC", "LOCAL", "CHATGPT_OAUTH"]); +const ALLOWED = new Set([ + "OPENAI", + "ANTHROPIC", + "LOCAL", + // v1.33.1 (#470) — the OpenAI-compatible gateway (LiteLLM / OpenRouter / + // vLLM). Its base URL, key and model live in their own three columns. + "OPENAI_COMPATIBLE", + "CHATGPT_OAUTH", +]); export const GET = apiHandler(async () => { const { user } = await requireAuth(); @@ -25,6 +33,10 @@ export const GET = apiHandler(async () => { aiAnthropicKeyEncrypted: true, aiLocalKeyEncrypted: true, aiOpenaiKeyEncrypted: true, + // v1.33.1 (#470) — the OpenAI-compatible gateway. + aiCompatBaseUrl: true, + aiCompatKeyEncrypted: true, + aiCompatModel: true, // v1.22 (#89) aiResponseTimeoutSeconds: true, }, @@ -52,6 +64,11 @@ export const GET = apiHandler(async () => { openaiKeyPreview: u?.aiOpenaiKeyEncrypted ? `...${decrypt(u.aiOpenaiKeyEncrypted).slice(-4)}` : null, + // v1.33.1 (#470) — the gateway's own configuration. The key is reported + // as presence only, like every other credential on this surface. + compatBaseUrl: u?.aiCompatBaseUrl ?? null, + compatModel: u?.aiCompatModel ?? null, + hasCompatKey: Boolean(u?.aiCompatKeyEncrypted), // v1.22 (#89) — per-user response timeout, in seconds (null = default). responseTimeoutSeconds: u?.aiResponseTimeoutSeconds ?? null, }); @@ -112,6 +129,46 @@ export const PATCH = apiHandler(async (request: NextRequest) => { } } + // ── v1.33.1 (#470) — the OpenAI-compatible gateway's three fields ── + // Deliberately a separate block from `baseUrl` above: that column is shared + // with LOCAL and is cleared when the provider switches away from it. This + // one belongs to a single provider and to no other, which is what keeps the + // OpenAI key from ever being sendable to a user-supplied host. + if (body.compatBaseUrl !== undefined) { + if (body.compatBaseUrl === null || body.compatBaseUrl === "") { + updates.aiCompatBaseUrl = null; + } else if (typeof body.compatBaseUrl === "string") { + const trimmed = body.compatBaseUrl.trim(); + // Same SSRF floor as the Local provider: a public host always, a + // private one only when the operator allowlisted it. Gateways on a LAN + // are the normal case for LiteLLM / vLLM, so the escape hatch matters + // here as much as it does for Ollama. + if (!isLocalAiHostAllowed(trimmed) && !isPublicUrl(trimmed)) { + return apiError( + "Base URL points to an internal/private host. Ops must allow it on this instance via ALLOW_LOCAL_AI_PRIVATE_HOSTS — set it to the exact host (e.g. litellm.lan) or to true for any private host (intended for a self-hosted gateway).", + 422, + ); + } + updates.aiCompatBaseUrl = trimmed; + } + } + + if (body.compatModel !== undefined) { + if (body.compatModel === null || body.compatModel === "") { + updates.aiCompatModel = null; + } else if (typeof body.compatModel === "string") { + updates.aiCompatModel = body.compatModel.trim() || null; + } + } + + if (body.compatKey !== undefined) { + if (body.compatKey === null || body.compatKey === "") { + updates.aiCompatKeyEncrypted = null; + } else if (typeof body.compatKey === "string") { + updates.aiCompatKeyEncrypted = encrypt(body.compatKey.trim()); + } + } + if (body.anthropicKey !== undefined) { if (body.anthropicKey === null || body.anthropicKey === "") { updates.aiAnthropicKeyEncrypted = null; diff --git a/src/lib/ai/coach/budget.ts b/src/lib/ai/coach/budget.ts index 21aa7d2c5..35c026222 100644 --- a/src/lib/ai/coach/budget.ts +++ b/src/lib/ai/coach/budget.ts @@ -55,7 +55,8 @@ export const USER_PLAN_CAP = 2_000_000; * primary provider is `admin-openai` (the operator's shared API key) OR * `admin-codex` (the operator's shared ChatGPT-subscription account) — both * drain the operator's resources, so the operator-cost cap applies. Every other - * primary (`codex` / `openai` / `anthropic` / `local`) is the user's own + * primary (`codex` / `openai` / `anthropic` / `local` / `openai-compatible`) + * is the user's own * egress, so the generous user-plan cap applies. An empty chain (no provider * resolved) defaults to the operator cap — the conservative side. */ diff --git a/src/lib/ai/json-dialect.ts b/src/lib/ai/json-dialect.ts new file mode 100644 index 000000000..f63a5a481 --- /dev/null +++ b/src/lib/ai/json-dialect.ts @@ -0,0 +1,72 @@ +/** + * Per-endpoint JSON-mode dialect, learned at runtime. + * + * Landed for the Local client in v1.28.28 (#470) and shared with the + * OpenAI-compatible gateway provider in v1.33.1 — both talk to endpoints + * HealthLog does not control, and both need the same answer to the same + * question: does this endpoint accept the standard + * `response_format: { type: "json_object" }` field? + * + * - `"response_format"` (the default): send the standard OpenAI field. + * Strict OpenAI-compatible gateways (LiteLLM, OpenRouter, vLLM) and + * Ollama's own `/v1` shim all accept it. + * - `"none"`: send no structured-output flag at all. Whatever JSON steering + * the caller already put in the prompt is the only steering left. + * (Ollama's NATIVE top-level `format: "json"` never belonged on the + * OpenAI-compatible `/chat/completions` wire — it 400s on strict + * gateways — so the fallback stays minimal rather than resurrecting it.) + * + * The dialect is cached per base URL for the process lifetime: the first + * JSON-mode request that 4xxes with a body referencing `response_format` or + * an unknown parameter flips that endpoint to `"none"` and is retried once + * without the flag. A successful flagged request pins `"response_format"` so + * a later transient 4xx cannot silently degrade the endpoint. + * + * The cache is keyed by base URL, not by provider, so two providers pointed + * at the same gateway learn once between them. + */ + +export type JsonModeDialect = "response_format" | "none"; + +const dialectByBaseUrl = new Map(); + +/** The learned dialect for an endpoint, defaulting to the standard field. */ +export function jsonModeDialectFor(baseUrl: string): JsonModeDialect { + return dialectByBaseUrl.get(baseUrl) ?? "response_format"; +} + +/** True once an endpoint's dialect has actually been observed. */ +export function hasLearnedJsonModeDialect(baseUrl: string): boolean { + return dialectByBaseUrl.has(baseUrl); +} + +/** Record what an endpoint answered to. */ +export function rememberJsonModeDialect( + baseUrl: string, + dialect: JsonModeDialect, +): void { + dialectByBaseUrl.set(baseUrl, dialect); +} + +/** Test hook — clears the learned per-endpoint dialect cache. */ +export function resetJsonModeDialectCache(): void { + dialectByBaseUrl.clear(); +} + +/** + * True when a 4xx error body reads as "this endpoint rejects the + * `response_format` field" — either it names the field outright or it + * complains about an unknown/unexpected parameter. + */ +export function isResponseFormatRejection( + status: number, + bodyExcerpt: string, +): boolean { + if (status < 400 || status >= 500) return false; + return ( + /response_format/i.test(bodyExcerpt) || + /(unknown|unexpected|unrecognized|unsupported|extra)[\s_-]*(parameter|field|argument|property|key)/i.test( + bodyExcerpt, + ) + ); +} diff --git a/src/lib/ai/local-client.ts b/src/lib/ai/local-client.ts index 91fb5c14a..2da3d3119 100644 --- a/src/lib/ai/local-client.ts +++ b/src/lib/ai/local-client.ts @@ -8,6 +8,14 @@ import type { CompletionResult, } from "./types"; import { buildOpenAIMessages, mapFinishReason } from "./openai-wire"; +import { + hasLearnedJsonModeDialect, + isResponseFormatRejection, + jsonModeDialectFor, + rememberJsonModeDialect, + resetJsonModeDialectCache, + type JsonModeDialect, +} from "./json-dialect"; interface LocalClientConfig { apiKey?: string | null; @@ -20,51 +28,18 @@ const STRICT_JSON_PREFIX = /** * v1.28.28 (#470) — per-endpoint JSON-mode dialect, learned at runtime. - * - * - `"response_format"` (the default): send the standard OpenAI - * `response_format: { type: "json_object" }`. Strict OpenAI-compatible - * gateways (LiteLLM, OpenRouter, vLLM) and Ollama's own `/v1` shim all - * accept it. - * - `"none"`: send no structured-output flag at all. The strict-JSON - * instruction prepended to the first user turn remains the only JSON - * steering — which is what this client shipped for years and what local - * model templates respect. (Ollama's NATIVE `format: "json"` field never - * belonged on the OpenAI-compatible `/chat/completions` wire; it was - * tolerated by Ollama but 400s on strict gateways, so the fallback stays - * minimal instead of resurrecting it.) - * - * The dialect is cached per baseUrl for the process lifetime: the first - * JSON-mode request that 4xxes with an error body referencing - * `response_format` / an unknown parameter flips the endpoint to `"none"` - * and is retried once without the flag (self-healing, mirroring the - * google-health dateFilter self-heal posture). Unrelated 4xx errors are - * NOT retried — they surface as the structured error they always were. + * v1.33.1 — the machinery moved to `json-dialect.ts` when the + * OpenAI-compatible gateway provider needed the same learning; the two + * clients share one cache keyed by base URL. The names below stay exported + * from here because this client's tests and call sites were the first + * consumers. */ -type LocalJsonDialect = "response_format" | "none"; -const jsonDialectByBaseUrl = new Map(); +type LocalJsonDialect = JsonModeDialect; /** Test hook — clears the learned per-endpoint dialect cache. */ -export function resetLocalJsonDialectCache(): void { - jsonDialectByBaseUrl.clear(); -} +export const resetLocalJsonDialectCache = resetJsonModeDialectCache; -/** - * True when a 4xx error body reads as "this endpoint rejects the - * `response_format` field" — either it names the field outright or it - * complains about an unknown/unexpected parameter. - */ -export function isResponseFormatRejection( - status: number, - bodyExcerpt: string, -): boolean { - if (status < 400 || status >= 500) return false; - return ( - /response_format/i.test(bodyExcerpt) || - /(unknown|unexpected|unrecognized|unsupported|extra)[\s_-]*(parameter|field|argument|property|key)/i.test( - bodyExcerpt, - ) - ); -} +export { isResponseFormatRejection }; /** * v1.22 (#89) — absolute backstop for the streaming path. The real ceiling is @@ -141,9 +116,8 @@ export class LocalOpenAICompatibleClient implements AIProvider { jsonDialect: LocalJsonDialect; } { const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`; - // v1.28.28 (#470) — per-endpoint JSON dialect (see LocalJsonDialect). - const jsonDialect = - jsonDialectByBaseUrl.get(this.config.baseUrl) ?? "response_format"; + // v1.28.28 (#470) — per-endpoint JSON dialect (see json-dialect.ts). + const jsonDialect = jsonModeDialectFor(this.config.baseUrl); const headers: Record = { "Content-Type": "application/json", @@ -255,7 +229,7 @@ export class LocalOpenAICompatibleClient implements AIProvider { jsonDialect === "response_format" && isResponseFormatRejection(res.status, bodyExcerpt) ) { - jsonDialectByBaseUrl.set(this.config.baseUrl, "none"); + rememberJsonModeDialect(this.config.baseUrl, "none"); annotate({ action: { name: "ai.local.jsonDialect" }, meta: { dialect: "none", httpStatus: res.status }, @@ -279,9 +253,9 @@ export class LocalOpenAICompatibleClient implements AIProvider { if ( params.responseFormat === "json" && jsonDialect === "response_format" && - !jsonDialectByBaseUrl.has(this.config.baseUrl) + !hasLearnedJsonModeDialect(this.config.baseUrl) ) { - jsonDialectByBaseUrl.set(this.config.baseUrl, "response_format"); + rememberJsonModeDialect(this.config.baseUrl, "response_format"); annotate({ action: { name: "ai.local.jsonDialect" }, meta: { dialect: "response_format" }, diff --git a/src/lib/ai/openai-client.ts b/src/lib/ai/openai-client.ts index eb1e140ce..6cccde128 100644 --- a/src/lib/ai/openai-client.ts +++ b/src/lib/ai/openai-client.ts @@ -1,6 +1,14 @@ import { safeFetch } from "@/lib/safe-fetch"; +import { annotate } from "@/lib/logging/context"; import type { AIProvider, CompletionParams, CompletionResult } from "./types"; import { selectOpenAIChatCompletionsCapabilities } from "./openai-capabilities"; +import { + hasLearnedJsonModeDialect, + isResponseFormatRejection, + jsonModeDialectFor, + rememberJsonModeDialect, +} from "./json-dialect"; +import { requirePublicHostFor } from "./local-host-allowlist"; import { buildOpenAIMessages, buildOpenAITools, @@ -11,6 +19,11 @@ import { } from "./openai-wire"; interface OpenAIClientConfig { + /** + * Bearer credential. Empty string means "no bearer" — only the + * `openai-compatible` tag ever constructs that way (a LAN gateway with no + * auth); every OpenAI-keyed call site checks the key's presence first. + */ apiKey: string; model: string; baseUrl: string; @@ -20,11 +33,18 @@ interface OpenAIClientConfig { * was obtained via the token-exchange grant against a ChatGPT * subscription — for billing and observability we want that path * to log as "codex" instead. + * + * v1.33.1 (#470) — `openai-compatible` is the user-configured gateway + * (LiteLLM / OpenRouter / vLLM). It is the ONLY tag whose `baseUrl` is not + * a HealthLog-pinned constant, and the two behaviours below hang off it: + * the operator's private-host allowlist may apply, and the JSON-mode + * dialect is learned per endpoint instead of assumed. Every other tag + * keeps the pinned `api.openai.com` posture unchanged. */ - providerType?: "admin-key" | "codex"; + providerType?: OpenAIProviderType; } -type OpenAIProviderType = "admin-key" | "codex"; +type OpenAIProviderType = "admin-key" | "codex" | "openai-compatible"; export class OpenAIClient implements AIProvider { readonly type: OpenAIProviderType; @@ -35,6 +55,15 @@ export class OpenAIClient implements AIProvider { this.type = config.providerType ?? "admin-key"; } + /** + * True only for the user-configured gateway. Read it as "the base URL came + * from a person, not from this repository" — every behaviour that must not + * change for `api.openai.com` is gated on it. + */ + private get isGateway(): boolean { + return this.type === "openai-compatible"; + } + async generateCompletion( params: CompletionParams, ): Promise { @@ -61,6 +90,14 @@ export class OpenAIClient implements AIProvider { // therefore stay out of JSON mode, and every tool round is non-JSON by // construction. Insight/extraction callers opt in with `responseFormat:"json"`. const useJsonFormat = params.responseFormat === "json" && !hasTools; + // v1.33.1 (#470) — a gateway may reject the standard `response_format` + // field. Learn that per endpoint (shared cache with the Local client) and + // retry once without the flag below. `api.openai.com` defines the field, + // so the pinned tags never consult the dialect and never retry. + const jsonDialect = this.isGateway + ? jsonModeDialectFor(this.config.baseUrl) + : "response_format"; + const sendJsonFormat = useJsonFormat && jsonDialect === "response_format"; const capabilities = selectOpenAIChatCompletionsCapabilities( this.config.baseUrl, this.config.model, @@ -74,19 +111,27 @@ export class OpenAIClient implements AIProvider { } : { [capabilities.tokenBudgetField]: tokenBudget }; + // A gateway that needs no bearer (a LAN LiteLLM without a master key) + // gets no `Authorization` header at all rather than an empty one — an + // empty bearer is a 401 on some proxies. Every OpenAI-keyed construction + // site checks the key's presence, so this branch is gateway-only in + // practice. + const headers: Record = { + "Content-Type": "application/json", + }; + const trimmedKey = this.config.apiKey?.trim(); + if (trimmedKey) headers.Authorization = `Bearer ${trimmedKey}`; + const res = await safeFetch( url, { method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.config.apiKey}`, - }, + headers, body: JSON.stringify({ model: this.config.model, messages, ...capabilityParams, - ...(useJsonFormat + ...(sendJsonFormat ? { response_format: { type: "json_object" } } : {}), ...(tools ? { tools } : {}), @@ -101,9 +146,16 @@ export class OpenAIClient implements AIProvider { // v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so // a mid-generation abort tears the upstream call down early. // v1.21.5 — honour the caller's per-request timeout override; default 60 s. + // v1.33.1 (#470) — the gateway tag reuses the Local provider's host + // policy verbatim: public hosts always; a private host only when the + // operator allowlisted it via ALLOW_LOCAL_AI_PRIVATE_HOSTS. Every other + // tag stays pinned to `requirePublicHost: true` — `api.openai.com` is + // public and there is nothing to opt into. { timeoutMs: params.timeoutMs ?? 60_000, - requirePublicHost: true, + requirePublicHost: this.isGateway + ? requirePublicHostFor(this.config.baseUrl) + : true, signal: params.signal, }, ); @@ -118,19 +170,54 @@ export class OpenAIClient implements AIProvider { .slice(0, 500) .replace(/sk-[A-Za-z0-9_-]{8,}/g, "sk-***redacted***") .replace(/Bearer\s+[A-Za-z0-9_.-]+/gi, "Bearer ***redacted***"); - const err = new Error(`OpenAI request failed (${res.status})`); + // v1.33.1 (#470) — dialect self-heal, gateway only. A 4xx whose body + // names `response_format` (or an unknown parameter) means this endpoint + // rejects the standard JSON flag: learn the no-flag dialect for the base + // URL and retry ONCE without it. The recursion terminates because the + // cached dialect is now "none". Unrelated 4xx/5xx errors are not retried + // and surface as the structured error they always were. + if ( + this.isGateway && + sendJsonFormat && + isResponseFormatRejection(res.status, bodyExcerpt) + ) { + rememberJsonModeDialect(this.config.baseUrl, "none"); + annotate({ + action: { name: "ai.compat.jsonDialect" }, + meta: { dialect: "none", httpStatus: res.status }, + }); + return this.generateCompletion(params); + } + const err = new Error( + `${this.isGateway ? "OpenAI-compatible gateway" : "OpenAI"} request failed (${res.status})`, + ); // Keep the body in a structured field rather than the message so // Error.message stays short and the excerpt lands in dedicated log // fields (bodyExcerpt) that can be filtered/truncated centrally. Object.assign(err, { httpStatus: res.status, - upstream: "openai", + upstream: this.isGateway ? "openai-compatible" : "openai", model: this.config.model, bodyExcerpt, }); throw err; } + // v1.33.1 (#470) — the standard `response_format` request succeeded: pin + // the dialect for this endpoint so a later transient 4xx can never + // silently degrade it. + if ( + this.isGateway && + sendJsonFormat && + !hasLearnedJsonModeDialect(this.config.baseUrl) + ) { + rememberJsonModeDialect(this.config.baseUrl, "response_format"); + annotate({ + action: { name: "ai.compat.jsonDialect" }, + meta: { dialect: "response_format" }, + }); + } + const json = (await res.json()) as OpenAIResponseJson; const choice = json.choices?.[0]; let content = choice?.message?.content; @@ -163,11 +250,13 @@ export class OpenAIClient implements AIProvider { // observability. The cascade is unchanged: `isHardProviderFailure` // already treats `status <= 0` as a hard failure and walks to the next // provider. - const err = new Error("OpenAI returned empty content"); + const err = new Error( + `${this.isGateway ? "OpenAI-compatible gateway" : "OpenAI"} returned empty content`, + ); Object.assign(err, { httpStatus: 0, kind: "empty_response", - upstream: "openai", + upstream: this.isGateway ? "openai-compatible" : "openai", }); throw err; } diff --git a/src/lib/ai/provider-chain.ts b/src/lib/ai/provider-chain.ts index 977646984..6a92ad27a 100644 --- a/src/lib/ai/provider-chain.ts +++ b/src/lib/ai/provider-chain.ts @@ -32,6 +32,15 @@ * (see `resolveProviderChain`), so a persisted `admin-codex` entry never * resolves on its own. * + * `openai-compatible` (v1.33.1, #470) is the user-configured gateway — + * LiteLLM, OpenRouter, vLLM, or any other endpoint on the + * `/v1/chat/completions` wire. It is offered in the chain editor but is NOT + * part of `PROVIDER_CHAIN_DEFAULT`: it resolves only against the base URL the + * user saved, so putting it in the default chain would put an entry there + * that can never resolve for anyone who has not configured it. It reads its + * own three columns and never the OpenAI ones — the OpenAI arm's pinned base + * URL stays pinned. + * * Distinct from `ProviderType` (in `types.ts`) because that enum tags * the **runtime** provider instance — which has no notion of "admin * OpenAI" vs "user OpenAI" at the wire level (both are @@ -43,6 +52,7 @@ export const PROVIDER_CHAIN_TYPES = [ "openai", "anthropic", "local", + "openai-compatible", "admin-openai", "admin-codex", ] as const; @@ -65,6 +75,10 @@ export interface ProviderChainEntry { * 4. local — self-hosted Ollama / LM Studio. * 5. admin-openai — operator's shared key, last-ditch so a user with * no personal config still sees insights. + * + * `openai-compatible` is deliberately absent: it resolves only against a + * base URL the user saved, so a default entry would be dead weight for + * everyone who never configures one. Users add it in the chain editor. */ export const PROVIDER_CHAIN_DEFAULT: readonly ProviderChainEntry[] = [ { providerType: "codex", priority: 1, enabled: true }, diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index 4b4552a73..a36cf735b 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -36,8 +36,48 @@ type UserAIRow = { aiAnthropicKeyEncrypted: string | null; aiLocalKeyEncrypted: string | null; aiOpenaiKeyEncrypted: string | null; + aiCompatBaseUrl: string | null; + aiCompatKeyEncrypted: string | null; + aiCompatModel: string | null; }; +/** + * v1.33.1 (#470) — build the OpenAI-compatible gateway client from the user's + * three dedicated columns. The signature names every column this provider is + * allowed to read: the OpenAI key and `aiBaseUrl` are absent on purpose, so a + * future edit that wants to hand it either has to widen this shape first and + * answer for it. + * + * Returns null when the gateway cannot be addressed — no base URL, or no + * model anywhere. An unconfigured entry is SKIPPED by the chain runner rather + * than erroring, exactly like an `anthropic` entry with no key. + * + * Model resolution: `aiCompatModel` is the override; absent, the shared + * `aiModel` applies (a gateway usually routes the same model name the user + * already types for their other providers). Nothing is invented — with + * neither set there is no model to send, so the entry does not resolve. + * + * The key is OPTIONAL: a LAN LiteLLM without a master key needs no bearer, + * and the client omits the header entirely for an empty string. The base URL + * passed the SSRF floor at write time (`/api/user/ai-provider`); the client + * re-applies the Local provider's host policy at call time. + */ +function buildCompatProvider(row: { + aiModel: string | null; + aiCompatBaseUrl: string | null; + aiCompatKeyEncrypted: string | null; + aiCompatModel: string | null; +}): AIProvider | null { + const model = row.aiCompatModel?.trim() || row.aiModel?.trim() || null; + if (!row.aiCompatBaseUrl || !model) return null; + return new OpenAIClient({ + apiKey: row.aiCompatKeyEncrypted ? decrypt(row.aiCompatKeyEncrypted) : "", + model, + baseUrl: row.aiCompatBaseUrl, + providerType: "openai-compatible", + }); +} + /** * Build a provider from a user-level config row. Returns null if the row does * not select a usable per-user provider (caller falls back to admin/codex). @@ -68,6 +108,11 @@ function buildUserProvider(row: UserAIRow): AIProvider | null { baseUrl: row.aiBaseUrl, }); } + case "OPENAI_COMPATIBLE": { + // v1.33.1 (#470) — the gateway provider. Reads its own three columns + // and nothing else; the OpenAI arm below stays pinned. + return buildCompatProvider(row); + } case "OPENAI": { // v1.4.3: user-level OpenAI key gets first crack — only fall back // to the admin key if the user hasn't supplied their own. The @@ -364,6 +409,9 @@ export async function resolveProvider(userId: string): Promise { aiAnthropicKeyEncrypted: true, aiLocalKeyEncrypted: true, aiOpenaiKeyEncrypted: true, + aiCompatBaseUrl: true, + aiCompatKeyEncrypted: true, + aiCompatModel: true, }, }); @@ -420,6 +468,9 @@ export async function resolveProviderChain( aiAnthropicKeyEncrypted: true, aiLocalKeyEncrypted: true, aiOpenaiKeyEncrypted: true, + aiCompatBaseUrl: true, + aiCompatKeyEncrypted: true, + aiCompatModel: true, aiProviderChain: true, useCentralCodex: true, }, @@ -468,6 +519,9 @@ export interface ProviderCredentialRow { aiLocalKeyEncrypted: string | null; aiOpenaiKeyEncrypted: string | null; aiBaseUrl: string | null; + aiModel: string | null; + aiCompatBaseUrl: string | null; + aiCompatModel: string | null; codexConnectionStatus: string | null; codexAccessTokenEncrypted: string | null; codexRefreshTokenEncrypted: string | null; @@ -514,6 +568,13 @@ export function userRowHasProviderCredential( case "local": if (row.aiBaseUrl) return true; break; + case "openai-compatible": + // Presence mirrors `buildCompatProvider`: a gateway needs an address + // AND a model name; the key is optional. + if (row.aiCompatBaseUrl && (row.aiCompatModel || row.aiModel)) { + return true; + } + break; case "admin-openai": if (adminKeyConfigured) return true; break; @@ -525,6 +586,13 @@ export function userRowHasProviderCredential( const choice = row.aiProvider?.toUpperCase(); if (choice === "ANTHROPIC" && row.aiAnthropicKeyEncrypted) return true; if (choice === "LOCAL" && row.aiBaseUrl) return true; + if ( + choice === "OPENAI_COMPATIBLE" && + row.aiCompatBaseUrl && + (row.aiCompatModel || row.aiModel) + ) { + return true; + } if (choice === "OPENAI" && row.aiOpenaiKeyEncrypted) return true; if ((choice === "CHATGPT_OAUTH" || !choice) && codexConnected) return true; return adminKeyConfigured; @@ -549,6 +617,9 @@ export async function hasAnyConfiguredProvider( aiLocalKeyEncrypted: true, aiOpenaiKeyEncrypted: true, aiBaseUrl: true, + aiCompatBaseUrl: true, + aiCompatModel: true, + aiModel: true, codexConnectionStatus: true, codexAccessTokenEncrypted: true, codexRefreshTokenEncrypted: true, @@ -637,6 +708,14 @@ function resolveManagedByFromRow( case "local": if (row.aiBaseUrl) return "local"; break; + case "openai-compatible": + // The gateway is the user's own egress to an endpoint they chose, + // like a BYO key — not the operator's, and not presumed on-host + // (LiteLLM / OpenRouter are usually remote). + if (row.aiCompatBaseUrl && (row.aiCompatModel || row.aiModel)) { + return "user"; + } + break; case "admin-openai": if (adminKeyConfigured) return "server"; break; @@ -648,6 +727,13 @@ function resolveManagedByFromRow( const choice = row.aiProvider?.toUpperCase(); if (choice === "ANTHROPIC" && row.aiAnthropicKeyEncrypted) return "user"; if (choice === "LOCAL" && row.aiBaseUrl) return "local"; + if ( + choice === "OPENAI_COMPATIBLE" && + row.aiCompatBaseUrl && + (row.aiCompatModel || row.aiModel) + ) { + return "user"; + } if (choice === "OPENAI" && row.aiOpenaiKeyEncrypted) return "user"; if ((choice === "CHATGPT_OAUTH" || !choice) && codexConnected) return "user"; return adminKeyConfigured ? "server" : null; @@ -672,6 +758,9 @@ export async function resolveProviderAvailability( aiLocalKeyEncrypted: true, aiOpenaiKeyEncrypted: true, aiBaseUrl: true, + aiCompatBaseUrl: true, + aiCompatModel: true, + aiModel: true, codexConnectionStatus: true, codexAccessTokenEncrypted: true, codexRefreshTokenEncrypted: true, @@ -715,6 +804,9 @@ async function resolveProviderForType( aiOpenaiKeyEncrypted: string | null; aiBaseUrl: string | null; aiModel: string | null; + aiCompatBaseUrl: string | null; + aiCompatKeyEncrypted: string | null; + aiCompatModel: string | null; } | null; }, ): Promise { @@ -749,6 +841,13 @@ async function resolveProviderForType( baseUrl: ctx.userRow.aiBaseUrl, }); } + case "openai-compatible": { + // v1.33.1 (#470) — the user's gateway. Note what is NOT read here: + // `aiOpenaiKeyEncrypted` and `aiBaseUrl`. The gateway gets the + // credential the user issued for it, or none at all. + if (!ctx.userRow) return null; + return buildCompatProvider(ctx.userRow); + } case "admin-openai": { const admin = await resolveAdminProvider(); return admin.type === "none" ? null : admin; @@ -776,6 +875,13 @@ export type AITestOverride = { anthropicKey?: string | null; localKey?: string | null; openaiKey?: string | null; + // v1.33.1 (#470) — the gateway's own fields. Dedicated rather than shared + // with `baseUrl` / `openaiKey` for the same reason the columns are: the + // pinned OpenAI arm must not be able to see a user-supplied host, and the + // gateway must not be able to see the OpenAI key. + compatBaseUrl?: string | null; + compatKey?: string | null; + compatModel?: string | null; }; export class AITestConfigError extends Error { @@ -806,6 +912,9 @@ export async function resolveProviderForTest( aiAnthropicKeyEncrypted: true, aiLocalKeyEncrypted: true, aiOpenaiKeyEncrypted: true, + aiCompatBaseUrl: true, + aiCompatKeyEncrypted: true, + aiCompatModel: true, }, }); @@ -877,6 +986,52 @@ export async function resolveProviderForTest( baseUrl, }); } + case "OPENAI_COMPATIBLE": { + // v1.33.1 (#470) — the gateway. Reads `aiCompatBaseUrl`, never the + // merged `baseUrl` above (which can carry a stale LOCAL URL) and never + // the OpenAI key. Same host policy as LOCAL: public always, private + // only when the operator allowlisted it. + const compatBaseUrl = ( + override.compatBaseUrl ?? + stored?.aiCompatBaseUrl ?? + "" + ) + .toString() + .trim(); + if (!compatBaseUrl) { + throw new AITestConfigError( + 422, + "OpenAI-compatible provider requires a base URL", + ); + } + if (!isLocalAiHostAllowed(compatBaseUrl) && !isPublicUrl(compatBaseUrl)) { + throw new AITestConfigError( + 422, + "Base URL points to an internal/private host", + ); + } + const compatModel = + (override.compatModel ?? stored?.aiCompatModel ?? "") + .toString() + .trim() || model; + if (!compatModel) { + throw new AITestConfigError( + 422, + "OpenAI-compatible provider requires a model name", + ); + } + const apiKey = + override.compatKey?.trim() || + (stored?.aiCompatKeyEncrypted + ? decrypt(stored.aiCompatKeyEncrypted) + : ""); + return new OpenAIClient({ + apiKey, + model: compatModel, + baseUrl: compatBaseUrl, + providerType: "openai-compatible", + }); + } case "CHATGPT_OAUTH": { const codex = await resolveCodexProvider(userId); if (codex) return codex; diff --git a/src/lib/ai/types.ts b/src/lib/ai/types.ts index cb33c91aa..67681677f 100644 --- a/src/lib/ai/types.ts +++ b/src/lib/ai/types.ts @@ -102,8 +102,13 @@ export type InsightResult = z.infer; // ─── Provider Types ──────────────────────────────────────── +// v1.33.1 (#470) — `openai-compatible` is the user-configured gateway +// (LiteLLM / OpenRouter / vLLM). It runs the OpenAI client against a base URL +// the user owns, so it is a distinct runtime tag from `admin-key`: logs, +// analytics and the budget classifier must be able to tell "the operator's +// OpenAI key" from "an endpoint someone pointed us at". export type ProviderType = - "codex" | "admin-key" | "anthropic" | "local" | "none"; + "codex" | "admin-key" | "openai-compatible" | "anthropic" | "local" | "none"; export interface AIProvider { type: ProviderType; diff --git a/src/lib/ai/vision-capability.ts b/src/lib/ai/vision-capability.ts index 673e90c64..515e99184 100644 --- a/src/lib/ai/vision-capability.ts +++ b/src/lib/ai/vision-capability.ts @@ -114,6 +114,7 @@ export type VisionProviderType = | "admin-openai" | "admin-key" | "local" + | "openai-compatible" | "codex" | "admin-codex" | "none"; @@ -127,6 +128,11 @@ export type VisionProviderType = * configuring a self-hosted model and we cannot sniff * its capabilities; a non-vision local model surfaces * a clear extract failure rather than a silent gate. + * - openai-compatible → same trust-by-default: a gateway routes whatever + * model name its operator aliased, so the OpenAI + * allowlist would reject perfectly capable models + * (and accept incapable ones) on the strength of a + * string the gateway owner picked. * - codex → vision when the resolved codex slug is a * multimodal model (GPT-5.x / gpt-4o-class). Image * input rides the ChatGPT plan — no API key. A @@ -145,6 +151,7 @@ export function supportsVisionForConfig( case "admin-key": return model ? openaiSupportsVision(model) : false; case "local": + case "openai-compatible": return true; case "codex": // The operator-shared central Codex runs the same `codex/responses` wire on diff --git a/src/lib/documents/provider-order.ts b/src/lib/documents/provider-order.ts index 8d036425b..388e51edc 100644 --- a/src/lib/documents/provider-order.ts +++ b/src/lib/documents/provider-order.ts @@ -40,7 +40,8 @@ import type { /** * Preference rank for a provider when the payload is a DOCUMENT. Lower wins. * Local keeps the document on the machine (rank 0); BYOK no-train API keys are - * next (rank 1); the operator's shared no-train key follows (rank 2); the + * next (rank 1, including the user's own OpenAI-compatible gateway); the + * operator's shared no-train key follows (rank 2); the * ChatGPT-subscription OAuth paths are LAST (rank 3) — the user's own `codex` * AND the operator's shared `admin-codex`, both train on consumer content by * default and cannot be verified opted-out from here. @@ -52,6 +53,11 @@ function documentProviderRank(providerType: string): number { case "openai": case "anthropic": case "admin-key": + // The user's own gateway (LiteLLM / OpenRouter / vLLM) is a BYO endpoint + // under their own contract. It may well be on their own network, but + // HealthLog cannot tell that from the URL, so it ranks with the BYO API + // keys rather than with `local` — the conservative side. + case "openai-compatible": return 1; case "admin-openai": return 2; diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index efed94e02..bd4f82344 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -814,6 +814,9 @@ export async function runCoachNudgeTick( aiLocalKeyEncrypted: true, aiOpenaiKeyEncrypted: true, aiBaseUrl: true, + aiModel: true, + aiCompatBaseUrl: true, + aiCompatModel: true, codexConnectionStatus: true, codexAccessTokenEncrypted: true, codexRefreshTokenEncrypted: true, diff --git a/src/lib/jobs/status-cron-candidates.ts b/src/lib/jobs/status-cron-candidates.ts index 55a5d74eb..a32c41843 100644 --- a/src/lib/jobs/status-cron-candidates.ts +++ b/src/lib/jobs/status-cron-candidates.ts @@ -67,6 +67,9 @@ export async function findStatusCronCandidates( aiLocalKeyEncrypted: true, aiOpenaiKeyEncrypted: true, aiBaseUrl: true, + aiModel: true, + aiCompatBaseUrl: true, + aiCompatModel: true, codexConnectionStatus: true, codexAccessTokenEncrypted: true, codexRefreshTokenEncrypted: true,