Files
HealthLog/src/lib/ai/json-dialect.ts
T
Marc-André Bombeck 0c7059804b 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.
2026-07-26 22:27:48 +02:00

73 lines
2.8 KiB
TypeScript

/**
* 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,
)
);
}