Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ release/
apps/web/.playwright
apps/web/playwright-report
apps/web/src/components/__screenshots__
.vitest-*
.vitest-*
__screenshots__/
115 changes: 111 additions & 4 deletions apps/web/src/appSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ import {
AppSettingsSchema,
DEFAULT_TIMESTAMP_FORMAT,
getAppModelOptions,
getCustomModelOptionsByProvider,
getCustomModelsByProvider,
getCustomModelsForProvider,
getDefaultCustomModelsForProvider,
MODEL_PROVIDER_SETTINGS,
normalizeCustomModelSlugs,
patchCustomModels,
resolveAppModelSelection,
} from "./appSettings";

Expand Down Expand Up @@ -66,13 +72,35 @@ describe("getAppModelOptions", () => {

describe("resolveAppModelSelection", () => {
it("preserves saved custom model slugs instead of falling back to the default", () => {
expect(resolveAppModelSelection("codex", ["galapagos-alpha"], "galapagos-alpha")).toBe(
"galapagos-alpha",
);
expect(
resolveAppModelSelection(
"codex",
{ codex: ["galapagos-alpha"], claudeAgent: [] },
"galapagos-alpha",
),
).toBe("galapagos-alpha");
});

it("falls back to the provider default when no model is selected", () => {
expect(resolveAppModelSelection("codex", [], "")).toBe("gpt-5.4");
expect(resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "")).toBe("gpt-5.4");
});

it("resolves display names through the shared resolver", () => {
expect(resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "GPT-5.3 Codex")).toBe(
"gpt-5.3-codex",
);
});

it("resolves aliases through the shared resolver", () => {
expect(resolveAppModelSelection("claudeAgent", { codex: [], claudeAgent: [] }, "sonnet")).toBe(
"claude-sonnet-4-6",
);
});

it("resolves transient selected custom models included in app model options", () => {
expect(
resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "custom/selected-model"),
).toBe("custom/selected-model");
});
});

Expand All @@ -90,6 +118,85 @@ describe("provider-specific custom models", () => {
});
});

describe("provider-indexed custom model settings", () => {
const settings = {
customCodexModels: ["custom/codex-model"],
customClaudeModels: ["claude/custom-opus"],
} as const;

it("exports one provider config per provider", () => {
expect(MODEL_PROVIDER_SETTINGS.map((config) => config.provider)).toEqual([
"codex",
"claudeAgent",
]);
});

it("reads custom models for each provider", () => {
expect(getCustomModelsForProvider(settings, "codex")).toEqual(["custom/codex-model"]);
expect(getCustomModelsForProvider(settings, "claudeAgent")).toEqual(["claude/custom-opus"]);
});

it("reads default custom models for each provider", () => {
const defaults = {
customCodexModels: ["default/codex-model"],
customClaudeModels: ["claude/default-opus"],
} as const;

expect(getDefaultCustomModelsForProvider(defaults, "codex")).toEqual(["default/codex-model"]);
expect(getDefaultCustomModelsForProvider(defaults, "claudeAgent")).toEqual([
"claude/default-opus",
]);
});

it("patches custom models for codex", () => {
expect(patchCustomModels("codex", ["custom/codex-model"])).toEqual({
customCodexModels: ["custom/codex-model"],
});
});

it("patches custom models for claude", () => {
expect(patchCustomModels("claudeAgent", ["claude/custom-opus"])).toEqual({
customClaudeModels: ["claude/custom-opus"],
});
});

it("builds a complete provider-indexed custom model record", () => {
expect(getCustomModelsByProvider(settings)).toEqual({
codex: ["custom/codex-model"],
claudeAgent: ["claude/custom-opus"],
});
});

it("builds provider-indexed model options including custom models", () => {
const modelOptionsByProvider = getCustomModelOptionsByProvider(settings);

expect(
modelOptionsByProvider.codex.some((option) => option.slug === "custom/codex-model"),
).toBe(true);
expect(
modelOptionsByProvider.claudeAgent.some((option) => option.slug === "claude/custom-opus"),
).toBe(true);
});

it("normalizes and deduplicates custom model options per provider", () => {
const modelOptionsByProvider = getCustomModelOptionsByProvider({
customCodexModels: [" custom/codex-model ", "gpt-5.4", "custom/codex-model"],
customClaudeModels: [" sonnet ", "claude/custom-opus", "claude/custom-opus"],
});

expect(
modelOptionsByProvider.codex.filter((option) => option.slug === "custom/codex-model"),
).toHaveLength(1);
expect(modelOptionsByProvider.codex.some((option) => option.slug === "gpt-5.4")).toBe(true);
expect(
modelOptionsByProvider.claudeAgent.filter((option) => option.slug === "claude/custom-opus"),
).toHaveLength(1);
expect(
modelOptionsByProvider.claudeAgent.some((option) => option.slug === "claude-sonnet-4-6"),
).toBe(true);
});
});

describe("AppSettingsSchema", () => {
it("fills decoding defaults for persisted settings that predate newer keys", () => {
const decode = Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema));
Expand Down
119 changes: 92 additions & 27 deletions apps/web/src/appSettings.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { useCallback } from "react";
import { Option, Schema } from "effect";
import { TrimmedNonEmptyString, type ProviderKind } from "@t3tools/contracts";
import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
import {
getDefaultModel,
getModelOptions,
normalizeModelSlug,
resolveSelectableModel,
} from "@t3tools/shared/model";
import { useLocalStorage } from "./hooks/useLocalStorage";
import { EnvMode } from "./components/BranchToolbar.logic";

Expand All @@ -12,6 +17,16 @@ export const MAX_CUSTOM_MODEL_LENGTH = 256;
export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]);
export type TimestampFormat = typeof TimestampFormat.Type;
export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale";
type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels";
export type ProviderCustomModelConfig = {
provider: ProviderKind;
settingsKey: CustomModelSettingsKey;
defaultSettingsKey: CustomModelSettingsKey;
title: string;
description: string;
placeholder: string;
example: string;
};

const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {
codex: new Set(getModelOptions("codex").map((option) => option.slug)),
Expand Down Expand Up @@ -50,6 +65,27 @@ export interface AppModelOption {
}

const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});
const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConfig> = {
codex: {
provider: "codex",
settingsKey: "customCodexModels",
defaultSettingsKey: "customCodexModels",
title: "Codex",
description: "Save additional Codex model slugs for the picker and `/model` command.",
placeholder: "your-codex-model-slug",
example: "gpt-6.7-codex-ultra-preview",
},
claudeAgent: {
provider: "claudeAgent",
settingsKey: "customClaudeModels",
defaultSettingsKey: "customClaudeModels",
title: "Claude",
description: "Save additional Claude model slugs for the picker and `/model` command.",
placeholder: "your-claude-model-slug",
example: "claude-sonnet-5-0",
},
};
export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG);

export function normalizeCustomModelSlugs(
models: Iterable<string | null | undefined>,
Expand Down Expand Up @@ -87,6 +123,39 @@ function normalizeAppSettings(settings: AppSettings): AppSettings {
customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"),
};
}

export function getCustomModelsForProvider(
settings: Pick<AppSettings, CustomModelSettingsKey>,
provider: ProviderKind,
): readonly string[] {
return settings[PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey];
}

export function getDefaultCustomModelsForProvider(
defaults: Pick<AppSettings, CustomModelSettingsKey>,
provider: ProviderKind,
): readonly string[] {
return defaults[PROVIDER_CUSTOM_MODEL_CONFIG[provider].defaultSettingsKey];
}

export function patchCustomModels(
provider: ProviderKind,
models: string[],
): Partial<Pick<AppSettings, CustomModelSettingsKey>> {
return {
[PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]: models,
};
}

export function getCustomModelsByProvider(
settings: Pick<AppSettings, CustomModelSettingsKey>,
): Record<ProviderKind, readonly string[]> {
return {
codex: getCustomModelsForProvider(settings, "codex"),
claudeAgent: getCustomModelsForProvider(settings, "claudeAgent"),
};
}

export function getAppModelOptions(
provider: ProviderKind,
customModels: readonly string[],
Expand All @@ -98,6 +167,7 @@ export function getAppModelOptions(
isCustom: false,
}));
const seen = new Set(options.map((option) => option.slug));
const trimmedSelectedModel = selectedModel?.trim().toLowerCase();

for (const slug of normalizeCustomModelSlugs(customModels, provider)) {
if (seen.has(slug)) {
Expand All @@ -113,7 +183,14 @@ export function getAppModelOptions(
}

const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider);
if (normalizedSelectedModel && !seen.has(normalizedSelectedModel)) {
const selectedModelMatchesExistingName =
typeof trimmedSelectedModel === "string" &&
options.some((option) => option.name.toLowerCase() === trimmedSelectedModel);
if (
normalizedSelectedModel &&
!seen.has(normalizedSelectedModel) &&
!selectedModelMatchesExistingName
) {
options.push({
slug: normalizedSelectedModel,
name: normalizedSelectedModel,
Expand All @@ -126,34 +203,22 @@ export function getAppModelOptions(

export function resolveAppModelSelection(
provider: ProviderKind,
customModels: readonly string[],
customModels: Record<ProviderKind, readonly string[]>,
selectedModel: string | null | undefined,
): string {
const options = getAppModelOptions(provider, customModels, selectedModel);
const trimmedSelectedModel = selectedModel?.trim();
if (trimmedSelectedModel) {
const direct = options.find((option) => option.slug === trimmedSelectedModel);
if (direct) {
return direct.slug;
}

const byName = options.find(
(option) => option.name.toLowerCase() === trimmedSelectedModel.toLowerCase(),
);
if (byName) {
return byName.slug;
}
}

const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider);
if (!normalizedSelectedModel) {
return getDefaultModel(provider);
}
const customModelsForProvider = customModels[provider];
const options = getAppModelOptions(provider, customModelsForProvider, selectedModel);
return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider);
}

return (
options.find((option) => option.slug === normalizedSelectedModel)?.slug ??
getDefaultModel(provider)
);
export function getCustomModelOptionsByProvider(
settings: Pick<AppSettings, CustomModelSettingsKey>,
): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> {
const customModelsByProvider = getCustomModelsByProvider(settings);
return {
codex: getAppModelOptions("codex", customModelsByProvider.codex),
claudeAgent: getAppModelOptions("claudeAgent", customModelsByProvider.claudeAgent),
};
}

export function useAppSettings() {
Expand Down
Loading