mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(ai): resolve an OpenAI-compatible gateway as its own provider
The chain gains a seventh entry. It builds the OpenAI client against the base URL the user saved, with the bearer they saved for it and the model they named, and it reads none of the OpenAI columns — the pinned api.openai.com arm stays pinned. Host policy is the Local provider's, unchanged: a public host always, a private one only where the operator allowlisted it, which is what a LAN LiteLLM or vLLM needs. Egress keeps riding safeFetch. The JSON-mode dialect the Local client learns per endpoint moves into a shared module and now serves both clients, so a gateway that rejects response_format is detected once and retried without it. The entry stays out of the default chain: it can only resolve against a base URL somebody configured.
This commit is contained in:
1 parent
d11019aebc
commit
0c7059804b
13 files changed
+462
-64
No files matched your search
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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<string, JsonModeDialect>();
|
||||
|
||||
/** 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,
|
||||
)
|
||||
);
|
||||
}
|
||||
+21
-47
@@ -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<string, LocalJsonDialect>();
|
||||
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<string, string> = {
|
||||
"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" },
|
||||
|
||||
+101
-12
@@ -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<CompletionResult> {
|
||||
@@ -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<string, string> = {
|
||||
"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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<AIProvider> {
|
||||
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<AIProvider | null> {
|
||||
@@ -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;
|
||||
|
||||
+6
-1
@@ -102,8 +102,13 @@ export type InsightResult = z.infer<typeof insightResultSchema>;
|
||||
|
||||
// ─── 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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user