mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(settings): add a Reasoning select to the Local and gateway forms
One select under the model field of the Local and OpenAI-compatible provider forms: Default, Off, Low, Medium, High, with a one-line hint that Off leaves the answer budget for the answer itself. It reuses the NativeSelect, label and hint layout of the field above it, seeds from the stored entry, and saves with the form. Strings in all seven locales. Refs #1126
This commit is contained in:
1 parent
befac59576
commit
39a5862d54
13 files changed
+383
-3
No files matched your search
@@ -339,4 +339,116 @@ test.describe("Settings → AI provider dropdown UX (B2)", () => {
|
||||
.locator('button[type="submit"]'),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
// #1126 — the Reasoning select on the Local and OpenAI-compatible forms
|
||||
// saves with the form, and at phone width it sits in the same column as
|
||||
// the model field without pushing the page sideways.
|
||||
test("reasoning setting saves from the Local and gateway forms at 390 px", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const patchBodies: Array<Record<string, unknown>> = [];
|
||||
let providerState: Record<string, unknown> = {
|
||||
provider: "LOCAL",
|
||||
model: null,
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
responseTimeoutSeconds: null,
|
||||
hasAnthropicKey: false,
|
||||
anthropicKeyPreview: null,
|
||||
hasLocalKey: false,
|
||||
hasOpenaiKey: false,
|
||||
openaiKeyPreview: null,
|
||||
compatBaseUrl: "https://gateway.example.org/v1",
|
||||
compatModel: "qwen3",
|
||||
hasCompatKey: false,
|
||||
localReasoningEffort: null,
|
||||
compatReasoningEffort: null,
|
||||
};
|
||||
await page.route("**/api/insights/settings", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
codexStatus: "disconnected",
|
||||
codexConnectedAt: null,
|
||||
hasAdminKey: false,
|
||||
codexOauthConfigured: true,
|
||||
privacyMode: "aggregated",
|
||||
lastInsightAt: null,
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/user/ai-provider", async (route) => {
|
||||
if (route.request().method() === "PATCH") {
|
||||
const body = route.request().postDataJSON() as Record<string, unknown>;
|
||||
patchBodies.push(body);
|
||||
providerState = { ...providerState, ...body };
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: providerState, error: null }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/insights/provider-chain", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
activeProvider: "local",
|
||||
cachedActiveProvider: null,
|
||||
configuredChain: [
|
||||
{ providerType: "local", enabled: true, available: true },
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const providerHydrated = page.waitForResponse("**/api/user/ai-provider");
|
||||
await page.goto("/settings/ai?provider=local", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await providerHydrated;
|
||||
|
||||
const localForm = page.getByTestId("ai-provider-config-local");
|
||||
const localReasoning = page.locator("#ai-local-reasoning");
|
||||
await expect(localReasoning).toHaveValue("");
|
||||
// Same column, same width as the model select above it.
|
||||
const [modelBox, reasoningBox] = await Promise.all([
|
||||
page.locator("#ai-local-model").boundingBox(),
|
||||
localReasoning.boundingBox(),
|
||||
]);
|
||||
expect(reasoningBox?.x).toBe(modelBox?.x);
|
||||
expect(reasoningBox?.width).toBe(modelBox?.width);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <=
|
||||
document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await localReasoning.selectOption("none");
|
||||
await localForm.locator('button[type="submit"]').click();
|
||||
await expect.poll(() => patchBodies.length).toBe(1);
|
||||
expect(patchBodies[0].localReasoningEffort).toBe("none");
|
||||
|
||||
await page
|
||||
.getByTestId("ai-active-provider-select")
|
||||
.selectOption("openai-compatible");
|
||||
const compatForm = page.getByTestId("ai-provider-config-openai-compatible");
|
||||
const compatReasoning = page.locator("#ai-compat-reasoning");
|
||||
await expect(compatReasoning).toHaveValue("");
|
||||
await compatReasoning.selectOption("high");
|
||||
await compatForm.locator('button[type="submit"]').click();
|
||||
await expect.poll(() => patchBodies.length).toBe(2);
|
||||
expect(patchBodies[1].compatReasoningEffort).toBe("high");
|
||||
expect(patchBodies[1]).not.toHaveProperty("localReasoningEffort");
|
||||
});
|
||||
});
|
||||
@@ -6080,6 +6080,17 @@
|
||||
"title": "Coach-Pläne",
|
||||
"description": "Die Pläne, die du mit dem Coach vereinbart hast, zum Lesen oder Entfernen.",
|
||||
"open": "Pläne öffnen"
|
||||
},
|
||||
"reasoning": {
|
||||
"label": "Nachdenken",
|
||||
"hint": "Reasoning-Modelle wie Gemma oder Qwen denken nach, bevor sie antworten. Schaltest du es aus, bleibt das ganze Antwortbudget für die Antwort selbst.",
|
||||
"options": {
|
||||
"default": "Standard",
|
||||
"none": "Aus",
|
||||
"low": "Niedrig",
|
||||
"medium": "Mittel",
|
||||
"high": "Hoch"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
|
||||
@@ -6080,6 +6080,17 @@
|
||||
"title": "Coach plans",
|
||||
"description": "The plans you agreed with the Coach, to read or remove.",
|
||||
"open": "Open plans"
|
||||
},
|
||||
"reasoning": {
|
||||
"label": "Reasoning",
|
||||
"hint": "Reasoning models such as Gemma or Qwen think before they answer. Off leaves the whole answer budget for the answer itself.",
|
||||
"options": {
|
||||
"default": "Default",
|
||||
"none": "Off",
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
|
||||
@@ -6080,6 +6080,17 @@
|
||||
"title": "Planes del Coach",
|
||||
"description": "Los planes que acordaste con el Coach, para leerlos o eliminarlos.",
|
||||
"open": "Abrir planes"
|
||||
},
|
||||
"reasoning": {
|
||||
"label": "Razonamiento",
|
||||
"hint": "Los modelos de razonamiento como Gemma o Qwen piensan antes de responder. Si lo desactivas, todo el presupuesto de respuesta se queda para la respuesta.",
|
||||
"options": {
|
||||
"default": "Predeterminado",
|
||||
"none": "Desactivado",
|
||||
"low": "Bajo",
|
||||
"medium": "Medio",
|
||||
"high": "Alto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
|
||||
@@ -6080,6 +6080,17 @@
|
||||
"title": "Plans du Coach",
|
||||
"description": "Les plans convenus avec le Coach, à lire ou à supprimer.",
|
||||
"open": "Ouvrir les plans"
|
||||
},
|
||||
"reasoning": {
|
||||
"label": "Raisonnement",
|
||||
"hint": "Les modèles de raisonnement comme Gemma ou Qwen réfléchissent avant de répondre. Désactivé, tout le budget de réponse sert à la réponse elle-même.",
|
||||
"options": {
|
||||
"default": "Par défaut",
|
||||
"none": "Désactivé",
|
||||
"low": "Faible",
|
||||
"medium": "Moyen",
|
||||
"high": "Élevé"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
|
||||
@@ -6080,6 +6080,17 @@
|
||||
"title": "Piani del Coach",
|
||||
"description": "I piani concordati con il Coach, da leggere o rimuovere.",
|
||||
"open": "Apri i piani"
|
||||
},
|
||||
"reasoning": {
|
||||
"label": "Ragionamento",
|
||||
"hint": "I modelli di ragionamento come Gemma o Qwen pensano prima di rispondere. Se lo disattivi, tutto il budget della risposta resta per la risposta stessa.",
|
||||
"options": {
|
||||
"default": "Predefinito",
|
||||
"none": "Disattivato",
|
||||
"low": "Basso",
|
||||
"medium": "Medio",
|
||||
"high": "Alto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
|
||||
@@ -6080,6 +6080,17 @@
|
||||
"title": "코치 계획",
|
||||
"description": "코치와 합의한 계획을 읽거나 삭제할 수 있습니다.",
|
||||
"open": "계획 열기"
|
||||
},
|
||||
"reasoning": {
|
||||
"label": "추론",
|
||||
"hint": "Gemma나 Qwen 같은 추론 모델은 답하기 전에 먼저 생각합니다. 끄면 답변 예산을 모두 답변 자체에 씁니다.",
|
||||
"options": {
|
||||
"default": "기본값",
|
||||
"none": "끄기",
|
||||
"low": "낮음",
|
||||
"medium": "보통",
|
||||
"high": "높음"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
|
||||
@@ -6080,6 +6080,17 @@
|
||||
"title": "Plany z Coachem",
|
||||
"description": "Plany uzgodnione z Coachem, do przeczytania lub usunięcia.",
|
||||
"open": "Otwórz plany"
|
||||
},
|
||||
"reasoning": {
|
||||
"label": "Rozumowanie",
|
||||
"hint": "Modele rozumujące, takie jak Gemma czy Qwen, myślą przed odpowiedzią. Po wyłączeniu cały budżet odpowiedzi idzie na samą odpowiedź.",
|
||||
"options": {
|
||||
"default": "Domyślnie",
|
||||
"none": "Wyłączone",
|
||||
"low": "Niskie",
|
||||
"medium": "Średnie",
|
||||
"high": "Wysokie"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import en from "../../../../../messages/en.json";
|
||||
import { CompatProviderForm } from "../compat-provider-form";
|
||||
import { LocalProviderForm } from "../local-provider-form";
|
||||
import type { UserAIProvider } from "../shared";
|
||||
|
||||
/**
|
||||
* #1126 — the Reasoning select on the Local and OpenAI-compatible forms. It
|
||||
* sits right under the model field, offers exactly Default / Off / Low /
|
||||
* Medium / High, and seeds from the value the server stored on the entry.
|
||||
* Saving it is pinned end to end in `e2e/ai-provider-dropdown.spec.ts`.
|
||||
*/
|
||||
|
||||
function provider(overrides: Partial<UserAIProvider>): UserAIProvider {
|
||||
return {
|
||||
provider: "LOCAL",
|
||||
model: "llama3:8b",
|
||||
baseUrl: "http://ollama.example.org:11434/v1",
|
||||
hasAnthropicKey: false,
|
||||
anthropicKeyPreview: null,
|
||||
hasLocalKey: false,
|
||||
hasOpenaiKey: false,
|
||||
openaiKeyPreview: null,
|
||||
compatBaseUrl: "https://gateway.example.org/v1",
|
||||
compatModel: "qwen3",
|
||||
hasCompatKey: false,
|
||||
responseTimeoutSeconds: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** The `<select>` element with the given id, as markup. */
|
||||
function selectMarkup(html: string, id: string): string {
|
||||
const match = new RegExp(`<select[^>]*\\bid="${id}"`).exec(html);
|
||||
expect(match, `no select #${id}`).not.toBeNull();
|
||||
const start = match!.index;
|
||||
return html.slice(start, html.indexOf("</select>", start));
|
||||
}
|
||||
|
||||
const OPTION_VALUES = ["", "none", "low", "medium", "high"];
|
||||
|
||||
describe.each([
|
||||
{
|
||||
name: "Local",
|
||||
id: "ai-local-reasoning",
|
||||
after: "ai-local-model",
|
||||
form: (p: UserAIProvider) => <LocalProviderForm userProvider={p} />,
|
||||
stored: (v: UserAIProvider["localReasoningEffort"]) => ({
|
||||
localReasoningEffort: v,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "OpenAI-compatible",
|
||||
id: "ai-compat-reasoning",
|
||||
after: "ai-compat-model",
|
||||
form: (p: UserAIProvider) => <CompatProviderForm userProvider={p} />,
|
||||
stored: (v: UserAIProvider["compatReasoningEffort"]) => ({
|
||||
compatReasoningEffort: v,
|
||||
provider: "OPENAI_COMPATIBLE",
|
||||
}),
|
||||
},
|
||||
])("$name form", ({ id, after, form, stored }) => {
|
||||
it("offers Default, Off, Low, Medium and High under the model field", () => {
|
||||
const html = render(form(provider({})));
|
||||
const select = selectMarkup(html, id);
|
||||
expect(
|
||||
[...select.matchAll(/<option value="([^"]*)"/g)].map((m) => m[1]),
|
||||
).toEqual(OPTION_VALUES);
|
||||
const r = en.settings.ai.reasoning;
|
||||
for (const label of Object.values(r.options)) {
|
||||
expect(select).toContain(`>${label}</option>`);
|
||||
}
|
||||
expect(html).toContain(`<label`);
|
||||
expect(html).toContain(`for="${id}"`);
|
||||
expect(html).toContain(r.label);
|
||||
expect(html).toContain(r.hint);
|
||||
expect(html.indexOf(`id="${id}"`)).toBeGreaterThan(
|
||||
html.indexOf(`id="${after}"`),
|
||||
);
|
||||
});
|
||||
|
||||
it("seeds Default when nothing is stored, or a payload predates the field", () => {
|
||||
const select = selectMarkup(render(form(provider({}))), id);
|
||||
expect(select).toMatch(/<option value="" selected="">/);
|
||||
});
|
||||
|
||||
it("seeds the stored value", () => {
|
||||
const select = selectMarkup(render(form(provider(stored("none")))), id);
|
||||
expect(select).toMatch(/<option value="none" selected="">/);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,12 @@ import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
|
||||
import { uiToLegacyProviderEnum, type UserAIProvider } from "./shared";
|
||||
import { ReasoningEffortField } from "./reasoning-effort-field";
|
||||
import {
|
||||
uiToLegacyProviderEnum,
|
||||
type ReasoningEffort,
|
||||
type UserAIProvider,
|
||||
} from "./shared";
|
||||
|
||||
export function CompatProviderForm({
|
||||
userProvider,
|
||||
@@ -32,6 +37,7 @@ export function CompatProviderForm({
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [reasoning, setReasoning] = useState<ReasoningEffort | null>(null);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [ok, setOk] = useState(false);
|
||||
const submitInFlightRef = useRef(false);
|
||||
@@ -40,13 +46,14 @@ export function CompatProviderForm({
|
||||
// render-time pattern the sibling forms use (no setState-in-effect).
|
||||
const seededKey =
|
||||
userProvider != null
|
||||
? `${userProvider.compatBaseUrl ?? ""}|${userProvider.compatModel ?? ""}`
|
||||
? `${userProvider.compatBaseUrl ?? ""}|${userProvider.compatModel ?? ""}|${userProvider.compatReasoningEffort ?? ""}`
|
||||
: null;
|
||||
const [previousSeed, setPreviousSeed] = useState<string | null>(null);
|
||||
if (seededKey && seededKey !== previousSeed) {
|
||||
setPreviousSeed(seededKey);
|
||||
setBaseUrl(userProvider?.compatBaseUrl ?? "");
|
||||
setModel(userProvider?.compatModel ?? "");
|
||||
setReasoning(userProvider?.compatReasoningEffort ?? null);
|
||||
}
|
||||
|
||||
const saveMutation = useMutation({
|
||||
@@ -55,6 +62,7 @@ export function CompatProviderForm({
|
||||
provider: uiToLegacyProviderEnum("openai-compatible"),
|
||||
compatBaseUrl: baseUrl.trim() || null,
|
||||
compatModel: model.trim() || null,
|
||||
compatReasoningEffort: reasoning,
|
||||
};
|
||||
if (apiKey.trim()) body.compatKey = apiKey.trim();
|
||||
await apiPatch("/api/user/ai-provider", body);
|
||||
@@ -130,6 +138,11 @@ export function CompatProviderForm({
|
||||
{t("settings.ai.compat.modelHint")}
|
||||
</p>
|
||||
</div>
|
||||
<ReasoningEffortField
|
||||
id="ai-compat-reasoning"
|
||||
value={reasoning}
|
||||
onChange={setReasoning}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
|
||||
@@ -17,10 +17,12 @@ import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
|
||||
import { ReasoningEffortField } from "./reasoning-effort-field";
|
||||
import {
|
||||
CUSTOM_MODEL_SENTINEL,
|
||||
LOCAL_MODEL_PRESETS,
|
||||
uiToLegacyProviderEnum,
|
||||
type ReasoningEffort,
|
||||
type UserAIProvider,
|
||||
} from "./shared";
|
||||
|
||||
@@ -36,17 +38,20 @@ export function LocalProviderForm({
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [modelChoice, setModelChoice] = useState<string>("");
|
||||
const [customModel, setCustomModel] = useState("");
|
||||
const [reasoning, setReasoning] = useState<ReasoningEffort | null>(null);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [ok, setOk] = useState(false);
|
||||
const submitInFlightRef = useRef(false);
|
||||
|
||||
const seededKey =
|
||||
userProvider != null
|
||||
? `${userProvider.provider ?? ""}|${userProvider.model ?? ""}|${userProvider.baseUrl ?? ""}`
|
||||
? `${userProvider.provider ?? ""}|${userProvider.model ?? ""}|${userProvider.baseUrl ?? ""}|${userProvider.localReasoningEffort ?? ""}`
|
||||
: null;
|
||||
const [previousSeed, setPreviousSeed] = useState<string | null>(null);
|
||||
if (seededKey && seededKey !== previousSeed) {
|
||||
setPreviousSeed(seededKey);
|
||||
// Stored on the chain entry, so it seeds whichever provider is selected.
|
||||
setReasoning(userProvider?.localReasoningEffort ?? null);
|
||||
if (userProvider?.provider === "LOCAL") {
|
||||
setBaseUrl(userProvider.baseUrl ?? "");
|
||||
const saved = userProvider.model ?? "";
|
||||
@@ -71,6 +76,7 @@ export function LocalProviderForm({
|
||||
provider: uiToLegacyProviderEnum("local"),
|
||||
baseUrl: baseUrl.trim() || null,
|
||||
model: effectiveModel || null,
|
||||
localReasoningEffort: reasoning,
|
||||
};
|
||||
if (apiKey.trim()) body.localKey = apiKey.trim();
|
||||
await apiPatch("/api/user/ai-provider", body);
|
||||
@@ -168,6 +174,11 @@ export function LocalProviderForm({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ReasoningEffortField
|
||||
id="ai-local-reasoning"
|
||||
value={reasoning}
|
||||
onChange={setReasoning}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
* Reasoning select (#1126) for the Local and OpenAI-compatible forms.
|
||||
* Same Label + NativeSelect + hint layout as the model field above it.
|
||||
* The empty value is Default: nothing is sent and the model decides.
|
||||
* ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import type { ReasoningEffort } from "./shared";
|
||||
|
||||
export function ReasoningEffortField({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
value: ReasoningEffort | null;
|
||||
onChange: (next: ReasoningEffort | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={id}>{t("settings.ai.reasoning.label")}</Label>
|
||||
<NativeSelect
|
||||
id={id}
|
||||
value={value ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
e.target.value === "" ? null : (e.target.value as ReasoningEffort),
|
||||
)
|
||||
}
|
||||
className="mt-1"
|
||||
aria-describedby={`${id}-hint`}
|
||||
>
|
||||
<option value="">{t("settings.ai.reasoning.options.default")}</option>
|
||||
<option value="none">{t("settings.ai.reasoning.options.none")}</option>
|
||||
<option value="low">{t("settings.ai.reasoning.options.low")}</option>
|
||||
<option value="medium">
|
||||
{t("settings.ai.reasoning.options.medium")}
|
||||
</option>
|
||||
<option value="high">{t("settings.ai.reasoning.options.high")}</option>
|
||||
</NativeSelect>
|
||||
<p id={`${id}-hint`} className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.ai.reasoning.hint")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
* this module under `src/components/settings/ai/`.
|
||||
*/
|
||||
|
||||
import type { ReasoningEffort } from "@/lib/ai/reasoning-effort";
|
||||
|
||||
export interface InsightsSettings {
|
||||
codexStatus: string;
|
||||
codexConnectedAt: string | null;
|
||||
@@ -40,8 +42,15 @@ export interface UserAIProvider {
|
||||
hasCompatKey: boolean;
|
||||
// v1.22 (#89) — per-user response timeout, in seconds (null = default).
|
||||
responseTimeoutSeconds: number | null;
|
||||
// #1126 — reasoning setting of the Local and gateway entries (null =
|
||||
// Default). Absent on a payload from a server older than the field.
|
||||
localReasoningEffort?: ReasoningEffort | null;
|
||||
compatReasoningEffort?: ReasoningEffort | null;
|
||||
}
|
||||
|
||||
/** #1126 — the `reasoning_effort` values; "Off" is `none`. */
|
||||
export type { ReasoningEffort };
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B2 — provider tags exposed to the UI. Mirrors
|
||||
* `PROVIDER_CHAIN_TYPES` server-side; kept as a const-array here to
|
||||
|
||||
Reference in new issue
Block a user