| author | |
| committer | |
| log | 7a08fcf2e832da5969aeb373c46d5249b4e820b7 |
| tree | c182dd50d087b546014d11037d32a3c7a16967a5 |
| parent | 19d47408a53181446a02fac967846cc15d77d5b8 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 141 insertions(+), 5 deletions(-)
apps/server/src/serverSettings.test.ts+29| ... | ... | @@ -142,6 +142,35 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 142 | 142 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| 143 | 143 | ); |
| 144 | 144 | |
| 145 | it.effect("drops stale text generation options when resetting model selection", () => | |
| 146 | Effect.gen(function* () { | |
| 147 | const serverSettings = yield* ServerSettingsService; | |
| 148 | ||
| 149 | yield* serverSettings.updateSettings({ | |
| 150 | textGenerationModelSelection: { | |
| 151 | provider: "codex", | |
| 152 | model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, | |
| 153 | options: { | |
| 154 | reasoningEffort: "high", | |
| 155 | fastMode: true, | |
| 156 | }, | |
| 157 | }, | |
| 158 | }); | |
| 159 | ||
| 160 | const next = yield* serverSettings.updateSettings({ | |
| 161 | textGenerationModelSelection: { | |
| 162 | provider: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.provider, | |
| 163 | model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, | |
| 164 | }, | |
| 165 | }); | |
| 166 | ||
| 167 | assert.deepEqual(next.textGenerationModelSelection, { | |
| 168 | provider: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.provider, | |
| 169 | model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, | |
| 170 | }); | |
| 171 | }).pipe(Effect.provide(makeServerSettingsLayer())), | |
| 172 | ); | |
| 173 | ||
| 145 | 174 | it.effect("trims provider path settings when updates are applied", () => |
| 146 | 175 | Effect.gen(function* () { |
| 147 | 176 | const serverSettings = yield* ServerSettingsService; |
apps/server/src/serverSettings.ts+18-2| ... | ... | @@ -42,6 +42,7 @@ import * as Semaphore from "effect/Semaphore"; |
| 42 | 42 | import { ServerConfig } from "./config"; |
| 43 | 43 | import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; |
| 44 | 44 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 45 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | |
| 45 | 46 | |
| 46 | 47 | export interface ServerSettingsShape { |
| 47 | 48 | /** Start the settings runtime and attach file watching. */ |
| ... | ... | @@ -80,7 +81,20 @@ export class ServerSettingsService extends Context.Service< |
| 80 | 81 | getSettings: Ref.get(currentSettingsRef), |
| 81 | 82 | updateSettings: (patch) => |
| 82 | 83 | Ref.get(currentSettingsRef).pipe( |
| 83 | Effect.map((currentSettings) => deepMerge(currentSettings, patch)), | |
| 84 | Effect.flatMap((currentSettings) => | |
| 85 | Schema.decodeEffect(ServerSettings)( | |
| 86 | applyServerSettingsPatch(currentSettings, patch), | |
| 87 | ).pipe( | |
| 88 | Effect.mapError( | |
| 89 | (cause) => | |
| 90 | new ServerSettingsError({ | |
| 91 | settingsPath: "<memory>", | |
| 92 | detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, | |
| 93 | cause, | |
| 94 | }), | |
| 95 | ), | |
| 96 | ), | |
| 97 | ), | |
| 84 | 98 | Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), |
| 85 | 99 | ), |
| 86 | 100 | streamChanges: Stream.empty, |
| ... | ... | @@ -314,7 +328,9 @@ const makeServerSettings = Effect.gen(function* () { |
| 314 | 328 | writeSemaphore.withPermits(1)( |
| 315 | 329 | Effect.gen(function* () { |
| 316 | 330 | const current = yield* getSettingsFromCache; |
| 317 | const next = yield* Schema.decodeEffect(ServerSettings)(deepMerge(current, patch)).pipe( | |
| 331 | const next = yield* Schema.decodeEffect(ServerSettings)( | |
| 332 | applyServerSettingsPatch(current, patch), | |
| 333 | ).pipe( | |
| 318 | 334 | Effect.mapError( |
| 319 | 335 | (cause) => |
| 320 | 336 | new ServerSettingsError({ |
apps/web/src/hooks/useSettings.ts+2-2| ... | ... | @@ -19,7 +19,7 @@ import { |
| 19 | 19 | } from "@t3tools/contracts/settings"; |
| 20 | 20 | import { ensureLocalApi } from "~/localApi"; |
| 21 | 21 | import { Struct } from "effect"; |
| 22 | import { deepMerge } from "@t3tools/shared/Struct"; | |
| 22 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | |
| 23 | 23 | import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState"; |
| 24 | 24 | |
| 25 | 25 | const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; |
| ... | ... | @@ -154,7 +154,7 @@ export function useUpdateSettings() { |
| 154 | 154 | if (Object.keys(serverPatch).length > 0) { |
| 155 | 155 | const currentServerConfig = getServerConfig(); |
| 156 | 156 | if (currentServerConfig) { |
| 157 | applySettingsUpdated(deepMerge(currentServerConfig.settings, serverPatch)); | |
| 157 | applySettingsUpdated(applyServerSettingsPatch(currentServerConfig.settings, serverPatch)); | |
| 158 | 158 | } |
| 159 | 159 | // Fire-and-forget RPC — push will reconcile on success |
| 160 | 160 | void ensureLocalApi().server.updateSettings(serverPatch); |
packages/shared/src/serverSettings.test.ts+59| ... | ... | @@ -1,5 +1,7 @@ |
| 1 | import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; | |
| 1 | 2 | import { describe, expect, it } from "vitest"; |
| 2 | 3 | import { |
| 4 | applyServerSettingsPatch, | |
| 3 | 5 | extractPersistedServerObservabilitySettings, |
| 4 | 6 | normalizePersistedServerSettingString, |
| 5 | 7 | parsePersistedServerObservabilitySettings, |
| ... | ... | @@ -50,4 +52,61 @@ describe("serverSettings helpers", () => { |
| 50 | 52 | otlpMetricsUrl: undefined, |
| 51 | 53 | }); |
| 52 | 54 | }); |
| 55 | ||
| 56 | it("replaces text generation selection when provider/model are provided", () => { | |
| 57 | const current = { | |
| 58 | ...DEFAULT_SERVER_SETTINGS, | |
| 59 | textGenerationModelSelection: { | |
| 60 | provider: "codex" as const, | |
| 61 | model: "gpt-5.4-mini", | |
| 62 | options: { | |
| 63 | reasoningEffort: "high" as const, | |
| 64 | fastMode: true, | |
| 65 | }, | |
| 66 | }, | |
| 67 | }; | |
| 68 | ||
| 69 | expect( | |
| 70 | applyServerSettingsPatch(current, { | |
| 71 | textGenerationModelSelection: { | |
| 72 | provider: "codex", | |
| 73 | model: "gpt-5.4-mini", | |
| 74 | }, | |
| 75 | }).textGenerationModelSelection, | |
| 76 | ).toEqual({ | |
| 77 | provider: "codex", | |
| 78 | model: "gpt-5.4-mini", | |
| 79 | }); | |
| 80 | }); | |
| 81 | ||
| 82 | it("still deep merges text generation selection when only options are provided", () => { | |
| 83 | const current = { | |
| 84 | ...DEFAULT_SERVER_SETTINGS, | |
| 85 | textGenerationModelSelection: { | |
| 86 | provider: "codex" as const, | |
| 87 | model: "gpt-5.4-mini", | |
| 88 | options: { | |
| 89 | reasoningEffort: "high" as const, | |
| 90 | fastMode: true, | |
| 91 | }, | |
| 92 | }, | |
| 93 | }; | |
| 94 | ||
| 95 | expect( | |
| 96 | applyServerSettingsPatch(current, { | |
| 97 | textGenerationModelSelection: { | |
| 98 | options: { | |
| 99 | fastMode: false, | |
| 100 | }, | |
| 101 | }, | |
| 102 | }).textGenerationModelSelection, | |
| 103 | ).toEqual({ | |
| 104 | provider: "codex", | |
| 105 | model: "gpt-5.4-mini", | |
| 106 | options: { | |
| 107 | reasoningEffort: "high", | |
| 108 | fastMode: false, | |
| 109 | }, | |
| 110 | }); | |
| 111 | }); | |
| 53 | 112 | }); |
packages/shared/src/serverSettings.ts+33-1| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | import { ServerSettings } from "@t3tools/contracts"; | |
| 1 | import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts"; | |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | import { deepMerge } from "./Struct"; | |
| 3 | 4 | import { fromLenientJson } from "./schemaJson"; |
| 4 | 5 | |
| 5 | 6 | const ServerSettingsJson = fromLenientJson(ServerSettings); |
| ... | ... | @@ -38,3 +39,34 @@ export function parsePersistedServerObservabilitySettings( |
| 38 | 39 | return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; |
| 39 | 40 | } |
| 40 | 41 | } |
| 42 | ||
| 43 | function shouldReplaceTextGenerationModelSelection( | |
| 44 | patch: ServerSettingsPatch["textGenerationModelSelection"] | undefined, | |
| 45 | ): boolean { | |
| 46 | return Boolean(patch && (patch.provider !== undefined || patch.model !== undefined)); | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * Applies a server settings patch while treating textGenerationModelSelection as | |
| 51 | * replace-on-provider/model updates. This prevents stale nested options from | |
| 52 | * surviving a reset patch that intentionally omits options. | |
| 53 | */ | |
| 54 | export function applyServerSettingsPatch( | |
| 55 | current: ServerSettings, | |
| 56 | patch: ServerSettingsPatch, | |
| 57 | ): ServerSettings { | |
| 58 | const selectionPatch = patch.textGenerationModelSelection; | |
| 59 | const next = deepMerge(current, patch); | |
| 60 | if (!selectionPatch || !shouldReplaceTextGenerationModelSelection(selectionPatch)) { | |
| 61 | return next; | |
| 62 | } | |
| 63 | ||
| 64 | return { | |
| 65 | ...next, | |
| 66 | textGenerationModelSelection: { | |
| 67 | provider: selectionPatch.provider ?? current.textGenerationModelSelection.provider, | |
| 68 | model: selectionPatch.model ?? current.textGenerationModelSelection.model, | |
| 69 | ...(selectionPatch.options ? { options: selectionPatch.options } : {}), | |
| 70 | }, | |
| 71 | }; | |
| 72 | } |