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
23 changes: 22 additions & 1 deletion site/src/pages/AgentsPage/AgentSettingsCompactionPage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useMutation, useQueries, useQuery, useQueryClient } from "react-query";
import {
deleteUserCompactionThreshold,
organizationChatModelOverrides,
updateUserCompactionThreshold,
userChatProviderConfigs,
userCompactionThresholds,
Expand All @@ -19,6 +20,25 @@ const AgentSettingsCompactionPage: FC = () => {
);
const providerConfigsQuery = useQuery(userChatProviderConfigs());
const thresholdsQuery = useQuery(userCompactionThresholds());
// Only refines the displayed trigger point; a failed request falls back
// to the chat model's own window.
const modelOverrideQueries = useQueries({
queries: organizations.map((organization) =>
organizationChatModelOverrides(organization.id),
),
});
const compactionModelIDByOrganization = new Map<string, string>();
for (const [index, query] of modelOverrideQueries.entries()) {
const compactionOverride = query.data?.overrides.find(
(override) => override.context === "compaction",
);
if (compactionOverride) {
compactionModelIDByOrganization.set(
organizations[index].id,
compactionOverride.model_config_id,
);
}
}
const saveThresholdMutation = useMutation(
updateUserCompactionThreshold(queryClient),
);
Expand All @@ -44,6 +64,7 @@ const AgentSettingsCompactionPage: FC = () => {
models={organizationModels.models}
providerTypeByID={providerTypeByID}
organizations={organizations}
compactionModelIDByOrganization={compactionModelIDByOrganization}
modelsError={organizationModels.error ?? organizationModels.partialError}
isLoadingModels={organizationModels.isLoading}
thresholds={thresholdsQuery.data?.thresholds}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface AgentSettingsCompactionPageViewProps {
models: readonly TypesGen.ChatModel[] | undefined;
providerTypeByID: ReadonlyMap<string, string>;
organizations: readonly TypesGen.Organization[];
compactionModelIDByOrganization?: ReadonlyMap<string, string>;
modelsError: unknown;
isLoadingModels: boolean;
thresholds: readonly TypesGen.UserChatCompactionThreshold[] | undefined;
Expand All @@ -25,6 +26,7 @@ export const AgentSettingsCompactionPageView: FC<
models,
providerTypeByID,
organizations,
compactionModelIDByOrganization,
modelsError,
isLoadingModels,
thresholds,
Expand All @@ -43,6 +45,7 @@ export const AgentSettingsCompactionPageView: FC<
models={models ?? []}
providerTypeByID={providerTypeByID}
organizations={organizations}
compactionModelIDByOrganization={compactionModelIDByOrganization}
modelsError={modelsError}
isLoadingModels={isLoadingModels}
thresholds={thresholds}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,36 @@ export const Default: Story = {
},
};

export const ContextWindowTracksDraft: Story = {
name: "Context Window Tracks Draft",
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const gpt4oInput = await canvas.findByRole("textbox", {
name: /GPT-4o compaction threshold/i,
});

// 128K window: default 80% compacts at ~102K, the draft moves it to ~64K.
await userEvent.type(gpt4oInput, "50");
},
};

export const CompactionOverrideShrinksWindow: Story = {
name: "Compaction Override Shrinks Window",
args: {
// The organization summarizes with the 16K model, so both enabled
// models show a 16K compaction window instead of their own.
compactionModelIDByOrganization: new Map([
[MockChatModel.organization_id, "model-3"],
]),
},
};

export const UnknownContextWindow: Story = {
args: {
models: [{ ...mockModels[0], context_limit: 0 }],
},
};

export const EmptyOrganizationDisplayNameFallsBackToName: Story = {
args: {
organizations: [organizationWithEmptyDisplayName],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,23 @@ import {
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { formatContextLimit } from "#/modules/aiModels/ModelSelector";
import { ProviderIcon } from "#/modules/aiModels/ProviderIcon";
import { formatProviderLabel } from "#/utils/aiProviders";
import {
compactionTriggerTokens,
resolveCompactionContextLimit,
} from "../utils/modelOptions";

interface UserCompactionThresholdSettingsProps {
models: readonly TypesGen.ChatModel[];
providerTypeByID: ReadonlyMap<string, string>;
organizations: readonly TypesGen.Organization[];
/**
* Organization ID to the model config the organization routes compaction
* through. Missing entries mean the chat model summarizes itself.
*/
compactionModelIDByOrganization?: ReadonlyMap<string, string>;
modelsError?: unknown;
isLoadingModels?: boolean;
thresholds: readonly TypesGen.UserChatCompactionThreshold[] | undefined;
Expand All @@ -48,6 +58,8 @@ interface UserCompactionThresholdSettingsProps {
onResetThreshold: (modelId: string) => Promise<unknown>;
}

const noCompactionOverrides: ReadonlyMap<string, string> = new Map();

const parseThresholdDraft = (value: string): number | null => {
const trimmedValue = value.trim();
if (!/^\d+$/.test(trimmedValue)) {
Expand Down Expand Up @@ -80,6 +92,7 @@ export const UserCompactionThresholdSettings: FC<
models,
providerTypeByID,
organizations,
compactionModelIDByOrganization = noCompactionOverrides,
modelsError,
isLoadingModels,
thresholds,
Expand Down Expand Up @@ -330,6 +343,7 @@ export const UserCompactionThresholdSettings: FC<
<TableHeader>
<TableRow>
<TableHead className="text-content-secondary">Model</TableHead>
<TableHead className="w-0 whitespace-nowrap">Context</TableHead>
<TableHead className="w-0 whitespace-nowrap">Default</TableHead>
<TableHead className="w-0 whitespace-nowrap">
Threshold
Expand Down Expand Up @@ -361,6 +375,21 @@ export const UserCompactionThresholdSettings: FC<
const organizationName =
organizationNameByID.get(modelConfig.organization_id) ??
modelConfig.organization_id;
// Prefer the typed draft so the trigger point tracks
// what the user is about to save.
const effectiveThreshold =
parsedDraftValue ??
existingOverride ??
modelConfig.compression_threshold;
const contextLimit = resolveCompactionContextLimit(
modelConfig,
models,
compactionModelIDByOrganization,
);
const triggerTokens = compactionTriggerTokens(
contextLimit,
effectiveThreshold,
Comment thread
jakehwll marked this conversation as resolved.
);

return (
<TableRow key={modelConfig.id}>
Expand Down Expand Up @@ -388,6 +417,20 @@ export const UserCompactionThresholdSettings: FC<
</p>
)}
</TableCell>
<TableCell className="w-0 whitespace-nowrap tabular-nums">
{contextLimit > 0 ? (
<div className="flex flex-col">
<span>{formatContextLimit(contextLimit)} tokens</span>
{triggerTokens !== undefined && (
<span className="text-2xs text-content-secondary">
Compacts at ~{formatContextLimit(triggerTokens)}
</span>
)}
</div>
) : (
<span className="text-content-secondary">Unknown</span>
)}
</TableCell>
<TableCell className="w-0 whitespace-nowrap tabular-nums">
{modelConfig.compression_threshold}%
</TableCell>
Expand Down Expand Up @@ -484,7 +527,7 @@ export const UserCompactionThresholdSettings: FC<
</TableBody>
<TableFooter className="bg-transparent">
<TableRow className="border-0">
<TableCell colSpan={3} className="border-0 p-0">
<TableCell colSpan={4} className="border-0 p-0">
<div className="mt-2 flex h-6 items-center justify-end gap-2 px-3">
{isSavedVisible ? (
<TemporarySavedState />
Expand Down
88 changes: 88 additions & 0 deletions site/src/pages/AgentsPage/utils/modelOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
MockChatProviderConfig,
} from "#/testHelpers/chatModels";
import {
compactionTriggerTokens,
countConfiguredProviderConfigs,
filterModelsWithEnabledProvider,
formatProviderLabel,
Expand All @@ -25,6 +26,7 @@ import {
NIL_UUID,
providerInfoByIDFromUserConfigs,
providerTypeByIDFromUserConfigs,
resolveCompactionContextLimit,
resolveCompactionThreshold,
resolveModelOptionId,
resolveModelSelector,
Expand Down Expand Up @@ -1210,3 +1212,89 @@ describe("resolveCompactionThreshold", () => {
expect(resolveCompactionThreshold("missing", [], models)).toBe(undefined);
});
});

describe("resolveCompactionContextLimit", () => {
const chatModel = createConfig({
id: "chat",
ai_provider_id: "prov-openai",
model: "gpt-4o",
context_limit: 1_000,
});
const smallSummarizer = createConfig({
id: "small",
ai_provider_id: "prov-openai",
model: "gpt-4o-mini",
context_limit: 100,
});
const largeSummarizer = createConfig({
id: "large",
ai_provider_id: "prov-anthropic",
model: "claude",
context_limit: 5_000,
});
const models = [chatModel, smallSummarizer, largeSummarizer];

it("uses the chat model window without an override", () => {
expect(resolveCompactionContextLimit(chatModel, models, new Map())).toBe(
1_000,
);
});

it("uses the override window when it is smaller", () => {
expect(
resolveCompactionContextLimit(
chatModel,
models,
new Map([[testOrganizationID, "small"]]),
),
).toBe(100);
});

it("keeps the chat model window when the override is larger", () => {
expect(
resolveCompactionContextLimit(
chatModel,
models,
new Map([[testOrganizationID, "large"]]),
),
).toBe(1_000);
});

it("falls back to the override window when the chat window is unknown", () => {
expect(
resolveCompactionContextLimit(
{ ...chatModel, context_limit: 0 },
models,
new Map([[testOrganizationID, "small"]]),
),
).toBe(100);
});

it("ignores overrides for other organizations and unknown models", () => {
expect(
resolveCompactionContextLimit(
chatModel,
models,
new Map([
["other-org", "small"],
[testOrganizationID, "missing"],
]),
),
).toBe(1_000);
});
});

describe("compactionTriggerTokens", () => {
it("scales the window by the threshold", () => {
expect(compactionTriggerTokens(128_000, 80)).toBe(102_400);
expect(compactionTriggerTokens(1_000, 70)).toBe(700);
});

it("returns undefined when the window is unknown", () => {
expect(compactionTriggerTokens(0, 80)).toBe(undefined);
});

it("returns undefined at 100% because compaction never triggers", () => {
expect(compactionTriggerTokens(128_000, 100)).toBe(undefined);
});
});
36 changes: 36 additions & 0 deletions site/src/pages/AgentsPage/utils/modelOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,42 @@ export function resolveCompactionThreshold(
return model.compression_threshold;
}

/**
* Context window the compaction trigger is measured against. Mirrors the
* backend: when the organization routes compaction to an override model,
* the history must also fit that model's window, so the smaller of the
* two limits wins. Returns 0 when neither limit is known.
*/
export function resolveCompactionContextLimit(
model: TypesGen.ChatModel,
models: readonly TypesGen.ChatModel[],
compactionModelIDByOrganization: ReadonlyMap<string, string>,
): number {
const chatLimit = model.context_limit > 0 ? model.context_limit : 0;
const overrideID = compactionModelIDByOrganization.get(model.organization_id);
const overrideLimit =
models.find((candidate) => candidate.id === overrideID)?.context_limit ?? 0;
if (overrideLimit > 0 && (chatLimit <= 0 || overrideLimit < chatLimit)) {
return overrideLimit;
}
return chatLimit;
}

/**
* Token count at which compaction triggers for the given context window and
* threshold, or undefined when the window is unknown or compaction is
* disabled (100%).
*/
export function compactionTriggerTokens(
contextLimit: number,
thresholdPercent: number,
): number | undefined {
if (contextLimit <= 0 || thresholdPercent >= 100) {
return undefined;
}
return Math.round((contextLimit * thresholdPercent) / 100);
}

export const getModelSelectorPlaceholder = (
modelOptions: readonly ModelSelectorOption[],
isModelCatalogLoading: boolean,
Expand Down
Loading