mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge pull request #1305 from MBombeck/release/v1.46.5
v1.46.5: Azure OpenAI reasoning deployments
This commit is contained in:
13 files changed
+460
-22
No files matched your search
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## [1.46.5] — 2026-10-30
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Azure OpenAI reasoning deployments answer through the gateway.** An
|
||||
OpenAI-compatible gateway pointed at an Azure OpenAI v1 endpoint sent
|
||||
`max_tokens` and `temperature`, and a reasoning deployment (gpt-5 on,
|
||||
the o-series) refused the request with a 400. A deployment named after
|
||||
its model now gets `max_completion_tokens` and no sampling controls
|
||||
from the first request, as on `api.openai.com`. A deployment with a
|
||||
name of its own, or any other gateway that refuses `max_tokens` the
|
||||
same way, is asked once more with `max_completion_tokens` and keeps
|
||||
that for the endpoint and model. GPT-4 deployments and other gateways
|
||||
keep the wire they had. Reported in #1304.
|
||||
|
||||
## [1.46.4] — 2026-10-30
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.46.4
|
||||
version: 1.46.5
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
@@ -216,6 +216,18 @@ JSON surfaces send the standard
|
||||
the field is detected on the first refusal and retried without it,
|
||||
so strict gateways and older local servers both work unmodified.
|
||||
|
||||
**Azure OpenAI.** Point the gateway at the resource's v1 endpoint,
|
||||
`https://<resource>.openai.azure.com/openai/v1`, and put the
|
||||
deployment name in Model. A reasoning model (gpt-5 on, the o-series)
|
||||
takes `max_completion_tokens` and no `temperature`, the same as on
|
||||
`api.openai.com`. HealthLog sends that from the first request when the
|
||||
deployment is named after its model (`gpt-6-luna-eu`). A deployment
|
||||
with a name of its own (`prod-chat`) gets the usual `max_tokens`
|
||||
once; Azure refuses it, and the request goes out again with
|
||||
`max_completion_tokens`. HealthLog remembers that for the endpoint and
|
||||
deployment until the container restarts. Any other gateway that
|
||||
refuses `max_tokens` the same way is handled the same way.
|
||||
|
||||
**When the gateway refuses.** OpenRouter answers a refused generation
|
||||
with HTTP 200 and the reason inside the body, so every refusal used to
|
||||
reach the provider health card as an empty reply with no status. It now
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.46.4",
|
||||
"version": "1.46.5",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.46.4";
|
||||
/* @sw-version-fallback */ "v1.46.5";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { resetJsonModeDialectCache } from "../json-dialect";
|
||||
import { OpenAIClient } from "../openai-client";
|
||||
import {
|
||||
isAzureOpenAIV1Endpoint,
|
||||
isMaxTokensRejection,
|
||||
resetMaxTokensRefusals,
|
||||
selectOpenAIChatCompletionsCapabilities,
|
||||
} from "../openai-capabilities";
|
||||
import { resetReasoningDialectCache } from "../reasoning/dialect-cache";
|
||||
import { singleUserTurn } from "../types";
|
||||
|
||||
/**
|
||||
* #1304 — an OpenAI-compatible gateway entry pointed straight at an Azure
|
||||
* OpenAI v1 endpoint. A reasoning deployment refuses `max_tokens` with a 400;
|
||||
* the request has to carry `max_completion_tokens` and no sampling controls,
|
||||
* as it does on `api.openai.com`. Every other gateway keeps the legacy wire.
|
||||
*/
|
||||
|
||||
vi.mock("undici", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("undici")>();
|
||||
return {
|
||||
...actual,
|
||||
fetch: (input: unknown, init?: unknown) =>
|
||||
(globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)(
|
||||
input,
|
||||
init,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
const AZURE = "https://example-resource.openai.azure.com/openai/v1";
|
||||
|
||||
// The upstream error the report quotes, verbatim.
|
||||
const AZURE_REFUSAL = JSON.stringify({
|
||||
error: {
|
||||
message:
|
||||
"Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead.",
|
||||
type: "invalid_request_error",
|
||||
param: "max_tokens",
|
||||
code: "unsupported_parameter",
|
||||
},
|
||||
});
|
||||
|
||||
function ok() {
|
||||
return {
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [
|
||||
{ message: { content: '{"ok":true}' }, finish_reason: "stop" },
|
||||
],
|
||||
usage: { total_tokens: 5 },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function refused(status: number, body: string) {
|
||||
return { ok: false, status, text: () => Promise.resolve(body) };
|
||||
}
|
||||
|
||||
function gateway(model: string, baseUrl = AZURE) {
|
||||
return new OpenAIClient({
|
||||
apiKey: "k",
|
||||
model,
|
||||
baseUrl,
|
||||
providerType: "openai-compatible",
|
||||
});
|
||||
}
|
||||
|
||||
const turn = (responseFormat?: "json") =>
|
||||
singleUserTurn({ system: "s", user: "u", responseFormat });
|
||||
|
||||
function bodyOf(mock: ReturnType<typeof vi.fn>, call = 0) {
|
||||
return JSON.parse(mock.mock.calls[call][1].body);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
resetJsonModeDialectCache();
|
||||
resetReasoningDialectCache();
|
||||
resetMaxTokensRefusals();
|
||||
});
|
||||
|
||||
describe("Azure OpenAI v1 endpoint", () => {
|
||||
it.each([
|
||||
AZURE,
|
||||
`${AZURE}/`,
|
||||
"https://example-resource.cognitiveservices.azure.com/openai/v1",
|
||||
"https://example-resource.services.ai.azure.com/openai/v1",
|
||||
])("recognises %s", (url) => {
|
||||
expect(isAzureOpenAIV1Endpoint(url)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"http://example-resource.openai.azure.com/openai/v1",
|
||||
"https://example-resource.openai.azure.com.example.com/openai/v1",
|
||||
"https://openai.azure.com/openai/v1",
|
||||
"https://example-resource.openai.azure.com/openai/deployments/x",
|
||||
"https://example-resource.openai.azure.com:8443/openai/v1",
|
||||
`${AZURE}?api-version=preview`,
|
||||
"https://litellm.example.com/v1",
|
||||
"not a url",
|
||||
])("does not take %s for one", (url) => {
|
||||
expect(isAzureOpenAIV1Endpoint(url)).toBe(false);
|
||||
});
|
||||
|
||||
it("sends a reasoning deployment max_completion_tokens and no temperature", async () => {
|
||||
const mock = vi.fn().mockResolvedValue(ok());
|
||||
vi.stubGlobal("fetch", mock);
|
||||
await gateway("gpt-6-luna-example").generateCompletion(turn());
|
||||
expect(mock).toHaveBeenCalledTimes(1);
|
||||
const body = bodyOf(mock);
|
||||
expect(body.max_completion_tokens).toBeGreaterThan(0);
|
||||
expect(body).not.toHaveProperty("max_tokens");
|
||||
expect(body).not.toHaveProperty("temperature");
|
||||
expect(body).not.toHaveProperty("seed");
|
||||
});
|
||||
|
||||
it("keeps the legacy wire for a GPT-4 deployment", async () => {
|
||||
const mock = vi.fn().mockResolvedValue(ok());
|
||||
vi.stubGlobal("fetch", mock);
|
||||
await gateway("gpt-4o").generateCompletion(turn());
|
||||
const body = bodyOf(mock);
|
||||
expect(body.max_tokens).toBeGreaterThan(0);
|
||||
expect(body.temperature).toBe(0.3);
|
||||
expect(body).not.toHaveProperty("max_completion_tokens");
|
||||
});
|
||||
|
||||
it("keeps the legacy wire for a reasoning model name behind another gateway", () => {
|
||||
expect(
|
||||
selectOpenAIChatCompletionsCapabilities(
|
||||
"https://litellm.example.com/v1",
|
||||
"gpt-6-luna",
|
||||
).tokenBudgetField,
|
||||
).toBe("max_tokens");
|
||||
});
|
||||
});
|
||||
|
||||
describe("an endpoint that refuses max_tokens", () => {
|
||||
it("asks again with max_completion_tokens for an opaque deployment name and remembers it", async () => {
|
||||
const mock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(refused(400, AZURE_REFUSAL))
|
||||
.mockResolvedValue(ok());
|
||||
vi.stubGlobal("fetch", mock);
|
||||
const client = gateway("prod-chat");
|
||||
|
||||
const result = await client.generateCompletion(turn());
|
||||
expect(result.content).toBe('{"ok":true}');
|
||||
expect(mock).toHaveBeenCalledTimes(2);
|
||||
expect(bodyOf(mock, 0).max_tokens).toBeGreaterThan(0);
|
||||
const repeat = bodyOf(mock, 1);
|
||||
expect(repeat.max_completion_tokens).toBe(bodyOf(mock, 0).max_tokens);
|
||||
expect(repeat).not.toHaveProperty("max_tokens");
|
||||
expect(repeat).not.toHaveProperty("temperature");
|
||||
|
||||
await client.generateCompletion(turn());
|
||||
expect(mock).toHaveBeenCalledTimes(3);
|
||||
expect(bodyOf(mock, 2)).toHaveProperty("max_completion_tokens");
|
||||
});
|
||||
|
||||
it("learns it for that model only", async () => {
|
||||
const mock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(refused(400, AZURE_REFUSAL))
|
||||
.mockResolvedValue(ok());
|
||||
vi.stubGlobal("fetch", mock);
|
||||
await gateway("prod-chat").generateCompletion(turn());
|
||||
await gateway("prod-legacy").generateCompletion(turn());
|
||||
expect(bodyOf(mock, 2)).toHaveProperty("max_tokens");
|
||||
});
|
||||
|
||||
it("keeps JSON mode on the repeat", async () => {
|
||||
const mock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(refused(400, AZURE_REFUSAL))
|
||||
.mockResolvedValue(ok());
|
||||
vi.stubGlobal("fetch", mock);
|
||||
await gateway("prod-chat").generateCompletion(turn("json"));
|
||||
expect(bodyOf(mock, 1).response_format).toEqual({ type: "json_object" });
|
||||
});
|
||||
|
||||
it("does not take a complaint about the value for a refusal of the field", async () => {
|
||||
const mock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
refused(400, '{"error":"max_tokens is too large: 900000"}'),
|
||||
);
|
||||
vi.stubGlobal("fetch", mock);
|
||||
await expect(
|
||||
gateway("prod-chat").generateCompletion(turn()),
|
||||
).rejects.toMatchObject({ httpStatus: 400 });
|
||||
expect(mock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("asks again on every call that sent max_tokens, also beside one that learned it", async () => {
|
||||
const mock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(refused(400, AZURE_REFUSAL))
|
||||
.mockResolvedValueOnce(refused(400, AZURE_REFUSAL))
|
||||
.mockResolvedValue(ok());
|
||||
vi.stubGlobal("fetch", mock);
|
||||
const client = gateway("prod-chat");
|
||||
const results = await Promise.allSettled([
|
||||
client.generateCompletion(turn()),
|
||||
client.generateCompletion(turn()),
|
||||
]);
|
||||
expect(results.map((r) => r.status)).toEqual(["fulfilled", "fulfilled"]);
|
||||
expect(mock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("reads only a 400 that names the field", () => {
|
||||
expect(isMaxTokensRejection(400, AZURE_REFUSAL)).toBe(true);
|
||||
expect(isMaxTokensRejection(422, AZURE_REFUSAL)).toBe(false);
|
||||
expect(
|
||||
isMaxTokensRejection(400, '{"error":"Unsupported parameter: top_k"}'),
|
||||
).toBe(false);
|
||||
// A refusal of another field that echoes the request.
|
||||
expect(
|
||||
isMaxTokensRejection(
|
||||
400,
|
||||
'{"error":"Unsupported parameter: reasoning_effort is not supported","request":{"max_tokens":1000}}',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isMaxTokensRejection(
|
||||
400,
|
||||
'{"error":"max_tokens is not supported with this model"}',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -57,8 +57,8 @@ export function resetJsonModeDialectCache(): void {
|
||||
* 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. A complaint that names
|
||||
* `reasoning_effort` and not `response_format` is about the reasoning field,
|
||||
* so it must not switch the endpoint's JSON mode off.
|
||||
* `reasoning_effort` or `max_tokens` (#1304) and not `response_format` is
|
||||
* about that field, so it must not switch the endpoint's JSON mode off.
|
||||
*/
|
||||
export function isResponseFormatRejection(
|
||||
status: number,
|
||||
@@ -66,7 +66,7 @@ export function isResponseFormatRejection(
|
||||
): boolean {
|
||||
if (status < 400 || status >= 500) return false;
|
||||
if (
|
||||
/reasoning[\s_-]*effort/i.test(bodyExcerpt) &&
|
||||
/reasoning[\s_-]*effort|\bmax_tokens\b/i.test(bodyExcerpt) &&
|
||||
!/response_format/i.test(bodyExcerpt)
|
||||
) {
|
||||
return false;
|
||||
|
||||
@@ -334,6 +334,8 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
json: sent.json
|
||||
? { endpoint: this.config.baseUrl, action: "ai.local.jsonDialect" }
|
||||
: null,
|
||||
// Local runtimes take `max_tokens`; this wire has no other field.
|
||||
budget: null,
|
||||
});
|
||||
if ((lesson || sent.streamed) && hops.canSend) {
|
||||
return this.generateCompletion(params, hops);
|
||||
|
||||
@@ -47,16 +47,89 @@ export function isCanonicalOpenAIEndpoint(baseUrl: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** The hosts an Azure OpenAI resource is served from. */
|
||||
const AZURE_OPENAI_HOST =
|
||||
/^[a-z0-9][a-z0-9-]*\.(openai\.azure\.com|cognitiveservices\.azure\.com|services\.ai\.azure\.com)$/;
|
||||
|
||||
/**
|
||||
* #1304 — an Azure OpenAI resource's v1 endpoint
|
||||
* (`https://<resource>.openai.azure.com/openai/v1`). It speaks the same
|
||||
* request contract as `api.openai.com` for the same model, but the model
|
||||
* field carries the deployment name, which only identifies the model when
|
||||
* the deployment was named after it. An opaque alias is caught by the
|
||||
* refusal it draws instead (`rememberMaxTokensRefused`).
|
||||
*/
|
||||
export function isAzureOpenAIV1Endpoint(baseUrl: string): boolean {
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
return (
|
||||
url.protocol === "https:" &&
|
||||
AZURE_OPENAI_HOST.test(url.hostname) &&
|
||||
url.port === "" &&
|
||||
url.pathname.replace(/\/+$/, "") === "/openai/v1" &&
|
||||
url.username === "" &&
|
||||
url.password === "" &&
|
||||
url.search === "" &&
|
||||
url.hash === ""
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #1304 — endpoint and model pairs that refused `max_tokens`, for the
|
||||
* process lifetime. Keyed by both, because one gateway can front a legacy
|
||||
* model and a reasoning model side by side.
|
||||
*/
|
||||
const maxTokensRefused = new Set<string>();
|
||||
|
||||
function refusalKey(baseUrl: string, model: string): string {
|
||||
return `${baseUrl}\n${model}`;
|
||||
}
|
||||
|
||||
export function rememberMaxTokensRefused(baseUrl: string, model: string): void {
|
||||
maxTokensRefused.add(refusalKey(baseUrl, model));
|
||||
}
|
||||
|
||||
/** Test hook — clears the learned `max_tokens` refusals. */
|
||||
export function resetMaxTokensRefusals(): void {
|
||||
maxTokensRefused.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a 400 says the model takes `max_completion_tokens` and not
|
||||
* `max_tokens`: it names the replacement, or it refuses `max_tokens` by
|
||||
* name. A complaint about the value ("max_tokens is too large") or about
|
||||
* another field that echoes the request is not a refusal of the field.
|
||||
*/
|
||||
export function isMaxTokensRejection(status: number, body: string): boolean {
|
||||
if (status !== 400 || !/\bmax_tokens\b/.test(body)) return false;
|
||||
return (
|
||||
/max_completion_tokens/.test(body) ||
|
||||
/unsupported[\s_-]*parameter\W{0,4}max_tokens\b|\bmax_tokens\W{0,4}(is\s+)?(not\s+supported|unsupported)/i.test(
|
||||
body,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select Chat Completions request capabilities from both endpoint and model.
|
||||
* Custom OpenAI-compatible gateways retain the broadly supported legacy wire,
|
||||
* even when their model name resembles a modern OpenAI model.
|
||||
* even when their model name resembles a modern OpenAI model, until the
|
||||
* endpoint refuses `max_tokens` for that model. `api.openai.com` and an
|
||||
* Azure OpenAI v1 endpoint take the modern wire for a reasoning model.
|
||||
*/
|
||||
export function selectOpenAIChatCompletionsCapabilities(
|
||||
baseUrl: string,
|
||||
model: string,
|
||||
): OpenAIChatCompletionsCapabilities {
|
||||
return isCanonicalOpenAIEndpoint(baseUrl) && isModernCanonicalModel(model)
|
||||
if (maxTokensRefused.has(refusalKey(baseUrl, model))) {
|
||||
return MODERN_CANONICAL_CAPABILITIES;
|
||||
}
|
||||
return (isCanonicalOpenAIEndpoint(baseUrl) ||
|
||||
isAzureOpenAIV1Endpoint(baseUrl)) &&
|
||||
isModernCanonicalModel(model)
|
||||
? MODERN_CANONICAL_CAPABILITIES
|
||||
: LEGACY_COMPATIBLE_CAPABILITIES;
|
||||
}
|
||||
@@ -206,11 +206,11 @@ export class OpenAIClient implements AIProvider {
|
||||
? jsonModeDialectFor(this.config.baseUrl)
|
||||
: "response_format";
|
||||
const sendJsonFormat = useJsonFormat && jsonDialect === "response_format";
|
||||
const capabilities = selectOpenAIChatCompletionsCapabilities(
|
||||
const caps = selectOpenAIChatCompletionsCapabilities(
|
||||
this.config.baseUrl,
|
||||
this.config.model,
|
||||
);
|
||||
const reasoningWire = this.planReasoning(params, hasTools, capabilities);
|
||||
const reasoningWire = this.planReasoning(params, hasTools, caps);
|
||||
// #1126 — the gateway entry's own setting, for a call without a level,
|
||||
// unless the wire carries one or the endpoint refused the field (#1279).
|
||||
const entryEffort =
|
||||
@@ -225,13 +225,13 @@ export class OpenAIClient implements AIProvider {
|
||||
reasoningWire.kind !== "none" ? reasoningWire.effort : entryEffort;
|
||||
const thinkingTokens = this.thinkingRoom(params, reasoningWire);
|
||||
const tokenBudget = (params.maxTokens ?? 1000) + thinkingTokens;
|
||||
const capabilityParams = capabilities.supportsSamplingControls
|
||||
const capabilityParams = caps.supportsSamplingControls
|
||||
? {
|
||||
[capabilities.tokenBudgetField]: tokenBudget,
|
||||
[caps.tokenBudgetField]: tokenBudget,
|
||||
temperature: params.temperature ?? 0.3,
|
||||
...(params.seed !== undefined ? { seed: params.seed } : {}),
|
||||
}
|
||||
: { [capabilities.tokenBudgetField]: tokenBudget };
|
||||
: { [caps.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
|
||||
@@ -299,7 +299,7 @@ export class OpenAIClient implements AIProvider {
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return this.failed(res, params, hops, { sent, sendJsonFormat });
|
||||
return this.failed(res, params, hops, { sent, sendJsonFormat, caps });
|
||||
}
|
||||
|
||||
// v1.33.1 (#470) — the standard `response_format` request succeeded: pin
|
||||
@@ -498,7 +498,8 @@ export class OpenAIClient implements AIProvider {
|
||||
* structured field (with anything key-shaped stripped), so an upstream
|
||||
* incident is diagnosable from the log instead of an opaque status.
|
||||
*
|
||||
* An endpoint or model that refuses the reasoning field, or (#470, gateway
|
||||
* An endpoint or model that refuses the reasoning field, `max_tokens`
|
||||
* (#1304, asked again with `max_completion_tokens`), or (#470, gateway
|
||||
* only) the JSON flag, is asked once more without it and remembers
|
||||
* (`wire-refusal.ts`), inside the call's request budget. A refusal the
|
||||
* budget leaves no repeat for is marked `reasoningRefused`.
|
||||
@@ -511,7 +512,11 @@ export class OpenAIClient implements AIProvider {
|
||||
res: Response,
|
||||
params: CompletionParams,
|
||||
hops: RequestHops,
|
||||
sent: { sent: string | null; sendJsonFormat: boolean },
|
||||
sent: {
|
||||
sent: string | null;
|
||||
sendJsonFormat: boolean;
|
||||
caps: OpenAIChatCompletionsCapabilities;
|
||||
},
|
||||
): Promise<CompletionResult> {
|
||||
const bodyExcerpt = sanitiseBodyExcerpt(await res.text().catch(() => ""));
|
||||
// The entry's own setting (#1126) included: a refused field is learned
|
||||
@@ -534,6 +539,12 @@ export class OpenAIClient implements AIProvider {
|
||||
this.isGateway && sent.sendJsonFormat
|
||||
? { endpoint: this.config.baseUrl, action: "ai.compat.jsonDialect" }
|
||||
: null,
|
||||
// The field this request carried, not today's selection: a call
|
||||
// running beside it may have learned the lesson meanwhile.
|
||||
budget:
|
||||
sent.caps.tokenBudgetField === "max_tokens"
|
||||
? { endpoint: this.config.baseUrl }
|
||||
: null,
|
||||
});
|
||||
if (lesson && hops.canSend) return this.generateCompletion(params, hops);
|
||||
const modelNotServed = await this.modelNoLongerServed(res.status);
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
* clients, in one place for both.
|
||||
*
|
||||
* Only an HTTP 400 that names a field the request carried teaches anything:
|
||||
* the reasoning field (`isReasoningParameterRejection`, learned per endpoint
|
||||
* and model) or the JSON flag (`isResponseFormatRejection`, learned per
|
||||
* endpoint). The reasoning field is checked first, so a body that names it
|
||||
* never switches JSON mode off. Whether the call then sends the lesser request
|
||||
* `max_tokens` where the model takes `max_completion_tokens` (#1304,
|
||||
* `isMaxTokensRejection`, learned per endpoint and model), the reasoning
|
||||
* field (`isReasoningParameterRejection`, learned per endpoint and model) or
|
||||
* the JSON flag (`isResponseFormatRejection`, learned per endpoint). The
|
||||
* JSON flag is checked last, so a body that names another field never
|
||||
* switches JSON mode off. Whether the call then sends the lesser request
|
||||
* is the client's, inside its request budget (`request-hops.ts`).
|
||||
*
|
||||
* Server-only.
|
||||
@@ -17,13 +19,17 @@ import {
|
||||
isResponseFormatRejection,
|
||||
rememberJsonModeDialect,
|
||||
} from "./json-dialect";
|
||||
import {
|
||||
isMaxTokensRejection,
|
||||
rememberMaxTokensRefused,
|
||||
} from "./openai-capabilities";
|
||||
import { learnReasoningFieldRefused } from "./reasoning/answerless";
|
||||
import {
|
||||
isReasoningParameterRejection,
|
||||
type ReasoningDialectKey,
|
||||
} from "./reasoning/dialect-cache";
|
||||
|
||||
export type RefusalLesson = "reasoning" | "json" | null;
|
||||
export type RefusalLesson = "budget" | "reasoning" | "json" | null;
|
||||
|
||||
export function learnFromRefusal(args: {
|
||||
status: number;
|
||||
@@ -39,8 +45,18 @@ export function learnFromRefusal(args: {
|
||||
} | null;
|
||||
/** The endpoint, when the request carried the JSON flag. */
|
||||
json: { endpoint: string; action: string } | null;
|
||||
/** The endpoint, when the request carried `max_tokens` it could drop. */
|
||||
budget: { endpoint: string } | null;
|
||||
}): RefusalLesson {
|
||||
const { reasoning, json } = args;
|
||||
const { reasoning, json, budget } = args;
|
||||
if (budget && isMaxTokensRejection(args.status, args.body)) {
|
||||
rememberMaxTokensRefused(budget.endpoint, args.model);
|
||||
annotate({
|
||||
action: { name: "ai.compat.tokenBudgetField" },
|
||||
meta: { field: "max_completion_tokens", httpStatus: args.status },
|
||||
});
|
||||
return "budget";
|
||||
}
|
||||
if (
|
||||
reasoning &&
|
||||
isReasoningParameterRejection(
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"schemaVersion":"2","exportedAt":"2026-10-30T16:09:08.289Z","userId":"round-trip-owner","appSettings":null,"accountSettings":{"heightCm":null,"dateOfBirth":null,"gender":null,"hasDiabetes":false,"displayName":null,"fullName":null,"insurerName":null,"insuranceNumber":null,"insuranceNumberEncrypted":null,"insurerIkNumber":null,"lastReportPracticeName":null,"avatarBytes":null,"avatarContentType":null,"avatarUpdatedAt":null,"homeTimezone":null,"homeSince":null,"homeLocationEncrypted":null,"environmentAirQualityEnabled":true,"timezone":"Europe/Berlin","locale":null,"unitPreference":null,"glucoseUnit":null,"timeFormat":"AUTO","dateFormat":"AUTO","modulePreferencesJson":null,"thresholdsJson":null,"healthScoreConfigJson":null,"sourcePriorityJson":null,"dashboardWidgetsJson":null,"insightsLayoutJson":null,"medicationListLayoutJson":null,"moodTagLayoutJson":null,"documentsLayoutJson":null,"moduleListLayoutJson":null,"activityGoalsJson":null,"reportSelectionJson":null,"globalExcludedInjectionSites":[],"healthKitConfigJson":null,"notificationPrefs":null,"moodReminderEnabled":false,"aiProvider":null,"aiModel":null,"aiBaseUrl":null,"aiCompatBaseUrl":null,"aiCompatModel":null,"aiProviderChain":null,"aiResponseTimeoutSeconds":null,"useCentralCodex":false,"insightsPrivacyMode":"aggregated","insightsExcludeMetrics":[],"disableCoach":false,"aiUsageLevel":null,"coachPrefsJson":null,"documentsAutoAiRead":false,"labsLocalOcrEnabled":false,"storeWorkoutRoutes":false,"onboardingCompletedAt":null,"onboardingTourCompleted":false,"onboardingTourProgressJson":null,"disclaimerAcknowledgedAt":null,"disclaimerAcknowledgedVersion":null,"passkeyUpgradeNudgeDismissed":false},"measurements":[{"id":"cmvv63ctz002ri5ov1fno1ff7","type":"BLOOD_GLUCOSE","value":5.4,"valueMin":null,"valueMax":null,"unit":"mmol/L","measuredAt":"2026-07-03T06:45:00.000Z","source":"MANUAL","notes":null,"notesEncrypted":null,"externalId":null,"externalSourceVersion":null,"aggregationProvenance":null,"glucoseContext":"FASTING","sleepStage":null,"rhythmClassification":null,"deviceType":null,"ingestTokenId":null,"syncVersion":1,"deletedAt":null,"createdAt":"2026-10-30T16:09:08.279Z","updatedAt":"2026-10-30T16:09:08.279Z"},{"id":"cmvv63cu0002si5ovpsdmt1n4","type":"SLEEP_DURATION","value":95,"valueMin":null,"valueMax":null,"unit":"min","measuredAt":"2026-07-03T02:00:00.000Z","source":"APPLE_HEALTH","notes":null,"notesEncrypted":null,"externalId":"round-trip-deep-sleep","externalSourceVersion":null,"aggregationProvenance":null,"glucoseContext":null,"sleepStage":"DEEP","rhythmClassification":null,"deviceType":null,"ingestTokenId":null,"syncVersion":1,"deletedAt":null,"createdAt":"2026-10-30T16:09:08.280Z","updatedAt":"2026-10-30T16:09:08.280Z"},{"id":"cmvv63cr20001i5ov8d1bjiil","type":"PULSE","value":61,"valueMin":null,"valueMax":null,"unit":"bpm","measuredAt":"2026-07-02T06:30:00.000Z","source":"APPLE_HEALTH","notes":null,"notesEncrypted":null,"externalId":"round-trip-deleted-sample","externalSourceVersion":null,"aggregationProvenance":null,"glucoseContext":null,"sleepStage":null,"rhythmClassification":null,"deviceType":null,"ingestTokenId":null,"syncVersion":4,"deletedAt":"2026-10-20T16:09:07.000Z","createdAt":"2026-10-30T16:09:08.174Z","updatedAt":"2026-10-30T16:09:08.174Z"},{"id":"cmvv63cqz0000i5ovhzyg2sjz","type":"WEIGHT","value":74.2,"valueMin":null,"valueMax":null,"unit":"kg","measuredAt":"2026-07-01T07:00:00.000Z","source":"MANUAL","notes":null,"notesEncrypted":null,"externalId":null,"externalSourceVersion":null,"aggregationProvenance":null,"glucoseContext":null,"sleepStage":null,"rhythmClassification":null,"deviceType":null,"ingestTokenId":null,"syncVersion":1,"deletedAt":null,"createdAt":"2026-10-30T16:09:08.171Z","updatedAt":"2026-10-30T16:09:08.171Z"},{"id":"cmvv63cr30002i5ovjtizk643","type":"IRREGULAR_RHYTHM_NOTIFICATION","value":1,"valueMin":null,"valueMax":null,"unit":"event","measuredAt":"2026-06-15T14:22:00.000Z","source":"WITHINGS","notes":null,"notesEncrypted":null,"externalId":null,"externalSourceVersion":null,"aggregationProvenance":null,"glucoseContext":null,"sleepStage":null,"rhythmClassification":"IRREGULAR","deviceType":null,"ingestTokenId":null,"syncVersion":1,"deletedAt":null,"createdAt":"2026-10-30T16:09:08.175Z","updatedAt":"2026-10-30T16:09:08.175Z"}],"customMedicationCategories":[{"key":"custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10","label":null,"labelEncrypted":"djEuWlZlUjltQzFhMk5CUXZPQVN3V3JjcXlEL1Y2d3lSQzVZbzB4cHFoRjRPNGRxT09aaE9uR0hXckxZK1pJdjNrcWt3PT0=","sortOrder":2,"isActive":true,"createdAt":"2026-10-30T16:09:08.195Z","updatedAt":"2026-10-30T16:09:08.195Z"}],"medications":[{"id":"cmvv63crh0005i5ovl2urjmny","treatmentClass":"GENERIC","dosesPerUnit":null,"unitsPerDose":"1","notificationsEnabled":true,"pausedAt":null,"snoozedUntil":null,"startsOn":"2026-06-10T00:00:00.000Z","endsOn":"2026-06-16T00:00:00.000Z","oneShot":false,"asNeeded":false,"deliveryForm":"ORAL","trackInjectionSites":false,"allowedInjectionSites":[],"liveActivityEnabled"Line truncated
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"release": "1.46.5",
|
||||
"schemaVersion": "2",
|
||||
"userId": "round-trip-owner",
|
||||
"exportedAt": "2026-10-30T16:09:08.289Z",
|
||||
"sha256": "4af553ea51571c60839e94f9fda740f90336de5ad5b2b3a25d9669a55ab88145",
|
||||
"bytes": 41985,
|
||||
"counts": {
|
||||
"measurements": 5,
|
||||
"medications": 1,
|
||||
"intakeEvents": 1,
|
||||
"medicationSideEffects": 1,
|
||||
"medicationPauseEras": 2,
|
||||
"medicationDoseChanges": 2,
|
||||
"medicationInventoryItems": 2,
|
||||
"medicationInventoryEvents": 2,
|
||||
"reminderPhaseConfigs": 1,
|
||||
"medicationEfficacyTargets": 1,
|
||||
"medicationScheduleRevisions": 3,
|
||||
"moodEntries": 1,
|
||||
"customMoodTagCategories": 1,
|
||||
"hiddenMoodTags": 1,
|
||||
"cycles": 2,
|
||||
"cycleDayLogs": 1,
|
||||
"nutrientDays": 1,
|
||||
"labResults": 1,
|
||||
"biomarkers": 1,
|
||||
"illnessEpisodes": 2,
|
||||
"illnessDayLogs": 1,
|
||||
"allergies": 1,
|
||||
"familyHistory": 1,
|
||||
"workouts": 1,
|
||||
"documents": 1,
|
||||
"healthProfile": 1,
|
||||
"customMetrics": 1,
|
||||
"healthProfileFactRevisions": 1,
|
||||
"customMetricEntries": 2,
|
||||
"correlationPatterns": 1,
|
||||
"intradayProfiles": 1,
|
||||
"healthScoreRecords": 1,
|
||||
"onboardingRecords": 1,
|
||||
"practitioners": 1,
|
||||
"encounters": 1,
|
||||
"encounterLinks": 3,
|
||||
"customVaccines": 1,
|
||||
"vaccinations": 1,
|
||||
"vaccinationLinks": 1,
|
||||
"measurementReminders": 1,
|
||||
"measurementReminderEvents": 2,
|
||||
"coachConversations": 2,
|
||||
"coachMessages": 3,
|
||||
"coachConversationDocuments": 1,
|
||||
"coachFacts": 1,
|
||||
"coachPlans": 1,
|
||||
"coachReminders": 1,
|
||||
"mentalHealthAssessments": 1,
|
||||
"consentReceipts": 2,
|
||||
"documentConditionLinks": 1,
|
||||
"extractedFacts": 1,
|
||||
"documentImportKeys": 1,
|
||||
"personalRecords": 3,
|
||||
"userAchievements": 2,
|
||||
"environmentContexts": 2,
|
||||
"environmentTravelLocations": 1,
|
||||
"dawarichDays": 1,
|
||||
"ecgRecordings": 1,
|
||||
"symptomDefinitions": 2,
|
||||
"symptomEvents": 3,
|
||||
"lifeEvents": 2,
|
||||
"relationLinks": 5
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user