| author | |
| committer | |
| log | 3062701c24ac6b6bde3d238fab8689e48ce2396b |
| tree | a7014e1f1b430ca5c190bc5bdb88a6eecb71ab28 |
| parent | c08093c2b136353bc755459febf3e8afd46bf50e |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>4 files changed, 61 insertions(+), 3 deletions(-)
apps/server/src/serverSettings.test.ts+38| ... | @@ -813,6 +813,44 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -813,6 +813,44 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 813 | }).pipe(Effect.provide(layerServerSettings())), | 813 | }).pipe(Effect.provide(layerServerSettings())), |
| 814 | ); | 814 | ); |
| 815 | 815 | ||
| 816 | // A backend behind a custom model may refuse the built-in default (#12651). | ||
| 817 | it.effect("falls back to the thread default when it is a custom model on that instance", () => | ||
| 818 | Effect.gen(function* () { | ||
| 819 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | ||
| 820 | const claude = ProviderInstanceId.make("claudeAgent"); | ||
| 821 | const providerInstances = { | ||
| 822 | [ProviderInstanceId.make("codex")]: { | ||
| 823 | driver: ProviderDriverKind.make("codex"), | ||
| 824 | enabled: false, | ||
| 825 | config: {}, | ||
| 826 | }, | ||
| 827 | [claude]: { | ||
| 828 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 829 | config: { customModels: ["z-ai/glm-5.3-flash"] }, | ||
| 830 | }, | ||
| 831 | }; | ||
| 832 | |||
| 833 | const custom = yield* serverSettings.updateSettings({ | ||
| 834 | providerInstances, | ||
| 835 | defaultModelSelection: { | ||
| 836 | instanceId: claude, | ||
| 837 | model: "z-ai/glm-5.3-flash", | ||
| 838 | options: [{ id: "thinking", value: false }], | ||
| 839 | }, | ||
| 840 | }); | ||
| 841 | assert.deepEqual(custom.textGenerationModelSelection, { | ||
| 842 | instanceId: claude, | ||
| 843 | model: "z-ai/glm-5.3-flash", | ||
| 844 | options: [{ id: "thinking", value: false }], | ||
| 845 | }); | ||
| 846 | |||
| 847 | const builtIn = yield* serverSettings.updateSettings({ | ||
| 848 | defaultModelSelection: { instanceId: claude, model: "claude-fable-5-1" }, | ||
| 849 | }); | ||
| 850 | assert.equal(builtIn.textGenerationModelSelection.model, "claude-haiku-5-5"); | ||
| 851 | }).pipe(Effect.provide(layerServerSettings())), | ||
| 852 | ); | ||
| 853 | |||
| 816 | it.effect("preserves enabled text generation selections for non-built-in drivers", () => | 854 | it.effect("preserves enabled text generation selections for non-built-in drivers", () => |
| 817 | Effect.gen(function* () { | 855 | Effect.gen(function* () { |
| 818 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | 856 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
apps/server/src/serverSettings.ts+1| ... | @@ -484,6 +484,7 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting | ... | @@ -484,6 +484,7 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting |
| 484 | resolveProviderInstanceEnabled( | 484 | resolveProviderInstanceEnabled( |
| 485 | settings.providerInstances[instanceId] ?? { driver, config: {} }, | 485 | settings.providerInstances[instanceId] ?? { driver, config: {} }, |
| 486 | ), | 486 | ), |
| 487 | { defaultModelSelection: settings.defaultModelSelection }, | ||
| 487 | ); | 488 | ); |
| 488 | return fallback === null ? settings : { ...settings, textGenerationModelSelection: fallback }; | 489 | return fallback === null ? settings : { ...settings, textGenerationModelSelection: fallback }; |
| 489 | } | 490 | } |
packages/shared/src/projectSettings.ts+4-1| ... | @@ -199,7 +199,10 @@ function resolveProjectOverrides( | ... | @@ -199,7 +199,10 @@ function resolveProjectOverrides( |
| 199 | const fallback = fallbackTextGenerationModelSelection( | 199 | const fallback = fallbackTextGenerationModelSelection( |
| 200 | settings.providerInstances, | 200 | settings.providerInstances, |
| 201 | (instanceId) => resolveProjectProviderInstanceEnabled(settings, projectId, instanceId), | 201 | (instanceId) => resolveProjectProviderInstanceEnabled(settings, projectId, instanceId), |
| 202 | { includeConfiguredInstances: true }, | 202 | { |
| 203 | includeConfiguredInstances: true, | ||
| 204 | defaultModelSelection: effective.defaultModelSelection as ModelSelection | null, | ||
| 205 | }, | ||
| 203 | ); | 206 | ); |
| 204 | if (fallback !== null) effective.textGenerationModelSelection = fallback; | 207 | if (fallback !== null) effective.textGenerationModelSelection = fallback; |
| 205 | } | 208 | } |
packages/shared/src/serverSettings.ts+18-2| ... | @@ -23,7 +23,7 @@ import * as Option from "effect/Option"; | ... | @@ -23,7 +23,7 @@ import * as Option from "effect/Option"; |
| 23 | import * as Schema from "effect/Schema"; | 23 | import * as Schema from "effect/Schema"; |
| 24 | import { deepMerge } from "./Struct.ts"; | 24 | import { deepMerge } from "./Struct.ts"; |
| 25 | import { fromLenientJson } from "./schemaJson.ts"; | 25 | import { fromLenientJson } from "./schemaJson.ts"; |
| 26 | import { createModelSelection } from "./model.ts"; | 26 | import { createModelSelection, readCustomModelEntries } from "./model.ts"; |
| 27 | import { | 27 | import { |
| 28 | getBackgroundActivityBaseProfile, | 28 | getBackgroundActivityBaseProfile, |
| 29 | normalizeBackgroundActivitySettings, | 29 | normalizeBackgroundActivitySettings, |
| ... | @@ -95,6 +95,11 @@ const TEXT_GENERATION_FALLBACK_DRIVERS = [ | ... | @@ -95,6 +95,11 @@ const TEXT_GENERATION_FALLBACK_DRIVERS = [ |
| 95 | * `includeConfiguredInstances` is set, configured instances of those | 95 | * `includeConfiguredInstances` is set, configured instances of those |
| 96 | * drivers. `null` when none is. | 96 | * drivers. `null` when none is. |
| 97 | * | 97 | * |
| 98 | * The model is the driver's cheap default, unless `defaultModelSelection` | ||
| 99 | * (the thread default) is one of that instance's custom models. A custom | ||
| 100 | * model usually means a backend that may refuse the built-in default, and | ||
| 101 | * the thread default, with its options, is known to work there. | ||
| 102 | * | ||
| 98 | * `includeConfiguredInstances` is for the project-scoped caller only: a | 103 | * `includeConfiguredInstances` is for the project-scoped caller only: a |
| 99 | * project that disabled every built-in instance should still be able to | 104 | * project that disabled every built-in instance should still be able to |
| 100 | * fall back to one it configured itself. The machine-level caller leaves it | 105 | * fall back to one it configured itself. The machine-level caller leaves it |
| ... | @@ -104,7 +109,10 @@ const TEXT_GENERATION_FALLBACK_DRIVERS = [ | ... | @@ -104,7 +109,10 @@ const TEXT_GENERATION_FALLBACK_DRIVERS = [ |
| 104 | export function fallbackTextGenerationModelSelection( | 109 | export function fallbackTextGenerationModelSelection( |
| 105 | providerInstances: ServerSettings["providerInstances"], | 110 | providerInstances: ServerSettings["providerInstances"], |
| 106 | isEnabled: (instanceId: ProviderInstanceId, driver: ProviderDriverKind) => boolean, | 111 | isEnabled: (instanceId: ProviderInstanceId, driver: ProviderDriverKind) => boolean, |
| 107 | options?: { readonly includeConfiguredInstances?: boolean }, | 112 | options?: { |
| 113 | readonly includeConfiguredInstances?: boolean; | ||
| 114 | readonly defaultModelSelection?: ModelSelection | null; | ||
| 115 | }, | ||
| 108 | ): ModelSelection | null { | 116 | ): ModelSelection | null { |
| 109 | const candidates = [ | 117 | const candidates = [ |
| 110 | ...TEXT_GENERATION_FALLBACK_DRIVERS.map( | 118 | ...TEXT_GENERATION_FALLBACK_DRIVERS.map( |
| ... | @@ -121,6 +129,14 @@ export function fallbackTextGenerationModelSelection( | ... | @@ -121,6 +129,14 @@ export function fallbackTextGenerationModelSelection( |
| 121 | const match = candidates.find(([instanceId, driver]) => isEnabled(instanceId, driver)); | 129 | const match = candidates.find(([instanceId, driver]) => isEnabled(instanceId, driver)); |
| 122 | if (match === undefined) return null; | 130 | if (match === undefined) return null; |
| 123 | const [instanceId, driver] = match; | 131 | const [instanceId, driver] = match; |
| 132 | const threadDefault = options?.defaultModelSelection; | ||
| 133 | const config = providerInstances[instanceId]?.config as { customModels?: unknown } | undefined; | ||
| 134 | if ( | ||
| 135 | threadDefault?.instanceId === instanceId && | ||
| 136 | readCustomModelEntries(config?.customModels).some((entry) => entry.slug === threadDefault.model) | ||
| 137 | ) { | ||
| 138 | return threadDefault; | ||
| 139 | } | ||
| 124 | return { | 140 | return { |
| 125 | instanceId, | 141 | instanceId, |
| 126 | model: | 142 | model: |