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:
@@ -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,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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user