From b7aefe06897ba97eb20bbe4e6d7e6f8ab3988f62 Mon Sep 17 00:00:00 2001 From: roboomp Date: Sat, 20 Jun 2026 07:27:09 +0000 Subject: [PATCH] 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 --- packages/agent/CHANGELOG.md | 4 ++ packages/agent/src/compaction/openai.ts | 20 ++++-- packages/agent/test/remote-compaction.test.ts | 40 +++++++++++- packages/catalog/CHANGELOG.md | 4 ++ packages/catalog/src/types.ts | 16 +++++ packages/coding-agent/CHANGELOG.md | 4 ++ .../coding-agent/src/config/model-registry.ts | 61 ++++++++++++++--- .../src/config/models-config-schema.ts | 44 +++++++++++-- .../coding-agent/src/config/models-config.ts | 5 +- .../coding-agent/src/session/agent-session.ts | 33 ++++++++-- .../compaction-prefer-current-model.test.ts | 65 +++++++++++++++++++ .../coding-agent/test/model-registry.test.ts | 22 +++++++ 12 files changed, 293 insertions(+), 25 deletions(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 3a68b2598..8f9dbc070 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -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 diff --git a/packages/agent/src/compaction/openai.ts b/packages/agent/src/compaction/openai.ts index a385b113e..e242ed01c 100644 --- a/packages/agent/src/compaction/openai.ts +++ b/packages/agent/src/compaction/openai.ts @@ -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 { 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, }; diff --git a/packages/agent/test/remote-compaction.test.ts b/packages/agent/test/remote-compaction.test.ts index 8fdfeb8c2..d7fe01ce5 100644 --- a/packages/agent/test/remote-compaction.test.ts +++ b/packages/agent/test/remote-compaction.test.ts @@ -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(); diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 7b90c6a7a..97e6f532d 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -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 diff --git a/packages/catalog/src/types.ts b/packages/catalog/src/types.ts index 87ff79518..00f209ad1 100644 --- a/packages/catalog/src/types.ts +++ b/packages/catalog/src/types.ts @@ -576,6 +576,18 @@ export type CompatOf = TApi extends "openrouter" ? ResolvedAnthropicCompat : undefined; +/** Provider-native compaction endpoint configuration for one model. */ +export interface RemoteCompactionConfig { + /** 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 { id: string; @@ -648,6 +660,10 @@ export interface Model { 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; /** Provider-assigned priority value (lower = higher priority). */ priority?: number; /** Canonical thinking capability metadata for this model. */ diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5b9663401..0a337efe5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 265b9c601..5a8444955 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -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["compat"]; + remoteCompaction?: RemoteCompactionConfig; transport?: Model["transport"]; } @@ -127,7 +136,7 @@ interface ProviderOverride { export function mergeDiscoveredModel( model: Model, existing: Model | undefined, - providerOverride?: Pick, + providerOverride?: Pick, ): Model { if (existing) { const supportsTools = model.supportsTools ?? existing.supportsTools; @@ -136,6 +145,10 @@ export function mergeDiscoveredModel( 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); @@ -146,6 +159,7 @@ export function mergeDiscoveredModel( 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); } @@ -353,6 +367,15 @@ function mergeCompat( return merged as TBase & TOverride; } +function mergeRemoteCompactionConfig( + baseConfig: RemoteCompactionConfig | undefined, + overrideConfig: RemoteCompactionConfig | undefined, +): RemoteCompactionConfig | 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; compat?: ModelSpec["compat"]; contextPromotionTarget?: string; + compactionModel?: string; + remoteCompaction?: RemoteCompactionConfig; premiumMultiplier?: number; } @@ -403,6 +428,10 @@ function applyModelPatch(base: Model, 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["compat"] | undefined, providerAuth: ProviderAuthMode | undefined, + providerRemoteCompaction: RemoteCompactionConfig | 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); @@ -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 }>( + #applyProviderTransportOverride< + T extends { baseUrl?: string; headers?: Record; remoteCompaction?: RemoteCompactionConfig }, + >( entry: T, - override: Pick, + 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[]): Model[] { @@ -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, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream; headers?: Record; compat?: ModelSpec["compat"]; + remoteCompaction?: RemoteCompactionConfig; authHeader?: boolean; /** Streaming transport override — see {@link Model.transport}. */ transport?: Model["transport"]; @@ -2255,6 +2298,8 @@ export interface ProviderConfigInput { headers?: Record; compat?: ModelSpec["compat"]; contextPromotionTarget?: string; + compactionModel?: string; + remoteCompaction?: RemoteCompactionConfig; premiumMultiplier?: number; }>; } diff --git a/packages/coding-agent/src/config/models-config-schema.ts b/packages/coding-agent/src/config/models-config-schema.ts index 31078f47a..2ef12d38c 100644 --- a/packages/coding-agent/src/config/models-config-schema.ts +++ b/packages/coding-agent/src/config/models-config-schema.ts @@ -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, diff --git a/packages/coding-agent/src/config/models-config.ts b/packages/coding-agent/src/config/models-config.ts index 676184263..cdf0916cb 100644 --- a/packages/coding-agent/src/config/models-config.ts +++ b/packages/coding-agent/src/config/models-config.ts @@ -30,6 +30,7 @@ export interface ProviderValidationConfig { oauthConfigured?: boolean; discovery?: ProviderDiscovery; compat?: ModelSpec["compat"]; + remoteCompaction?: unknown; disableStrictTools?: boolean; modelOverrides?: Record; 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("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[], diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 0de528d96..7b023256b 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -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); diff --git a/packages/coding-agent/test/compaction-prefer-current-model.test.ts b/packages/coding-agent/test/compaction-prefer-current-model.test.ts index f21ffe511..4d2c45ec8 100644 --- a/packages/coding-agent/test/compaction-prefer-current-model.test.ts +++ b/packages/coding-agent/test/compaction-prefer-current-model.test.ts @@ -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}`); + }); }); diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index a78877aa2..3316d6273 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -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);