diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index f9771fe85..e49138989 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -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, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 7b03fc37e..d95e2a6d3 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -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, diff --git a/src/app/api/insights/provider-chain/__tests__/put.test.ts b/src/app/api/insights/provider-chain/__tests__/put.test.ts index 3c7a666e5..f58ce7b78 100644 --- a/src/app/api/insights/provider-chain/__tests__/put.test.ts +++ b/src/app/api/insights/provider-chain/__tests__/put.test.ts @@ -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([ { diff --git a/src/app/api/insights/provider-chain/route.ts b/src/app/api/insights/provider-chain/route.ts index 4685916f1..3a2e15e00 100644 --- a/src/app/api/insights/provider-chain/route.ts +++ b/src/app/api/insights/provider-chain/route.ts @@ -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, diff --git a/src/app/api/user/ai-provider/__tests__/route.test.ts b/src/app/api/user/ai-provider/__tests__/route.test.ts index 481a4791f..1e0ac20f8 100644 --- a/src/app/api/user/ai-provider/__tests__/route.test.ts +++ b/src/app/api/user/ai-provider/__tests__/route.test.ts @@ -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( diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts index 09a99b994..97e5a951b 100644 --- a/src/app/api/user/ai-provider/route.ts +++ b/src/app/api/user/ai-provider/route.ts @@ -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, + "localReasoningEffort" | "compatReasoningEffort" + >, + pickedNow: unknown, +): Promise { + 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) { diff --git a/src/lib/ai/__tests__/provider-presence.test.ts b/src/lib/ai/__tests__/provider-presence.test.ts index 87b19c213..56c62d561 100644 --- a/src/lib/ai/__tests__/provider-presence.test.ts +++ b/src/lib/ai/__tests__/provider-presence.test.ts @@ -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 () => { diff --git a/src/lib/ai/__tests__/resolve-chain.test.ts b/src/lib/ai/__tests__/resolve-chain.test.ts index 1b7fcf67c..183d72c71 100644 --- a/src/lib/ai/__tests__/resolve-chain.test.ts +++ b/src/lib/ai/__tests__/resolve-chain.test.ts @@ -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; + }) => + 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([]); }); }); diff --git a/src/lib/ai/provider-chain.ts b/src/lib/ai/provider-chain.ts index 9cefda920..09fb634f5 100644 --- a/src/lib/ai/provider-chain.ts +++ b/src/lib/ai/provider-chain.ts @@ -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, }); } diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index 3e3d60cd8..45d600f9a 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -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; } /**