mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
fix(ai): keep the picked gateway serving behind an empty chain
A person on the OpenAI-compatible gateway lost every AI surface once their chain was saved at all: the fallback to the picked gateway asked whether the chain was the default object itself, so a chain reset to the default, reordered, or written by the gateway form's reasoning setting (which appended a disabled entry to hold the value) no longer counted, and the chain resolved empty. Rows written that way before this release broke on deploy. The fallback now reads the chain's content: the picked gateway serves when nothing else resolves, unless the chain holds the gateway switched off. A disabled gateway entry that carries a reasoning effort is the form's holder, not a switch; the chain editor drops the effort of a gateway it switches off, so a switch-off stays one. Saving the gateway form with the gateway picked writes its entry switched on. Resolution, the presence probe, the account payload and the iOS availability flag all read the same rule.
This commit is contained in:
1 parent
17dbb225d4
commit
4be724fefb
10 files changed
+261
-118
No files matched your search
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 84,
|
||||
"total": 93834,
|
||||
"total": 93833,
|
||||
"entries": {
|
||||
"app/medications/page-client.tsx": 839,
|
||||
"components/charts/mood-chart.tsx": 1166,
|
||||
"components/cycle/log-day-sheet.tsx": 1639,
|
||||
"components/insights/coach-panel/chat-bubble.tsx": 822,
|
||||
"components/insights/coach-panel/chat-bubble.tsx": 821,
|
||||
"components/insights/coach-panel/coach-conversation.tsx": 1278,
|
||||
"components/insights/coach-panel/use-coach.ts": 1486,
|
||||
"components/insights/derived/vitals-dashboard.tsx": 1033,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 337,
|
||||
"total": 109677,
|
||||
"total": 109571,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
|
||||
@@ -36,7 +36,7 @@
|
||||
"app/api/nutrients/batch/route.ts :: postBatch": 304,
|
||||
"app/api/onboarding/complete/route.ts :: completeNeedsFlow": 218,
|
||||
"app/api/record-settings/[family]/route.ts :: apiHandler(arg) #2": 262,
|
||||
"app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 250,
|
||||
"app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 224,
|
||||
"app/api/withings/callback/route.ts :: apiHandler(arg)": 247,
|
||||
"app/auth/login/page.tsx :: LoginPage": 569,
|
||||
"app/auth/register/page.tsx :: RegisterPage": 285,
|
||||
@@ -53,20 +53,20 @@
|
||||
"components/admin/api-token-overview-section.tsx :: ApiTokenOverviewSection": 212,
|
||||
"components/admin/app-log-preview-section.tsx :: AppLogPreviewSection": 341,
|
||||
"components/admin/central-codex-section.tsx :: CentralCodexSection": 228,
|
||||
"components/admin/encryption-section.tsx :: EncryptionSection": 264,
|
||||
"components/admin/encryption-section.tsx :: EncryptionSection": 261,
|
||||
"components/admin/general-settings-section.tsx :: GeneralSettingsSection": 220,
|
||||
"components/admin/glitchtip-section.tsx :: GlitchtipSection": 233,
|
||||
"components/admin/glitchtip-section.tsx :: GlitchtipSection": 231,
|
||||
"components/admin/host-metrics-chart.tsx :: HostMetricsChart": 232,
|
||||
"components/admin/invite-tokens-section.tsx :: InviteTokensSection": 525,
|
||||
"components/admin/login-overview-section.tsx :: LoginOverviewSection": 503,
|
||||
"components/admin/pulse-day-mean-card.tsx :: PulseDayMeanCard": 305,
|
||||
"components/admin/reminders-section.tsx :: RemindersSection": 309,
|
||||
"components/admin/pulse-day-mean-card.tsx :: PulseDayMeanCard": 302,
|
||||
"components/admin/reminders-section.tsx :: RemindersSection": 306,
|
||||
"components/admin/user-management-section.tsx :: UserManagementSection": 630,
|
||||
"components/admin/web-push-vapid-section.tsx :: WebPushVapidSection": 217,
|
||||
"components/auth/claim-profile.tsx :: ClaimProfile": 429,
|
||||
"components/auth/mfa-login-step.tsx :: MfaLoginStep": 217,
|
||||
"components/charts/compliance-heatmap.tsx :: ComplianceHeatmap": 325,
|
||||
"components/charts/health-chart.tsx :: HealthChart": 470,
|
||||
"components/charts/health-chart.tsx :: HealthChart": 462,
|
||||
"components/charts/medication-compliance-chart.tsx :: MedicationComplianceChart": 543,
|
||||
"components/charts/mood-chart.tsx :: MoodChart": 896,
|
||||
"components/charts/mood-heatmap.tsx :: MoodHeatmap": 296,
|
||||
@@ -75,7 +75,7 @@
|
||||
"components/command-palette/command-palette.tsx :: PaletteBody": 333,
|
||||
"components/custom-metrics/custom-metric-chart.tsx :: CustomMetricChart": 228,
|
||||
"components/custom-metrics/custom-metric-detail.tsx :: CustomMetricDetail": 277,
|
||||
"components/custom-metrics/custom-metric-form.tsx :: CustomMetricForm": 214,
|
||||
"components/custom-metrics/custom-metric-form.tsx :: CustomMetricForm": 210,
|
||||
"components/custom-metrics/custom-metric-history-list.tsx :: CustomMetricHistoryList": 298,
|
||||
"components/cycle/bbt-chart.tsx :: BbtChart": 244,
|
||||
"components/cycle/cycle-calendar.tsx :: CycleCalendar": 292,
|
||||
@@ -99,10 +99,10 @@
|
||||
"components/documents/sources/document-source-picker.tsx :: DocumentSourcePicker": 563,
|
||||
"components/illness/log-day-sheet.tsx :: LogDaySheet": 265,
|
||||
"components/illness/new-episode-sheet.tsx :: NewEpisodeSheet": 264,
|
||||
"components/insights/coach-panel/chat-bubble.tsx :: ChatBubbleImpl": 419,
|
||||
"components/insights/coach-panel/chat-bubble.tsx :: ChatBubbleImpl": 417,
|
||||
"components/insights/coach-panel/coach-conversation.tsx :: CoachConversation": 1002,
|
||||
"components/insights/coach-panel/coach-input.tsx :: CoachInput": 259,
|
||||
"components/insights/coach-panel/coach-model-picker.tsx :: CoachModelPicker": 212,
|
||||
"components/insights/coach-panel/coach-model-picker.tsx :: CoachModelPicker": 208,
|
||||
"components/insights/coach-panel/conversations-panel.tsx :: ConversationsPanel": 317,
|
||||
"components/insights/coach-panel/message-thread.tsx :: MessageThread": 390,
|
||||
"components/insights/coach-panel/sources-rail.tsx :: SourcesRail": 203,
|
||||
@@ -147,19 +147,19 @@
|
||||
"components/measurements/measurement-form.tsx :: MeasurementForm": 496,
|
||||
"components/medications/detail/efficacy/efficacy-chart.tsx :: EfficacyChart": 361,
|
||||
"components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 767,
|
||||
"components/medications/dose-history-add-dialog.tsx :: LedgerAddDialog": 223,
|
||||
"components/medications/dose-history-add-dialog.tsx :: LedgerAddDialog": 221,
|
||||
"components/medications/dose-strength-curve.tsx :: DoseStrengthCurve": 266,
|
||||
"components/medications/glp1-medication-card.tsx :: Glp1MedicationCard": 405,
|
||||
"components/medications/injection-site-picker.tsx :: InjectionSitePicker": 287,
|
||||
"components/medications/intake-history-list-v2.tsx :: IntakeHistoryListV2": 403,
|
||||
"components/medications/intake-import-dialog.tsx :: IntakeImportDialog": 208,
|
||||
"components/medications/intake-import-dialog.tsx :: IntakeImportDialog": 201,
|
||||
"components/medications/log-intake-dialog.tsx :: LogIntakeDialog": 267,
|
||||
"components/medications/medication-card.tsx :: MedicationCard": 413,
|
||||
"components/medications/medication-table.tsx :: MedicationTableRowItem": 377,
|
||||
"components/medications/scheduling/natural-language-extractor.tsx :: NaturalLanguageExtractor": 214,
|
||||
"components/medications/scheduling/natural-language-extractor.tsx :: NaturalLanguageExtractor": 211,
|
||||
"components/medications/scheduling/schedule-history-timeline.tsx :: EraDialog": 219,
|
||||
"components/medications/scheduling/schedule-history-timeline.tsx :: ScheduleHistoryTimeline": 254,
|
||||
"components/medications/sections/api-tokens-row.tsx :: ApiTokensRow": 296,
|
||||
"components/medications/sections/api-tokens-row.tsx :: ApiTokensRow": 294,
|
||||
"components/medications/sections/composition-section.tsx :: CompositionEditor": 362,
|
||||
"components/medications/sections/inventory-dialogs.tsx :: AddInventoryDialog": 238,
|
||||
"components/medications/sections/inventory-section.tsx :: InventorySection": 368,
|
||||
@@ -167,12 +167,12 @@
|
||||
"components/medications/wizard/medication-wizard-dialog.tsx :: WizardDialogShell": 516,
|
||||
"components/medications/wizard/steps/step3-dose.tsx :: Step3Dose": 222,
|
||||
"components/medications/wizard/steps/step8-summary.tsx :: Step8Summary": 230,
|
||||
"components/mood/manage/tag-groups-card.tsx :: TagGroupsCard": 256,
|
||||
"components/mood/manage/tag-manager-card.tsx :: TagManagerCard": 270,
|
||||
"components/mood/manage/tag-groups-card.tsx :: TagGroupsCard": 252,
|
||||
"components/mood/manage/tag-manager-card.tsx :: TagManagerCard": 261,
|
||||
"components/mood/mood-form.tsx :: MoodForm": 514,
|
||||
"components/mood/mood-list.tsx :: MoodList": 1109,
|
||||
"components/mood/mood-tag-picker.tsx :: MoodTagPicker": 257,
|
||||
"components/onboarding/confirm-screen.tsx :: ConfirmScreen": 255,
|
||||
"components/onboarding/confirm-screen.tsx :: ConfirmScreen": 251,
|
||||
"components/onboarding/getting-started-checklist.tsx :: GettingStartedChecklist": 423,
|
||||
"components/onboarding/tour.tsx :: OnboardingTour": 544,
|
||||
"components/practitioners/practitioner-list.tsx :: PractitionerList": 225,
|
||||
@@ -186,8 +186,8 @@
|
||||
"components/settings/access/managed-profile-edit-form.tsx :: ManagedProfileEditFields": 208,
|
||||
"components/settings/access/managed-profile-guardians.tsx :: ManagedProfileGuardians": 234,
|
||||
"components/settings/account-section/index.tsx :: AccountSection": 659,
|
||||
"components/settings/ai/codex-provider-form.tsx :: CodexProviderForm": 280,
|
||||
"components/settings/ai/fallback-chain-card.tsx :: FallbackChainCard": 228,
|
||||
"components/settings/ai/codex-provider-form.tsx :: CodexProviderForm": 270,
|
||||
"components/settings/ai/fallback-chain-card.tsx :: FallbackChainCard": 224,
|
||||
"components/settings/ai/runtime-actions-row.tsx :: RuntimeActionsRow": 284,
|
||||
"components/settings/api-section.tsx :: ApiTokensCard": 269,
|
||||
"components/settings/coach-nudge-card.tsx :: CoachNudgeCard": 251,
|
||||
@@ -195,7 +195,7 @@
|
||||
"components/settings/environment-section.tsx :: EnvironmentSection": 666,
|
||||
"components/settings/import-panel/apple-health-import-card.tsx :: AppleHealthImportCard": 298,
|
||||
"components/settings/import-panel/csv-import-card.tsx :: CsvImportCard": 212,
|
||||
"components/settings/import-panel/dose-history-import-card.tsx :: DoseHistoryImportCard": 225,
|
||||
"components/settings/import-panel/dose-history-import-card.tsx :: DoseHistoryImportCard": 222,
|
||||
"components/settings/import-panel/health-connect-import-card.tsx :: HealthConnectImportCard": 293,
|
||||
"components/settings/import-panel/record-csv-import-card.tsx :: RecordCsvImportSheet": 458,
|
||||
"components/settings/insights-pill-order-section.tsx :: InsightsPillOrderSection": 255,
|
||||
@@ -210,7 +210,7 @@
|
||||
"components/settings/report-selection/selection-panel.tsx :: HealthRecordExportPanel": 370,
|
||||
"components/settings/score-section.tsx :: ScoreSection": 383,
|
||||
"components/settings/security-section/passkey-list-section.tsx :: PasskeyListSection": 395,
|
||||
"components/settings/security-section/security-keys-card.tsx :: SecurityKeysCard": 288,
|
||||
"components/settings/security-section/security-keys-card.tsx :: SecurityKeysCard": 286,
|
||||
"components/settings/security-section/totp-card.tsx :: TotpCard": 533,
|
||||
"components/settings/security-sessions-card.tsx :: SecuritySessionsCard": 245,
|
||||
"components/settings/settings-shell.tsx :: SettingsShell": 254,
|
||||
@@ -222,7 +222,7 @@
|
||||
"components/symptoms/symptom-entry-form.tsx :: SymptomEntryForm": 317,
|
||||
"components/symptoms/symptoms-section.tsx :: SymptomsSection": 205,
|
||||
"components/targets/range-bar.tsx :: RangeBar": 220,
|
||||
"components/targets/target-edit-sheet.tsx :: TargetEditSheetBody": 474,
|
||||
"components/targets/target-edit-sheet.tsx :: TargetEditSheetBody": 473,
|
||||
"components/timeline/life-event-form.tsx :: LifeEventForm": 281,
|
||||
"components/timeline/timeline-chart.tsx :: ChartBody": 236,
|
||||
"components/timeline/timeline-chronicle.tsx :: TimelineChronicle": 246,
|
||||
|
||||
@@ -238,6 +238,48 @@ describe("PUT /api/insights/provider-chain", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops the effort of a gateway the person switches off", async () => {
|
||||
// A disabled gateway entry with an effort is the gateway form's holder,
|
||||
// which the picked gateway still serves behind; a switch-off is not.
|
||||
storedChain([
|
||||
{
|
||||
providerType: "openai-compatible",
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
reasoningEffort: "low",
|
||||
},
|
||||
]);
|
||||
await put([{ providerType: "openai-compatible", enabled: false }]);
|
||||
expect(persisted()).toEqual([
|
||||
{ providerType: "openai-compatible", priority: 1, enabled: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the holder's effort when the chain is saved around it", async () => {
|
||||
storedChain([
|
||||
{ providerType: "local", priority: 1, enabled: true },
|
||||
{
|
||||
providerType: "openai-compatible",
|
||||
priority: 2,
|
||||
enabled: false,
|
||||
reasoningEffort: "low",
|
||||
},
|
||||
]);
|
||||
await put([
|
||||
{ providerType: "openai-compatible", enabled: false },
|
||||
{ providerType: "local", enabled: true },
|
||||
]);
|
||||
expect(persisted()).toEqual([
|
||||
{
|
||||
providerType: "openai-compatible",
|
||||
priority: 1,
|
||||
enabled: false,
|
||||
reasoningEffort: "low",
|
||||
},
|
||||
{ providerType: "local", priority: 2, enabled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears it on null", async () => {
|
||||
storedChain([
|
||||
{
|
||||
|
||||
@@ -118,18 +118,28 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
where: { id: user.id },
|
||||
select: { aiProviderChain: true },
|
||||
});
|
||||
const storedChain = parseProviderChain(stored?.aiProviderChain ?? null);
|
||||
const storedEffort = new Map(
|
||||
parseProviderChain(stored?.aiProviderChain ?? null).map((e) => [
|
||||
e.providerType,
|
||||
e.reasoningEffort ?? null,
|
||||
]),
|
||||
storedChain.map((e) => [e.providerType, e.reasoningEffort ?? null]),
|
||||
);
|
||||
// A disabled gateway entry with an effort is the holder the gateway form
|
||||
// writes, which the picked gateway still serves behind
|
||||
// (`servesPickedGateway`). Switching the gateway off here is the person's
|
||||
// word, so its effort goes with the switch.
|
||||
const gatewayWasOn =
|
||||
storedChain.find((e) => e.providerType === "openai-compatible")?.enabled !==
|
||||
false;
|
||||
const normalised = parsed.data.chain.map((entry, idx) => {
|
||||
const reasoningEffort = isReasoningProviderType(entry.providerType)
|
||||
? entry.reasoningEffort === undefined
|
||||
? (storedEffort.get(entry.providerType) ?? null)
|
||||
: entry.reasoningEffort
|
||||
: null;
|
||||
const switchedOff =
|
||||
entry.providerType === "openai-compatible" &&
|
||||
!entry.enabled &&
|
||||
gatewayWasOn;
|
||||
const reasoningEffort =
|
||||
!isReasoningProviderType(entry.providerType) || switchedOff
|
||||
? null
|
||||
: entry.reasoningEffort === undefined
|
||||
? (storedEffort.get(entry.providerType) ?? null)
|
||||
: entry.reasoningEffort;
|
||||
return {
|
||||
providerType: entry.providerType,
|
||||
priority: idx + 1,
|
||||
|
||||
@@ -751,6 +751,24 @@ describe("PATCH /api/user/ai-provider — reasoning setting (#1126)", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("switches the gateway's entry on when the gateway form saves it as the pick", async () => {
|
||||
// The disabled holder above, written for a picked gateway, read as the
|
||||
// person switching off the provider they had just picked.
|
||||
storedChain(null);
|
||||
await patch(
|
||||
patchRequest({
|
||||
provider: "OPENAI_COMPATIBLE",
|
||||
compatReasoningEffort: "low",
|
||||
}),
|
||||
);
|
||||
const chain = written()?.aiProviderChain as Array<{
|
||||
providerType: string;
|
||||
}>;
|
||||
expect(
|
||||
chain.find((e) => e.providerType === "openai-compatible"),
|
||||
).toMatchObject({ enabled: true, reasoningEffort: "low" });
|
||||
});
|
||||
|
||||
it("does not touch the chain when Default is restated on a default chain", async () => {
|
||||
storedChain(null);
|
||||
await patch(
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
providerWorkAuthorityForRecord,
|
||||
} from "@/lib/sharing/provider-work-authority";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { z } from "zod/v4";
|
||||
import { aiProviderPatchSchema } from "@/lib/validations/ai-provider";
|
||||
import {
|
||||
chainWithReasoningEffort,
|
||||
@@ -191,6 +192,51 @@ export const GET = apiHandler(async () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #1126 — the chain to write after the Local or gateway form set its entry's
|
||||
* reasoning effort, or undefined when nothing changed. Stored on the entry in
|
||||
* `aiProviderChain` rather than in a column of their own, so the setting is
|
||||
* per entry and a chain can mix a thinking model with one that does not know
|
||||
* the field. See `chainWithReasoningEffort` for how a default chain, or a
|
||||
* provider absent from the chain, holds the value.
|
||||
*/
|
||||
async function chainAfterReasoningSave(
|
||||
userId: string,
|
||||
body: Pick<
|
||||
z.infer<typeof aiProviderPatchSchema>,
|
||||
"localReasoningEffort" | "compatReasoningEffort"
|
||||
>,
|
||||
pickedNow: unknown,
|
||||
): Promise<unknown> {
|
||||
const current = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { aiProviderChain: true, aiProvider: true },
|
||||
});
|
||||
let chain: unknown = current?.aiProviderChain ?? null;
|
||||
if (body.localReasoningEffort !== undefined) {
|
||||
chain = chainWithReasoningEffort(chain, "local", body.localReasoningEffort);
|
||||
}
|
||||
if (body.compatReasoningEffort !== undefined) {
|
||||
// Saving the gateway form picks the gateway, so its entry is on: a
|
||||
// disabled one read as the person switching off what they had just picked.
|
||||
const picked = pickedNow !== undefined ? pickedNow : current?.aiProvider;
|
||||
chain = chainWithReasoningEffort(
|
||||
chain,
|
||||
"openai-compatible",
|
||||
body.compatReasoningEffort,
|
||||
String(picked ?? "").toUpperCase() === "OPENAI_COMPATIBLE",
|
||||
);
|
||||
}
|
||||
// Written only when it changed: restating Default on a chain that was
|
||||
// never customised must not freeze today's default chain into the row.
|
||||
const before = serializeProviderChain(
|
||||
parseProviderChain(current?.aiProviderChain ?? null),
|
||||
);
|
||||
return serializeProviderChain(parseProviderChain(chain)) !== before
|
||||
? chain
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "user.ai-provider.update" } });
|
||||
@@ -337,42 +383,16 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
updates.aiResponseTimeoutSeconds = body.responseTimeoutSeconds;
|
||||
}
|
||||
|
||||
// ── #1126 — reasoning settings of the Local and gateway entries ──
|
||||
// Stored on the entry in `aiProviderChain` rather than in a column of their
|
||||
// own, so the setting is per entry and a chain can mix a thinking model with
|
||||
// one that does not know the field. See `chainWithReasoningEffort` for how a
|
||||
// default chain, or a provider absent from the chain, holds the value.
|
||||
const reasoningTouched =
|
||||
body.localReasoningEffort !== undefined ||
|
||||
body.compatReasoningEffort !== undefined;
|
||||
if (reasoningTouched) {
|
||||
const current = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { aiProviderChain: true },
|
||||
});
|
||||
let chain: unknown = current?.aiProviderChain ?? null;
|
||||
if (body.localReasoningEffort !== undefined) {
|
||||
chain = chainWithReasoningEffort(
|
||||
chain,
|
||||
"local",
|
||||
body.localReasoningEffort,
|
||||
);
|
||||
}
|
||||
if (body.compatReasoningEffort !== undefined) {
|
||||
chain = chainWithReasoningEffort(
|
||||
chain,
|
||||
"openai-compatible",
|
||||
body.compatReasoningEffort,
|
||||
);
|
||||
}
|
||||
// Written only when it changed: restating Default on a chain that was
|
||||
// never customised must not freeze today's default chain into the row.
|
||||
const before = serializeProviderChain(
|
||||
parseProviderChain(current?.aiProviderChain ?? null),
|
||||
const chain = await chainAfterReasoningSave(
|
||||
user.id,
|
||||
body,
|
||||
updates.aiProvider,
|
||||
);
|
||||
if (serializeProviderChain(parseProviderChain(chain)) !== before) {
|
||||
updates.aiProviderChain = chain;
|
||||
}
|
||||
if (chain !== undefined) updates.aiProviderChain = chain;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0 && !reasoningTouched) {
|
||||
|
||||
@@ -19,6 +19,7 @@ vi.mock("@/lib/crypto", () => ({
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { chainWithReasoningEffort } from "../provider-chain";
|
||||
import type { ProviderWorkAuthority } from "@/lib/sharing/provider-work-authority";
|
||||
|
||||
import {
|
||||
@@ -238,16 +239,36 @@ describe("probeProviderChain", () => {
|
||||
]);
|
||||
expect(presence.managedBy).toBe("user");
|
||||
|
||||
// Once the person saved a chain without it, the chain is their word.
|
||||
// A saved chain without it says nothing about the gateway, and neither
|
||||
// does the switched-off entry the gateway form wrote for its reasoning
|
||||
// effort.
|
||||
for (const aiProviderChain of [
|
||||
[{ providerType: "local", enabled: true, priority: 1 }],
|
||||
chainWithReasoningEffort(null, "openai-compatible", "low"),
|
||||
]) {
|
||||
load(row({ ...gateway, aiProviderChain }));
|
||||
expect(
|
||||
(await probeProviderChain("u1", OWNER)).entries.map(
|
||||
(e) => e.providerType,
|
||||
),
|
||||
).toEqual(["openai-compatible"]);
|
||||
}
|
||||
|
||||
// Switched off in the chain, the gateway stays off.
|
||||
load(
|
||||
row({
|
||||
...gateway,
|
||||
aiProviderChain: [
|
||||
{ providerType: "local", enabled: true, priority: 1 },
|
||||
{ providerType: "openai-compatible", enabled: false, priority: 2 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect((await probeProviderChain("u1", OWNER)).entries).toEqual([]);
|
||||
expect(await resolveProviderAvailability("u1")).toEqual({
|
||||
aiAvailable: false,
|
||||
managedBy: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("carries the person's in-browser OCR opt-in", async () => {
|
||||
|
||||
@@ -17,6 +17,10 @@ vi.mock("@/lib/ai/codex-oauth", () => ({
|
||||
}));
|
||||
|
||||
import { resolveProviderChain } from "../provider";
|
||||
import {
|
||||
chainWithReasoningEffort,
|
||||
PROVIDER_CHAIN_DEFAULT,
|
||||
} from "../provider-chain";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptCodexCreds } from "@/lib/ai/codex-oauth";
|
||||
|
||||
@@ -207,41 +211,66 @@ describe("resolveProviderChain", () => {
|
||||
|
||||
expect(await resolveProviderChain("user-1")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("serves the picked gateway, under its own name, on a chain never saved", async () => {
|
||||
const gatewayRow = {
|
||||
aiProvider: "OPENAI_COMPATIBLE",
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: null,
|
||||
aiCompatBaseUrl: "https://gateway.example/v1",
|
||||
aiCompatKeyEncrypted: null,
|
||||
aiCompatModel: "llama-3",
|
||||
role: "USER",
|
||||
};
|
||||
userRows(
|
||||
{ ...gatewayRow, aiProviderChain: null },
|
||||
{
|
||||
codexAccessTokenEncrypted: null,
|
||||
codexRefreshTokenEncrypted: null,
|
||||
codexTokenExpiresAt: null,
|
||||
codexConnectionStatus: "disconnected",
|
||||
},
|
||||
);
|
||||
operator({
|
||||
adminAiKeyEncrypted: null,
|
||||
});
|
||||
describe("resolveProviderChain and the picked gateway", () => {
|
||||
const gatewayRow = {
|
||||
aiProvider: "OPENAI_COMPATIBLE",
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: null,
|
||||
aiCompatBaseUrl: "https://gateway.example/v1",
|
||||
aiCompatKeyEncrypted: null,
|
||||
aiCompatModel: "llama-3",
|
||||
role: "USER",
|
||||
};
|
||||
const DEFAULT_SAVED = PROVIDER_CHAIN_DEFAULT.map((entry) => ({ ...entry }));
|
||||
|
||||
const chain = await resolveProviderChain("user-1");
|
||||
expect(chain.map((c) => c.providerType)).toEqual(["openai-compatible"]);
|
||||
/** Serve `chain` as the record's saved chain; Codex is not connected. */
|
||||
async function resolveWith(chain: unknown) {
|
||||
vi.mocked(prisma.user.findUnique).mockImplementation((async (args: {
|
||||
select?: Record<string, unknown>;
|
||||
}) =>
|
||||
args.select?.codexAccessTokenEncrypted
|
||||
? {
|
||||
codexAccessTokenEncrypted: null,
|
||||
codexRefreshTokenEncrypted: null,
|
||||
codexTokenExpiresAt: null,
|
||||
codexConnectionStatus: "disconnected",
|
||||
}
|
||||
: { ...gatewayRow, aiProviderChain: chain }) as never);
|
||||
operator({ adminAiKeyEncrypted: null });
|
||||
return (await resolveProviderChain("user-1")).map((c) => c.providerType);
|
||||
}
|
||||
|
||||
// A saved chain without the gateway is the person's word.
|
||||
userRows({
|
||||
...gatewayRow,
|
||||
aiProviderChain: [{ providerType: "local", priority: 1, enabled: true }],
|
||||
});
|
||||
expect(await resolveProviderChain("user-1")).toEqual([]);
|
||||
it.each([
|
||||
["never saved", null],
|
||||
["saved with the default content", DEFAULT_SAVED],
|
||||
[
|
||||
"reset to the default and saved",
|
||||
JSON.parse(JSON.stringify(DEFAULT_SAVED)),
|
||||
],
|
||||
["reordered without the gateway", [...DEFAULT_SAVED].reverse()],
|
||||
[
|
||||
"holding a reasoning effort the gateway form wrote switched off",
|
||||
chainWithReasoningEffort(null, "openai-compatible", "low"),
|
||||
],
|
||||
[
|
||||
"holding a reasoning effort the gateway form writes now",
|
||||
chainWithReasoningEffort(null, "openai-compatible", "low", true),
|
||||
],
|
||||
])("serves the gateway on a chain %s", async (_case, chain) => {
|
||||
expect(await resolveWith(chain)).toEqual(["openai-compatible"]);
|
||||
});
|
||||
|
||||
it("serves nothing once the person switched the gateway off in the chain", async () => {
|
||||
expect(
|
||||
await resolveWith([
|
||||
...DEFAULT_SAVED,
|
||||
{ providerType: "openai-compatible", priority: 6, enabled: false },
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -181,27 +181,30 @@ export function serializeProviderChain(
|
||||
* reasoning effort from that provider's own settings form.
|
||||
*
|
||||
* The setting lives on the chain entry, so a chain that was never customised
|
||||
* is materialised from the default first. A provider that is not in the chain
|
||||
* at all gets a DISABLED entry to hold the value: the setting must reach the
|
||||
* provider when it is resolved as the selected one, and a disabled entry is
|
||||
* inert everywhere else (every reader filters on `enabled`). Clearing on an
|
||||
* absent entry writes nothing new.
|
||||
* is materialised from the default first. With `enable`, which the gateway
|
||||
* form passes because saving it picks the gateway, the entry is switched on,
|
||||
* a new one included. Otherwise a provider that is not in the chain gets a
|
||||
* DISABLED entry to hold the value, inert everywhere but where the provider
|
||||
* serves as the picked one (`servesPickedGateway`). Clearing on an absent
|
||||
* entry writes nothing new.
|
||||
*/
|
||||
export function chainWithReasoningEffort(
|
||||
rawChain: unknown,
|
||||
providerType: ReasoningProviderType,
|
||||
effort: ReasoningEffort | null,
|
||||
enable = false,
|
||||
): ProviderChainEntry[] {
|
||||
const chain = parseProviderChain(rawChain).map((e) => ({ ...e }));
|
||||
const entry = chain.find((e) => e.providerType === providerType);
|
||||
if (entry) {
|
||||
if (effort) entry.reasoningEffort = effort;
|
||||
else delete entry.reasoningEffort;
|
||||
if (enable) entry.enabled = true;
|
||||
} else if (effort) {
|
||||
chain.push({
|
||||
providerType,
|
||||
priority: Math.max(0, ...chain.map((e) => e.priority)) + 1,
|
||||
enabled: false,
|
||||
enabled: enable,
|
||||
reasoningEffort: effort,
|
||||
});
|
||||
}
|
||||
|
||||
+14
-14
@@ -25,7 +25,6 @@ import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import { isLocalAiHostAllowed } from "./local-host-allowlist";
|
||||
import {
|
||||
parseProviderChain,
|
||||
PROVIDER_CHAIN_DEFAULT,
|
||||
PROVIDER_CHAIN_TYPES,
|
||||
type ProviderChainType,
|
||||
} from "./provider-chain";
|
||||
@@ -812,7 +811,7 @@ async function readBoundSettings(userId: string): Promise<{
|
||||
* usable credential (e.g. chain says `anthropic` but
|
||||
* `aiAnthropicKeyEncrypted` is null).
|
||||
* 3. Append the operator's central Codex behind the person's opt-in.
|
||||
* 4. Still empty: the picked gateway on a chain never saved
|
||||
* 4. Still empty: the picked gateway, unless the chain switched it off
|
||||
* (`servesPickedGateway`), else nothing. Every caller treats an
|
||||
* empty result as "no provider"; none falls back on its own.
|
||||
*/
|
||||
@@ -964,24 +963,25 @@ function providerOrigin(providerType: string): ProviderManagedBy {
|
||||
}
|
||||
|
||||
/**
|
||||
* What a chain that resolves empty falls back to. A saved chain is the
|
||||
* person's own word: an entry they switched off or removed stays out, so an
|
||||
* empty saved chain means no provider. A chain they never saved is the
|
||||
* default one (`parseProviderChain` hands back `PROVIDER_CHAIN_DEFAULT`
|
||||
* itself), which holds every provider a person can pick except the
|
||||
* OpenAI-compatible gateway; so the gateway they picked under Settings, AI
|
||||
* serves when nothing in the default chain does. Resolution and the presence
|
||||
* probe both read this, so they cannot disagree about it.
|
||||
* What a chain that resolves empty falls back to: the OpenAI-compatible
|
||||
* gateway picked under Settings, AI, unless the chain switched it off. The
|
||||
* default chain has no entry for the gateway, so a chain never saved, reset
|
||||
* to the default, or reordered without it says nothing about the gateway.
|
||||
* Nor does a disabled gateway entry that carries a reasoning effort: that is
|
||||
* the holder the gateway form wrote for the setting
|
||||
* (`chainWithReasoningEffort`), not a switch somebody turned off; the chain
|
||||
* editor drops the effort of a gateway it switches off. Resolution and the
|
||||
* presence probe both read this, so they cannot disagree about it.
|
||||
*/
|
||||
function servesPickedGateway(row: {
|
||||
aiProvider: string | null;
|
||||
aiProviderChain: unknown;
|
||||
}): boolean {
|
||||
return (
|
||||
parseProviderChain(row.aiProviderChain ?? null) ===
|
||||
PROVIDER_CHAIN_DEFAULT &&
|
||||
row.aiProvider?.toUpperCase() === "OPENAI_COMPATIBLE"
|
||||
if (row.aiProvider?.toUpperCase() !== "OPENAI_COMPATIBLE") return false;
|
||||
const gateway = parseProviderChain(row.aiProviderChain ?? null).find(
|
||||
(entry) => entry.providerType === "openai-compatible",
|
||||
);
|
||||
return !gateway || gateway.enabled || gateway.reasoningEffort !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user