fix(compaction): enabled custom remote compaction

- Added provider/model remoteCompaction metadata and models.yml propagation.\n- Routed configured OpenAI-compatible compaction endpoints for custom providers.\n- Added compactionModel as a summary-only model selector that leaves the active session model unchanged.\n\nFixes #3104
This commit is contained in:
roboomp
2026-06-20 07:27:09 +00:00
parent 62756efb8f
commit b7aefe0689
12 changed files with 293 additions and 25 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Allowed configured custom OpenAI-compatible providers to use native remote compaction instead of falling back to local summarization. ([#3104](https://github.com/can1357/oh-my-pi/issues/3104))
## [16.1.5] - 2026-06-19
### Fixed
+15 -5
View File
@@ -15,7 +15,7 @@
import { ProviderHttpError } from "@oh-my-pi/pi-ai/errors";
import { parseTextSignature } from "@oh-my-pi/pi-ai/providers/openai-shared";
import { transformMessages } from "@oh-my-pi/pi-ai/providers/transform-messages";
import type { AssistantMessage, FetchImpl, Message, Model } from "@oh-my-pi/pi-ai/types";
import type { Api, AssistantMessage, FetchImpl, Message, Model } from "@oh-my-pi/pi-ai/types";
import {
getOpenAIResponsesHistoryItems,
getOpenAIResponsesHistoryPayload,
@@ -86,12 +86,22 @@ export interface RemoteCompactionResponse {
// OpenAI provider gating + endpoint resolution
// ============================================================================
function isOpenAiRemoteCompactionApi(api: Api | undefined): boolean {
return api === "openai-responses" || api === "azure-openai-responses" || api === "openai-codex-responses";
}
export function shouldUseOpenAiRemoteCompaction(model: Model): boolean {
return model.provider === "openai" || model.provider === "openai-codex";
if (model.remoteCompaction?.enabled === false) return false;
if (model.provider === "openai" || model.provider === "openai-codex") return true;
if (model.remoteCompaction?.enabled !== true) return false;
return isOpenAiRemoteCompactionApi(model.remoteCompaction.api ?? model.api);
}
function resolveOpenAiCompactEndpoint(model: Model): string {
if (model.provider === "openai-codex") {
const configuredEndpoint = model.remoteCompaction?.endpoint;
if (configuredEndpoint && configuredEndpoint.length > 0) return configuredEndpoint;
const compactionApi = model.remoteCompaction?.api ?? model.api;
if (model.provider === "openai-codex" || compactionApi === "openai-codex-responses") {
return resolveOpenAiCodexCompactEndpoint(model.baseUrl);
}
@@ -444,7 +454,6 @@ export function buildOpenAiNativeHistory(
// ============================================================================
// Endpoint requests
// ============================================================================
export async function requestOpenAiRemoteCompaction(
model: Model,
apiKey: string,
@@ -454,8 +463,9 @@ export async function requestOpenAiRemoteCompaction(
opts?: { fetch?: FetchImpl; timeoutMs?: number },
): Promise<OpenAiRemoteCompactionResponse> {
const endpoint = resolveOpenAiCompactEndpoint(model);
const requestModel = model.remoteCompaction?.model ?? model.requestModelId ?? model.id;
const request: OpenAiRemoteCompactionRequest = {
model: model.id,
model: requestModel,
input: trimOpenAiCompactInput(compactInput, model.contextWindow ?? Number.POSITIVE_INFINITY, instructions),
instructions,
};
+39 -1
View File
@@ -5,7 +5,11 @@ import {
createFileOps,
DEFAULT_COMPACTION_SETTINGS,
} from "@oh-my-pi/pi-agent-core/compaction";
import { buildOpenAiNativeHistory, requestOpenAiRemoteCompaction } from "@oh-my-pi/pi-agent-core/compaction/openai";
import {
buildOpenAiNativeHistory,
requestOpenAiRemoteCompaction,
shouldUseOpenAiRemoteCompaction,
} from "@oh-my-pi/pi-agent-core/compaction/openai";
import * as ai from "@oh-my-pi/pi-ai";
import type { AssistantMessage, FetchImpl, Model, ToolResultMessage } from "@oh-my-pi/pi-ai/types";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
@@ -231,6 +235,40 @@ describe("remote compaction input trimming", () => {
});
});
test("uses configured OpenAI-compatible compaction for custom providers", async () => {
const model = makeOpenAiModel({
provider: "cliproxy-codex",
baseUrl: "http://127.0.0.1:8317/v1",
remoteCompaction: {
enabled: true,
api: "openai-responses",
endpoint: "http://127.0.0.1:8317/v1/responses/compact",
model: "gpt-5.5",
},
});
let requestBody: unknown;
const fetchMock: FetchImpl = async (input, init) => {
expect(String(input)).toBe("http://127.0.0.1:8317/v1/responses/compact");
requestBody = JSON.parse(String(init?.body));
return new Response(
JSON.stringify({
output: [{ type: "compaction_summary", summary: "native compacted" }],
}),
);
};
expect(shouldUseOpenAiRemoteCompaction(model)).toBe(true);
await requestOpenAiRemoteCompaction(
model,
"test-key",
[{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }],
"instructions",
undefined,
{ fetch: fetchMock },
);
expect(requestBody).toMatchObject({ model: "gpt-5.5" });
});
describe("requestOpenAiRemoteCompaction abort", () => {
test("rejects when the abort signal is aborted mid-fetch", async () => {
const controller = new AbortController();
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added model metadata for provider-native remote compaction and compaction-only model selection. ([#3104](https://github.com/can1357/oh-my-pi/issues/3104))
## [16.1.7] - 2026-06-20
### Fixed
+16
View File
@@ -576,6 +576,18 @@ export type CompatOf<TApi extends Api> = TApi extends "openrouter"
? ResolvedAnthropicCompat
: undefined;
/** Provider-native compaction endpoint configuration for one model. */
export interface RemoteCompactionConfig<TApi extends Api = Api> {
/** Enables provider-native compaction for providers not enabled by built-in policy. */
enabled?: boolean;
/** Adapter family used by the configured compaction endpoint. */
api?: TApi;
/** Absolute compact endpoint URL; when omitted, the adapter derives it from the model base URL. */
endpoint?: string;
/** Model id sent to the compaction endpoint when it differs from the active model id. */
model?: string;
}
// Model interface for the unified model system
export interface Model<TApi extends Api = Api> {
id: string;
@@ -648,6 +660,10 @@ export interface Model<TApi extends Api = Api> {
preferWebsockets?: boolean;
/** Preferred model to switch to when context promotion is triggered (model id or provider/id). */
contextPromotionTarget?: string;
/** Preferred model to use only for compaction (model id or provider/id); the active session model is unchanged. */
compactionModel?: string;
/** Provider-native compaction endpoint configuration. */
remoteCompaction?: RemoteCompactionConfig<TApi>;
/** Provider-assigned priority value (lower = higher priority). */
priority?: number;
/** Canonical thinking capability metadata for this model. */
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added `models.yml` `remoteCompaction` and `compactionModel` config so custom providers can opt into provider-native compaction and run compaction on a separate model without changing the active session model. ([#3104](https://github.com/can1357/oh-my-pi/issues/3104))
## [16.1.7] - 2026-06-20
### Fixed
@@ -1,7 +1,15 @@
import { execSync } from "node:child_process";
import * as path from "node:path";
import { registerCustomApi, unregisterCustomApis } from "@oh-my-pi/pi-ai/api-registry";
import type { Api, Context, Model, ModelSpec, SimpleStreamOptions, ThinkingConfig } from "@oh-my-pi/pi-ai/types";
import type {
Api,
Context,
Model,
ModelSpec,
RemoteCompactionConfig,
SimpleStreamOptions,
ThinkingConfig,
} from "@oh-my-pi/pi-ai/types";
import type { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { isVertexExpressOpenAIUrl } from "@oh-my-pi/pi-catalog/hosts";
@@ -95,6 +103,7 @@ interface ProviderOverride {
apiKey?: string;
authHeader?: boolean;
compat?: ModelSpec<Api>["compat"];
remoteCompaction?: RemoteCompactionConfig<Api>;
transport?: Model<Api>["transport"];
}
@@ -127,7 +136,7 @@ interface ProviderOverride {
export function mergeDiscoveredModel<TApi extends Api>(
model: Model<TApi>,
existing: Model<Api> | undefined,
providerOverride?: Pick<ProviderOverride, "baseUrl" | "headers" | "transport">,
providerOverride?: Pick<ProviderOverride, "baseUrl" | "headers" | "remoteCompaction" | "transport">,
): Model<TApi> {
if (existing) {
const supportsTools = model.supportsTools ?? existing.supportsTools;
@@ -136,6 +145,10 @@ export function mergeDiscoveredModel<TApi extends Api>(
baseUrl: providerOverride?.baseUrl ?? model.baseUrl ?? existing.baseUrl,
headers: existing.headers ? { ...existing.headers, ...model.headers } : model.headers,
transport: providerOverride?.transport ?? existing.transport ?? model.transport,
remoteCompaction: mergeRemoteCompactionConfig(
existing.remoteCompaction ?? model.remoteCompaction,
providerOverride?.remoteCompaction,
),
...(supportsTools !== undefined ? { supportsTools } : {}),
compat: model.compatConfig,
} as ModelSpec<TApi>);
@@ -146,6 +159,7 @@ export function mergeDiscoveredModel<TApi extends Api>(
baseUrl: providerOverride.baseUrl ?? model.baseUrl,
headers: providerOverride.headers ? { ...model.headers, ...providerOverride.headers } : model.headers,
...(providerOverride.transport !== undefined ? { transport: providerOverride.transport } : {}),
remoteCompaction: mergeRemoteCompactionConfig(model.remoteCompaction, providerOverride.remoteCompaction),
compat: model.compatConfig,
} as ModelSpec<TApi>);
}
@@ -353,6 +367,15 @@ function mergeCompat<TBase extends object, TOverride extends object>(
return merged as TBase & TOverride;
}
function mergeRemoteCompactionConfig(
baseConfig: RemoteCompactionConfig<Api> | undefined,
overrideConfig: RemoteCompactionConfig<Api> | undefined,
): RemoteCompactionConfig<Api> | undefined {
if (!baseConfig) return overrideConfig;
if (!overrideConfig) return baseConfig;
return { ...baseConfig, ...overrideConfig };
}
/**
* Project a built model back to spec shape for the model-manager/cache
* boundary: sparse compat comes from `compatConfig`, never from the resolved
@@ -380,6 +403,8 @@ interface ModelPatch {
headers?: Record<string, string>;
compat?: ModelSpec<Api>["compat"];
contextPromotionTarget?: string;
compactionModel?: string;
remoteCompaction?: RemoteCompactionConfig<Api>;
premiumMultiplier?: number;
}
@@ -403,6 +428,10 @@ function applyModelPatch(base: Model<Api>, patch: ModelPatch, transport: ModelTr
if (patch.maxTokens !== undefined) result.maxTokens = patch.maxTokens;
if (patch.omitMaxOutputTokens !== undefined) result.omitMaxOutputTokens = patch.omitMaxOutputTokens;
if (patch.contextPromotionTarget !== undefined) result.contextPromotionTarget = patch.contextPromotionTarget;
if (patch.compactionModel !== undefined) result.compactionModel = patch.compactionModel;
if (patch.remoteCompaction !== undefined) {
result.remoteCompaction = mergeRemoteCompactionConfig(base.remoteCompaction, patch.remoteCompaction);
}
if (patch.premiumMultiplier !== undefined) result.premiumMultiplier = patch.premiumMultiplier;
if (patch.cost) {
result.cost = {
@@ -475,8 +504,6 @@ function mergeAuthHeader(
/**
* Decide whether a custom-yaml model should force OAuth-style request shaping.
* - Explicit `auth: oauth` → force on.
* - Explicit `auth: apiKey` / `auth: none` → leave unset (auto-detect by key prefix).
* - No `auth` specified and `api: anthropic-messages` → default on. Custom Anthropic
* endpoints are typically Claude-Code-style proxies (e.g. CLIProxyAPI) that expect
* the cloaked request shape regardless of how the proxy itself is authenticated.
* - Otherwise → unset.
@@ -497,6 +524,7 @@ function buildCustomModelOverlay(
authHeader: boolean | undefined,
providerCompat: ModelSpec<Api>["compat"] | undefined,
providerAuth: ProviderAuthMode | undefined,
providerRemoteCompaction: RemoteCompactionConfig<Api> | undefined,
modelDef: CustomModelDefinitionLike,
): CustomModelOverlay | undefined {
const api = modelDef.api ?? providerApi;
@@ -518,6 +546,8 @@ function buildCustomModelOverlay(
headers: mergeCustomModelHeaders(providerHeaders, modelDef.headers, authHeader, providerApiKey),
compat: mergeCompat(providerCompat, modelDef.compat),
contextPromotionTarget: modelDef.contextPromotionTarget,
compactionModel: modelDef.compactionModel,
remoteCompaction: mergeRemoteCompactionConfig(providerRemoteCompaction, modelDef.remoteCompaction),
premiumMultiplier: modelDef.premiumMultiplier,
isOAuth: resolveCustomModelIsOAuth(api, providerAuth),
};
@@ -558,6 +588,8 @@ function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuil
omitMaxOutputTokens: resolvedModel.omitMaxOutputTokens ?? reference?.omitMaxOutputTokens,
compat: mergeCompat(reference?.compatConfig, resolvedModel.compat),
contextPromotionTarget: resolvedModel.contextPromotionTarget,
compactionModel: resolvedModel.compactionModel,
remoteCompaction: resolvedModel.remoteCompaction,
premiumMultiplier: resolvedModel.premiumMultiplier,
isOAuth: resolvedModel.isOAuth,
} as ModelSpec<Api>);
@@ -1127,7 +1159,6 @@ export class ModelRegistry {
const discoverableProviders: DiscoveryProviderConfig[] = [];
const providerEntries = Object.entries(value.providers ?? {});
const configuredProviders = new Set(Object.keys(value.providers ?? {}));
for (const [providerName, providerConfig] of providerEntries) {
const resolvedProviderHeaders = resolveConfigHeaders(providerConfig.headers);
// Always set overrides when baseUrl/headers/apiKey/authHeader/compat/disableStrictTools/transport are present
@@ -1138,6 +1169,7 @@ export class ModelRegistry {
providerConfig.authHeader !== undefined ||
providerConfig.compat ||
providerConfig.disableStrictTools ||
providerConfig.remoteCompaction ||
providerConfig.transport
) {
const disableStrictCompat = providerConfig.disableStrictTools ? { disableStrictTools: true } : undefined;
@@ -1147,6 +1179,7 @@ export class ModelRegistry {
apiKey: providerConfig.apiKey,
authHeader: providerConfig.authHeader,
compat: mergeCompat(providerConfig.compat, disableStrictCompat),
remoteCompaction: providerConfig.remoteCompaction,
transport: providerConfig.transport,
});
}
@@ -1538,12 +1571,18 @@ export class ModelRegistry {
authHeader: override.authHeader ?? baseOverride?.authHeader,
headers: override.headers ? { ...(baseOverride?.headers ?? {}), ...override.headers } : baseOverride?.headers,
compat: override.compat ? mergeCompat(baseOverride?.compat, override.compat) : baseOverride?.compat,
remoteCompaction: mergeRemoteCompactionConfig(baseOverride?.remoteCompaction, override.remoteCompaction),
transport: override.transport ?? baseOverride?.transport,
};
}
#applyProviderTransportOverride<T extends { baseUrl?: string; headers?: Record<string, string> }>(
#applyProviderTransportOverride<
T extends { baseUrl?: string; headers?: Record<string, string>; remoteCompaction?: RemoteCompactionConfig<Api> },
>(
entry: T,
override: Pick<ProviderOverride, "baseUrl" | "headers" | "authHeader" | "apiKey" | "transport">,
override: Pick<
ProviderOverride,
"baseUrl" | "headers" | "authHeader" | "apiKey" | "remoteCompaction" | "transport"
>,
): T {
const headers = mergeAuthHeader(
override.headers ? { ...entry.headers, ...override.headers } : entry.headers,
@@ -1557,6 +1596,7 @@ export class ModelRegistry {
// Preserve the model's existing transport when the override omits one;
// providers without a `transport` field keep the default per-API dispatch.
...(override.transport !== undefined ? { transport: override.transport } : {}),
remoteCompaction: mergeRemoteCompactionConfig(entry.remoteCompaction, override.remoteCompaction),
};
}
#applyRuntimeProviderOverrides(models: Model<Api>[]): Model<Api>[] {
@@ -1641,7 +1681,6 @@ export class ModelRegistry {
#parseModels(config: ModelsConfig): CustomModelOverlay[] {
const models: CustomModelOverlay[] = [];
for (const [providerName, providerConfig] of Object.entries(config.providers ?? {})) {
const modelDefs = providerConfig.models ?? [];
if (modelDefs.length === 0) continue; // Override-only, no custom models
@@ -1662,6 +1701,7 @@ export class ModelRegistry {
providerConfig.authHeader,
providerCompat,
(providerConfig.auth as ProviderAuthMode | undefined) ?? undefined,
providerConfig.remoteCompaction,
modelDef as CustomModelDefinitionLike,
);
if (!model) continue;
@@ -2069,6 +2109,7 @@ export class ModelRegistry {
config.authHeader,
config.compat,
undefined,
config.remoteCompaction,
modelDef as CustomModelDefinitionLike,
);
if (!overlay) {
@@ -2136,6 +2177,7 @@ export class ModelRegistry {
providerAuthHeader,
providerCompat,
undefined,
config.remoteCompaction,
modelDef as CustomModelDefinitionLike,
);
if (overlay) results.push(finalizeCustomModel(overlay, { useDefaults: true }));
@@ -2221,6 +2263,7 @@ export interface ProviderConfigInput {
streamSimple?: (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream;
headers?: Record<string, string>;
compat?: ModelSpec<Api>["compat"];
remoteCompaction?: RemoteCompactionConfig<Api>;
authHeader?: boolean;
/** Streaming transport override — see {@link Model.transport}. */
transport?: Model<Api>["transport"];
@@ -2255,6 +2298,8 @@ export interface ProviderConfigInput {
headers?: Record<string, string>;
compat?: ModelSpec<Api>["compat"];
contextPromotionTarget?: string;
compactionModel?: string;
remoteCompaction?: RemoteCompactionConfig<Api>;
premiumMultiplier?: number;
}>;
}
@@ -70,6 +70,10 @@ export const OpenAICompatSchema = type({
"whenThinking?": OpenAICompatFieldsSchema,
});
const ApiSchema = type(
'"openai-completions" | "openai-responses" | "openai-codex-responses" | "azure-openai-responses" | "anthropic-messages" | "google-generative-ai" | "google-gemini-cli" | "google-vertex"',
);
const EffortSchema = type('"minimal" | "low" | "medium" | "high" | "xhigh"');
const ThinkingControlModeSchema = type(
@@ -118,11 +122,25 @@ const ModelThinkingSchema = type({
};
});
const RemoteCompactionSchema = type({
"enabled?": "boolean",
"api?": ApiSchema,
"endpoint?": "string",
"model?": "string",
}).narrow((value, ctx) => {
if (value.endpoint !== undefined && typeof value.endpoint === "string" && value.endpoint.length === 0) {
return ctx.mustBe("remoteCompaction.endpoint a non-empty string");
}
if (value.model !== undefined && typeof value.model === "string" && value.model.length === 0) {
return ctx.mustBe("remoteCompaction.model a non-empty string");
}
return true;
});
const ModelDefinitionSchema = type({
id: "string",
"name?": "string",
"api?":
'"openai-completions" | "openai-responses" | "openai-codex-responses" | "azure-openai-responses" | "anthropic-messages" | "google-generative-ai" | "google-gemini-cli" | "google-vertex"',
"api?": ApiSchema,
"baseUrl?": "string",
"reasoning?": "boolean",
"thinking?": ModelThinkingSchema,
@@ -141,6 +159,8 @@ const ModelDefinitionSchema = type({
"headers?": { "[string]": "string" },
"compat?": OpenAICompatSchema,
"contextPromotionTarget?": "string",
"compactionModel?": "string",
"remoteCompaction?": RemoteCompactionSchema,
}).narrow((value, ctx) => {
// Enforce id non-empty
if (typeof value.id === "string" && value.id.length === 0) {
@@ -159,6 +179,13 @@ const ModelDefinitionSchema = type({
) {
return ctx.mustBe("contextPromotionTarget a non-empty string");
}
if (
value.compactionModel !== undefined &&
typeof value.compactionModel === "string" &&
value.compactionModel.length === 0
) {
return ctx.mustBe("compactionModel a non-empty string");
}
return true;
});
@@ -181,6 +208,8 @@ export const ModelOverrideSchema = type({
"headers?": { "[string]": "string" },
"compat?": OpenAICompatSchema,
"contextPromotionTarget?": "string",
"compactionModel?": "string",
"remoteCompaction?": RemoteCompactionSchema,
}).narrow((value, ctx) => {
if (value.name !== undefined && typeof value.name === "string" && value.name.length === 0) {
return ctx.mustBe("name a non-empty string");
@@ -192,6 +221,13 @@ export const ModelOverrideSchema = type({
) {
return ctx.mustBe("contextPromotionTarget a non-empty string");
}
if (
value.compactionModel !== undefined &&
typeof value.compactionModel === "string" &&
value.compactionModel.length === 0
) {
return ctx.mustBe("compactionModel a non-empty string");
}
return true;
});
@@ -209,10 +245,10 @@ export type ProviderDiscovery = typeof ProviderDiscoverySchema.infer;
const ProviderConfigSchema = type({
"baseUrl?": "string",
"apiKey?": "string",
"api?":
'"openai-completions" | "openai-responses" | "openai-codex-responses" | "azure-openai-responses" | "anthropic-messages" | "google-generative-ai" | "google-gemini-cli" | "google-vertex"',
"api?": ApiSchema,
"headers?": { "[string]": "string" },
"compat?": OpenAICompatSchema,
"remoteCompaction?": RemoteCompactionSchema,
"authHeader?": "boolean",
"auth?": ProviderAuthSchema,
"discovery?": ProviderDiscoverySchema,
@@ -30,6 +30,7 @@ export interface ProviderValidationConfig {
oauthConfigured?: boolean;
discovery?: ProviderDiscovery;
compat?: ModelSpec<Api>["compat"];
remoteCompaction?: unknown;
disableStrictTools?: boolean;
modelOverrides?: Record<string, unknown>;
models: ProviderValidationModel[];
@@ -53,11 +54,12 @@ export function validateProviderConfiguration(
!config.apiKey &&
config.auth !== "none" &&
!config.disableStrictTools &&
!config.remoteCompaction &&
!hasModelOverrides &&
!config.discovery
) {
throw new Error(
`Provider ${providerName}: must specify "baseUrl", "headers", "apiKey", "auth: none", "compat", "disableStrictTools", "modelOverrides", "discovery", or "models"`,
`Provider ${providerName}: must specify "baseUrl", "headers", "apiKey", "auth: none", "compat", "disableStrictTools", "remoteCompaction", "modelOverrides", "discovery", or "models"`,
);
}
}
@@ -120,6 +122,7 @@ export const ModelsConfigFile = new ConfigFile<ModelsConfig>("models", ModelsCon
auth: (providerConfig.auth ?? "apiKey") as ProviderAuthMode,
discovery: providerConfig.discovery as ProviderDiscovery | undefined,
compat: providerConfig.compat,
remoteCompaction: providerConfig.remoteCompaction,
disableStrictTools: providerConfig.disableStrictTools,
modelOverrides: providerConfig.modelOverrides,
models: (providerConfig.models ?? []) as ProviderValidationModel[],
@@ -7611,8 +7611,14 @@ export class AgentSession {
? { ...compactionSettings, ...compactMode.overrides }
: compactionSettings;
if (compactMode?.requiresRemote) {
const compactionTarget = this.#resolveCompactionConfiguredTarget(
this.model,
this.#modelRegistry.getAvailable(),
);
const remoteReady =
Boolean(effectiveSettings.remoteEndpoint) || shouldUseOpenAiRemoteCompaction(this.model);
Boolean(effectiveSettings.remoteEndpoint) ||
shouldUseOpenAiRemoteCompaction(this.model) ||
(compactionTarget ? shouldUseOpenAiRemoteCompaction(compactionTarget) : false);
if (!remoteReady) {
this.emitNotice(
"warning",
@@ -9047,11 +9053,15 @@ export class AgentSession {
});
return formatModelSelectorValue(modelKey, thinkingLevel);
}
#resolveContextPromotionConfiguredTarget(currentModel: Model, availableModels: Model[]): Model | undefined {
const configuredTarget = currentModel.contextPromotionTarget?.trim();
if (!configuredTarget) return undefined;
#resolveConfiguredModelTarget(
configuredTarget: string | undefined,
currentModel: Model,
availableModels: Model[],
): Model | undefined {
const trimmedTarget = configuredTarget?.trim();
if (!trimmedTarget) return undefined;
const parsed = parseModelString(configuredTarget, {
const parsed = parseModelString(trimmedTarget, {
allowMaxAlias: true,
isLiteralModelId: (provider, id) =>
availableModels.some(model => model.provider === provider && model.id === id),
@@ -9061,7 +9071,15 @@ export class AgentSession {
if (explicitModel) return explicitModel;
}
return availableModels.find(m => m.provider === currentModel.provider && m.id === configuredTarget);
return availableModels.find(m => m.provider === currentModel.provider && m.id === trimmedTarget);
}
#resolveContextPromotionConfiguredTarget(currentModel: Model, availableModels: Model[]): Model | undefined {
return this.#resolveConfiguredModelTarget(currentModel.contextPromotionTarget, currentModel, availableModels);
}
#resolveCompactionConfiguredTarget(currentModel: Model, availableModels: Model[]): Model | undefined {
return this.#resolveConfiguredModelTarget(currentModel.compactionModel, currentModel, availableModels);
}
#resolveRoleModelFull(
@@ -9102,6 +9120,9 @@ export class AgentSession {
candidates.push(model);
};
if (preferredModel) {
addCandidate(this.#resolveCompactionConfiguredTarget(preferredModel, availableModels));
}
addCandidate(preferredModel ?? undefined);
for (const role of MODEL_ROLE_IDS) {
addCandidate(this.#resolveRoleModelFull(role, availableModels, preferredModel ?? undefined).model);
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { Agent } from "@oh-my-pi/pi-agent-core";
import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
@@ -97,4 +98,68 @@ describe("compaction prefers the current session model over modelRoles.default",
const [, firstCandidate] = compactSpy.mock.calls[0]!;
expect(`${firstCandidate.provider}/${firstCandidate.id}`).toBe(`${currentModel.provider}/${currentModel.id}`);
});
it("uses compactionModel only for the summary call and leaves the active model unchanged", async () => {
const baseCurrentModel = getBundledModel("anthropic", "claude-sonnet-4-5");
const compactionModel = getBundledModel("openai", "gpt-5");
if (!baseCurrentModel || !compactionModel) {
throw new Error("Expected bundled test models to exist");
}
const currentModel = buildModel({
...baseCurrentModel,
compactionModel: `${compactionModel.provider}/${compactionModel.id}`,
compat: baseCurrentModel.compatConfig,
});
const agent = new Agent({
initialState: {
model: currentModel,
systemPrompt: ["Test"],
tools: [],
messages: [],
},
});
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
authStorage.setRuntimeApiKey(currentModel.provider, "anthropic-token");
authStorage.setRuntimeApiKey(compactionModel.provider, "openai-token");
modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
session = new AgentSession({
agent,
sessionManager: SessionManager.inMemory(),
settings: Settings.isolated({ "compaction.keepRecentTokens": 1 }),
modelRegistry,
});
session.subscribe(() => {});
for (const [userText, assistantText] of [
["first question", "first answer"],
["second question", "second answer"],
] as const) {
const user = userMsg(userText);
const assistant = assistantMsg(assistantText);
session.agent.appendMessage(user);
session.sessionManager.appendMessage(user);
session.agent.appendMessage(assistant);
session.sessionManager.appendMessage(assistant);
}
const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => ({
summary: "ok",
shortSummary: "ok short",
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: 1,
details: { provider: model.provider },
}));
await session.compact();
expect(compactSpy).toHaveBeenCalled();
const [, firstCandidate] = compactSpy.mock.calls[0]!;
expect(`${firstCandidate.provider}/${firstCandidate.id}`).toBe(
`${compactionModel.provider}/${compactionModel.id}`,
);
expect(`${session.model?.provider}/${session.model?.id}`).toBe(`${currentModel.provider}/${currentModel.id}`);
});
});
@@ -764,6 +764,12 @@ describe("ModelRegistry", () => {
compat: {
supportsImageDetailOriginal: false,
},
remoteCompaction: {
enabled: true,
api: "openai-responses",
endpoint: "http://127.0.0.1:8080/v1/responses/provider-compact",
model: "provider-compact",
},
models: [
{
id: "gpt-5.5",
@@ -772,6 +778,11 @@ describe("ModelRegistry", () => {
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 100_000,
compactionModel: "cc-switch/gpt-5.4",
remoteCompaction: {
endpoint: "http://127.0.0.1:8080/v1/responses/model-compact",
model: "gpt-5.5-compact",
},
},
],
},
@@ -832,6 +843,17 @@ describe("ModelRegistry", () => {
expect(compat?.supportsImageDetailOriginal).toBe(false);
});
test("custom Responses providers preserve compaction config", () => {
const model = customResponsesCompat.find("cc-switch", "gpt-5.5");
expect(model?.compactionModel).toBe("cc-switch/gpt-5.4");
expect(model?.remoteCompaction).toEqual({
enabled: true,
api: "openai-responses",
endpoint: "http://127.0.0.1:8080/v1/responses/model-compact",
model: "gpt-5.5-compact",
});
});
test("model-level compat overrides provider-level compat for custom models", () => {
const model = customModelCompat.find("demo", "demo-model");
const compat = getOpenAICompat(model);