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
7 changes: 6 additions & 1 deletion apps/web/src/components/settings/ProjectDefaultsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,12 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
? resolveProjectSettings(target.settings, null, null, null).settings
: null;

const modelDisabledReason = useScopedModelDisabledReason(settings, entries);
const modelDisabledReason = useScopedModelDisabledReason(
settings,
entries,
false,
"defaultModelSelection",
);
const effortDisabledReason = selection
? modelDisabledReason(selection.instanceId, selection.model)
: null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,12 @@ export function SourceControlWritingSettingsSection() {
activeSelection.model,
);
const writerModelDisabledReason = useScopedModelDisabledReason(settings, writerEntries, true);
const reviewGuideModelDisabledReason = useScopedModelDisabledReason(
settings,
instanceEntries,
true,
"reviewGuideModelSelection",
);
const writerInstanceEntry = writerEntries.find(
(entry) => entry.instanceId === activeSelection.instanceId,
);
Expand Down Expand Up @@ -353,9 +359,9 @@ export function SourceControlWritingSettingsSection() {
triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
triggerAriaLabel="Review guide model"
{...(mixedReviewGuideModel ? { triggerLabel: "Mixed" } : {})}
getModelDisabledReason={writerModelDisabledReason}
getModelDisabledReason={reviewGuideModelDisabledReason}
onInstanceModelChange={(instanceId, model) => {
const reason = writerModelDisabledReason(instanceId, model);
const reason = reviewGuideModelDisabledReason(instanceId, model);
if (reason) {
toastManager.add({
type: "error",
Expand Down Expand Up @@ -405,7 +411,7 @@ export function SourceControlWritingSettingsSection() {
const seed = textGeneratorForSelection(settings, activeSelection.instanceId)
? reviewGuideSelection
: activeSelection;
const reason = writerModelDisabledReason(seed.instanceId, seed.model);
const reason = reviewGuideModelDisabledReason(seed.instanceId, seed.model);
if (reason) {
toastManager.add({
type: "error",
Expand Down
108 changes: 108 additions & 0 deletions apps/web/src/components/settings/scopedModelSelection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import {
type ModelSelection,
type ProviderInstanceId,
type ServerProvider,
type ServerSettings,
textGeneratorForSelection,
} from "@t3tools/contracts";
import * as Equal from "effect/Equal";

export const SCOPED_MODEL_SETTING_KEYS = [
"defaultModelSelection",
"fallbackModelSelection",
"textGenerationModelSelection",
"sourceControlWriterModelSelection",
"reviewGuideModelSelection",
"speechPostProcessingModelSelection",
] as const;
export type ScopedModelSettingKey = (typeof SCOPED_MODEL_SETTING_KEYS)[number];

export const isScopedModelSettingKey = (key: string): key is ScopedModelSettingKey =>
(SCOPED_MODEL_SETTING_KEYS as readonly string[]).includes(key);

export const supportsScopedTextGenerator = (key: ScopedModelSettingKey) =>
key === "textGenerationModelSelection" ||
key === "sourceControlWriterModelSelection" ||
key === "speechPostProcessingModelSelection";

export const scopedTextGenerator = (
settings: Pick<ServerSettings, "textGenerators">,
instanceId: ProviderInstanceId,
key: ScopedModelSettingKey,
) =>
key === "defaultModelSelection" || key === "fallbackModelSelection"
? undefined
: textGeneratorForSelection(settings, instanceId);

export interface ScopedModelEnvironment {
readonly providers: readonly ServerProvider[];
readonly settings: Pick<ServerSettings, "textGenerators">;
}

/** Instance IDs and names are machine-local; only a reported ChatGPT login identifies the account. */
const chatGptAccount = ({ driver, auth }: ServerProvider) =>
driver === "codex" &&
auth.status === "authenticated" &&
auth.type === "chatgpt" &&
auth.email &&
auth.workspaceId
? `${auth.email.toLowerCase()}\n${auth.workspaceId}`
: null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

const find = (providers: readonly ServerProvider[], instanceId: ProviderInstanceId) =>
providers.find((provider) => provider.instanceId === instanceId);

/** Keeps an ID the destination has; remaps an absent one only through a unique ChatGPT account. */
export function resolveScopedModelInstanceId(
instanceId: ProviderInstanceId,
sourceEnvironment: Pick<ScopedModelEnvironment, "providers">,
destination: ScopedModelEnvironment,
key: ScopedModelSettingKey = "defaultModelSelection",
): ProviderInstanceId | null {
if (
key === "reviewGuideModelSelection" &&
scopedTextGenerator(destination.settings, instanceId, key)
)
return null;
if (find(destination.providers, instanceId)) return instanceId;
const source = find(sourceEnvironment.providers, instanceId);
const account = source ? chatGptAccount(source) : null;
if (account === null) return null;
const matches = (providers: readonly ServerProvider[]) =>
providers.filter((provider) => chatGptAccount(provider) === account);
const [match, ...rest] = matches(destination.providers);
// A text generator takes precedence over a provider instance with its ID.
return match &&
rest.length === 0 &&
matches(sourceEnvironment.providers).length === 1 &&
scopedTextGenerator(destination.settings, match.instanceId, key) === undefined
? match.instanceId
: null;
}

export function scopedModelSelectionsEqual(
first: ModelSelection | null,
firstEnvironment: ScopedModelEnvironment,
second: ModelSelection | null,
secondEnvironment: ScopedModelEnvironment,
key: ScopedModelSettingKey = "defaultModelSelection",
): boolean {
if (first === null || second === null) return first === second;
const firstGenerator = scopedTextGenerator(firstEnvironment.settings, first.instanceId, key);
const secondGenerator = scopedTextGenerator(secondEnvironment.settings, second.instanceId, key);
if (firstGenerator || secondGenerator) {
return (
supportsScopedTextGenerator(key) &&
firstGenerator?.kind === secondGenerator?.kind &&
firstGenerator?.baseUrl === secondGenerator?.baseUrl &&
Equal.equals(first, second)
);
}
const sameInstance =
first.instanceId === second.instanceId ||
(resolveScopedModelInstanceId(first.instanceId, firstEnvironment, secondEnvironment, key) ===
second.instanceId &&
resolveScopedModelInstanceId(second.instanceId, secondEnvironment, firstEnvironment, key) ===
first.instanceId);
return sameInstance && Equal.equals({ ...first, instanceId: second.instanceId }, second);
}
211 changes: 211 additions & 0 deletions apps/web/src/components/settings/scopedSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import {
EnvironmentAuthorizationError,
EnvironmentId,
ProjectId,
ProviderDriverKind,
ProviderInstanceId,
type ServerProvider,
type ServerSettings,
} from "@t3tools/contracts";
import { describe, expect, it, vi } from "vite-plus/test";
Expand All @@ -13,6 +15,7 @@ import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";

import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping";
import {
isProjectScopedSettingKey,
persistScopedSettingsPatch,
planProjectOverridesClear,
planScopedSettingsClear,
Expand All @@ -24,6 +27,11 @@ import {
selectScopedSettingsEnvironments,
} from "./scopedSettings";
import { resolveSettingsScope } from "./settingsScope";
import {
resolveScopedModelInstanceId,
SCOPED_MODEL_SETTING_KEYS,
scopedModelSelectionsEqual,
} from "./scopedModelSelection";

function environment(
id: string,
Expand Down Expand Up @@ -809,3 +817,206 @@ describe("model selection patches at project scope", () => {
).toEqual({ ...saved, options });
});
});

describe("model selections across environments' provider instances", () => {
const account = (
instanceId: string,
auth: Partial<ServerProvider["auth"]> = {},
driver = "codex",
): ServerProvider => ({
instanceId: ProviderInstanceId.make(instanceId),
driver: ProviderDriverKind.make(driver),
enabled: true,
installed: true,
version: null,
status: "ready",
auth: {
status: "authenticated",
type: driver === "codex" ? "chatgpt" : "max",
email: "Gmail@Example.test",
workspaceId: "personal",
...auth,
},
checkedAt: "2026-11-03T00:00:00.000Z",
models: [],
slashCommands: [],
skills: [],
});
const laptopCodex = account("codex_laptop_gmail");
const serverCodex = account("codex_server_gmail", { email: "gmail@example.test" });
const providers = (laptopProviders: ServerProvider[], serverProviders: ServerProvider[]) =>
new Map([
[laptop.environmentId, laptopProviders],
[server.environmentId, serverProviders],
]);
const matching = providers([laptopCodex], [serverCodex]);
const from = (
providersByEnvironment: ReadonlyMap<EnvironmentId, ServerProvider[]>,
{ environmentId, serverConfig } = laptop,
) => ({
modelSource: {
target: { environmentId, settings: serverConfig!.settings },
providersByEnvironment,
},
});
const luna = { instanceId: laptopCodex.instanceId, model: "gpt-6-luna" };
const keys = SCOPED_MODEL_SETTING_KEYS;
const forKeys = <T>(value: T, only: readonly string[] = keys) =>
Object.fromEntries(only.map((key) => [key, value]));

it("writes each environment's own instance for every model setting", () => {
const plan = planScopedSettingsPatch(all, environments, forKeys(luna), from(matching));
expect(plan.serverWrites.map((write) => write.patch)).toEqual([
forKeys(luna),
forKeys({ ...luna, instanceId: serverCodex.instanceId }),
]);
const pending = planScopedSettingsPatch(
all,
environments,
forKeys(luna),
from(new Map([[laptop.environmentId, [laptopCodex]]])),
);
expect(pending.serverWrites).toEqual([]);
expect(pending.unavailableReason).toContain("Server");
});

it("maps project overrides to each member's own instance", () => {
const projectKeys = keys.filter(isProjectScopedSettingKey);
const plan = planScopedSettingsPatch(
project,
environments,
forKeys(luna, projectKeys),
from(matching),
);
expect(plan.serverWrites.map((write) => write.patch.projectSettingsOverrides)).toEqual([
{ [projectId]: forKeys({ ...luna, instanceId: serverCodex.instanceId }, projectKeys) },
{ [laptopProjectId]: forKeys(luna, projectKeys) },
]);
});

const a = account;
const other = { email: "other@example.test" };
const lower = { email: "gmail@example.test" };
const team = { workspaceId: "team" };
const apiKey = { type: "apiKey" };
const nameOnly = (id: string) => ({ ...a(id, { workspaceId: undefined }), displayName: "Gmail" });
const claude = "claudeAgent";
it.each([
["same ID takes precedence", [a("x"), a("l")], [a("s"), a("x")], "x"],
["the same ID on another account", [a("x", {}, claude)], [a("x", other, claude)], "x"],
["case-insensitive email", [a("l")], [a("s", lower)], "s"],
["Claude, different IDs", [a("l", {}, claude)], [a("s", {}, claude)], null],
["an API key account", [a("l", apiKey)], [a("s", apiKey)], null],
["another workspace", [a("l")], [a("s", team)], null],
["same display name, no workspace", [nameOnly("l")], [nameOnly("s")], null],
["an unauthenticated destination", [a("l")], [a("s", { status: "unknown" })], null],
["two matching destination accounts", [a("l")], [a("s"), a("s2")], null],
["two matching source accounts", [a("l"), a("l2")], [a("s")], null],
["missing from the source snapshot", [], [a("s")], null],
] as const)("resolves %s", (_name, laptopProviders, serverProviders, serverId) => {
const instanceId = laptopProviders[0]?.instanceId ?? ProviderInstanceId.make("l");
const destination = { providers: serverProviders, settings: DEFAULT_SERVER_SETTINGS };
expect(
resolveScopedModelInstanceId(instanceId, { providers: laptopProviders }, destination),
).toBe(serverId);
});

it("keeps a text generator's ID only where it reaches the same API", () => {
const id = ProviderInstanceId.make("openrouter");
const openrouter: ServerSettings["textGenerators"][ProviderInstanceId] = {
kind: "openaiCompatible",
baseUrl: "https://openrouter.ai/api/v1",
apiKey: "",
enabled: true,
};
const withGenerator = (target: typeof laptop, config?: typeof openrouter, generatorId = id) =>
environment(target.label, {
settings: { textGenerators: config ? { [generatorId]: config } : {} },
});
const selection = { instanceId: id, model: "openai/gpt-6-mini" };
const patch = forKeys(selection, [
"textGenerationModelSelection",
"speechPostProcessingModelSelection",
]);
// Generators have no provider snapshots to match accounts through.
const source = withGenerator(laptop, openrouter);
const generator = { providers: [a(id)], settings: source.serverConfig!.settings };
expect(
scopedModelSelectionsEqual(
selection,
generator,
selection,
{
...generator,
providers: [a(id, other)],
},
"textGenerationModelSelection",
),
).toBe(true);
expect(
planScopedSettingsPatch(
all,
[source, withGenerator(server, openrouter)],
forKeys(selection, [
"defaultModelSelection",
"fallbackModelSelection",
"reviewGuideModelSelection",
]),
from(providers([a(id)], [a(id, other)]), source),
).serverWrites,
).toEqual([]);
const plan = (serverGenerator?: typeof openrouter) =>
planScopedSettingsPatch(
all,
[source, withGenerator(server, serverGenerator)],
patch,
from(providers([], []), source),
);
expect(plan(openrouter).serverWrites.map((write) => write.patch)).toEqual([patch, patch]);
for (const serverGenerator of [
undefined,
{ ...openrouter, enabled: false },
{ ...openrouter, baseUrl: "https://api.openai.com/v1" },
]) {
expect(plan(serverGenerator).serverWrites).toEqual([]);
expect(plan(serverGenerator).unavailableReason).toContain("Server");
}
// A generator wins over a provider instance with its ID, so it cannot receive a matched account.
expect(
planScopedSettingsPatch(
all,
[laptop, withGenerator(server, openrouter, serverCodex.instanceId)],
{ textGenerationModelSelection: luna },
from(matching),
).serverWrites,
).toEqual([]);
});

it("compares selections by account in either order", () => {
const target = ({ environmentId }: typeof laptop, selection: object) => ({
environmentId,
settings: { ...DEFAULT_SERVER_SETTINGS, ...forKeys(selection) },
});
const x = { ...luna, instanceId: ProviderInstanceId.make("x") };
const onServer = { ...luna, instanceId: serverCodex.instanceId };
const mixed = (onLaptop: object, onServer: object, providersByEnvironment = matching) => {
const targets = [target(laptop, onLaptop), target(server, onServer)];
return [targets, [...targets].reverse()].map((ordered) =>
scopedSettingsAreMixed(ordered, keys, providersByEnvironment),
);
};
expect.soft(mixed(luna, onServer)).toEqual([false, false]);
expect.soft(mixed(luna, { ...onServer, model: "gpt-6-sol" })).toEqual([true, true]);
expect.soft(mixed(x, x, new Map())).toEqual([false, false]);
// Main writes a shared ID everywhere even when each machine reports another account.
expect.soft(mixed(x, x, providers([a("x")], [a("x", other)]))).toEqual([false, false]);
expect(
planScopedSettingsPatch(
all,
environments,
forKeys(x),
from(providers([a("x")], [a("x", other)])),
).serverWrites.map((write) => write.patch),
).toEqual([forKeys(x), forKeys(x)]);
});
});
Loading
Loading