Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import {
DEFAULT_SERVER_SETTINGS,
DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER,
ModelSelection,
ProjectId,
ProjectScript,
Expand Down Expand Up @@ -688,6 +689,90 @@ it.layer(NodeServices.layer)("server settings", (it) => {
const settings = yield* serverSettings.getSettings;

assert.equal(settings.textGenerationModelSelection.instanceId, "claudeAgent");
assert.equal(
settings.textGenerationModelSelection.model,
DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[ProviderDriverKind.make("claudeAgent")],
);
}).pipe(Effect.provide(makeServerSettingsLayer())),
);

it.effect(
"keeps the product text-generation model when the thread default is a built-in slug",
() =>
Effect.gen(function* () {
const serverConfig = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
// A built-in chat model must not replace the cheap text-generation slug.
yield* fileSystem.writeFileString(
serverConfig.settingsPath,
'{"providerInstances":{"codex":{"driver":"codex","enabled":false,"config":{}},"claudeAgent":{"driver":"claudeAgent","config":{"customModels":["z-ai/glm-5.3-flash"]}}},"defaultModelSelection":{"instanceId":"claudeAgent","model":"claude-opus-4-6","options":[{"id":"effort","value":"high"}]}}',
);

const settings = yield* serverSettings.getSettings;
const productModel =
DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[ProviderDriverKind.make("claudeAgent")];

assert.isDefined(productModel);
assert.deepEqual(
settings.textGenerationModelSelection,
createModelSelection(ProviderInstanceId.make("claudeAgent"), productModel),
);
}).pipe(Effect.provide(makeServerSettingsLayer())),
);

it.effect("uses a custom thread default when falling back for text generation", () =>
Effect.gen(function* () {
const serverConfig = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
yield* fileSystem.writeFileString(
serverConfig.settingsPath,
'{"providerInstances":{"codex":{"driver":"codex","enabled":false,"config":{}},"claudeAgent":{"driver":"claudeAgent","config":{"customModels":["z-ai/glm-5.3-flash"]}}},"defaultModelSelection":{"instanceId":"claudeAgent","model":"z-ai/glm-5.3-flash","options":[{"id":"effort","value":"low"}]}}',
);

const settings = yield* serverSettings.getSettings;

assert.deepEqual(
settings.textGenerationModelSelection,
createModelSelection(ProviderInstanceId.make("claudeAgent"), "z-ai/glm-5.3-flash", [
{ id: "effort", value: "low" },
]),
);
}).pipe(Effect.provide(makeServerSettingsLayer())),
);

it.effect("uses a configured custom model when falling back for text generation", () =>
Effect.gen(function* () {
const serverConfig = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
yield* fileSystem.writeFileString(
serverConfig.settingsPath,
'{"providerInstances":{"codex":{"driver":"codex","enabled":false,"config":{}},"claudeAgent":{"driver":"claudeAgent","config":{"customModels":["z-ai/glm-5.3-flash"]}}}}',
);

const settings = yield* serverSettings.getSettings;

assert.equal(settings.textGenerationModelSelection.instanceId, "claudeAgent");
assert.equal(settings.textGenerationModelSelection.model, "z-ai/glm-5.3-flash");
}).pipe(Effect.provide(makeServerSettingsLayer())),
);

it.effect("uses a legacy custom model when falling back for text generation", () =>
Effect.gen(function* () {
const serverConfig = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
yield* fileSystem.writeFileString(
serverConfig.settingsPath,
'{"providerInstances":{"codex":{"driver":"codex","enabled":false,"config":{}}},"providers":{"claudeAgent":{"customModels":["z-ai/glm-5.3-flash"]}}}',
);

const settings = yield* serverSettings.getSettings;

assert.equal(settings.textGenerationModelSelection.instanceId, "claudeAgent");
assert.equal(settings.textGenerationModelSelection.model, "z-ai/glm-5.3-flash");
}).pipe(Effect.provide(makeServerSettingsLayer())),
);

Expand Down
88 changes: 81 additions & 7 deletions apps/server/src/serverSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import * as Stream from "effect/Stream";
import * as SqlClient from "effect/unstable/sql/SqlClient";
import { writeFileStringAtomically } from "./atomicWrite.ts";
import * as ServerConfig from "./config.ts";
import { createModelSelection, readCustomModelEntries } from "@t3tools/shared/model";
import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";
import {
Expand Down Expand Up @@ -338,6 +339,81 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings
: fallbackTextGenerationProvider(settings);
}

/**
* Custom-model slugs configured on this instance.
*
* An explicit `providerInstances[id].config.customModels` array wins, including
* an empty one. Otherwise the legacy `providers[driver].customModels` list is
* used for the default instance only, matching the Settings UI.
*/
function configuredCustomModelSlugs(
settings: ServerSettings,
driver: ProviderDriverKind,
instanceId: ProviderInstanceId,
): ReadonlyArray<string> {
const instanceConfig = settings.providerInstances[instanceId]?.config;
if (
instanceConfig !== null &&
typeof instanceConfig === "object" &&
"customModels" in instanceConfig
) {
const value = (instanceConfig as { customModels?: unknown }).customModels;
if (Array.isArray(value)) {
return readCustomModelEntries(value).map((entry) => entry.slug);
}
}
if (instanceId !== ProviderInstanceId.make(driver)) {
return [];
}
const legacy = Object.entries(settings.providers).find(([key]) => key === driver)?.[1];
return readCustomModelEntries(legacy?.customModels).map((entry) => entry.slug);
}

/**
* Model for one-shot text generation when the stored selection's instance is
* unusable. Commit, PR, branch, and title generation all read this value.
*
* The product slug (`DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER`, Claude's
* `claude-haiku-4-5`) stays the fallback when this instance has no custom
* model, when that slug is itself configured, or when `defaultModelSelection`
* for this instance is a built-in chat model. Title, branch, and commit
* generation keep the cheap product default for users who only picked a chat
* model.
*
* The hardcoded slug is replaced only on the broken fallback: the instance
* has custom models that do not include the product slug, and it did not pin
* a built-in thread default. A matching custom `defaultModelSelection` wins,
* options included; otherwise the first custom slug is used.
*/
function fallbackTextGenerationModelSelection(
settings: ServerSettings,
driver: ProviderDriverKind,
instanceId: ProviderInstanceId,
): ModelSelection {
const productModel =
DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ??
DEFAULT_MODEL_BY_PROVIDER[driver] ??
DEFAULT_TEXT_GENERATION_MODEL;
const productSelection = createModelSelection(instanceId, productModel);
const customModels = configuredCustomModelSlugs(settings, driver, instanceId);
if (customModels.length === 0 || customModels.includes(productModel)) {
return productSelection;
}

const threadDefault = settings.defaultModelSelection;
if (threadDefault?.instanceId === instanceId) {
if (!customModels.includes(threadDefault.model)) {
return productSelection;
}
return createModelSelection(instanceId, threadDefault.model, threadDefault.options);
}

const customModel = customModels[0];
return customModel === undefined
? productSelection
: createModelSelection(instanceId, customModel);
}

function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings {
// Same precedence as isModelSelectionProviderEnabled: an explicit provider
// instance wins over the legacy providers map, which decodes to defaults
Expand All @@ -353,13 +429,11 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting

return {
...settings,
textGenerationModelSelection: {
instanceId: ProviderInstanceId.make(fallback),
model:
DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ??
DEFAULT_MODEL_BY_PROVIDER[fallback] ??
DEFAULT_TEXT_GENERATION_MODEL,
} satisfies ModelSelection,
textGenerationModelSelection: fallbackTextGenerationModelSelection(
settings,
fallback,
ProviderInstanceId.make(fallback),
),
};
}

Expand Down
Loading