fix: retain native compaction auth fallback

(cherry picked from commit 164d77ee90ed109b62ed7f1b6afcf644d26abe12)
This commit is contained in:
usr-bin-roygbiv
2026-07-26 20:59:45 +00:00
committed by can1357
parent 5ce80fdedc
commit a8349d4dea
4 changed files with 18 additions and 151 deletions
+1 -1
View File
@@ -163,7 +163,7 @@
- Added a selectable voice setting for `/live` realtime sessions ([#6566](https://github.com/can1357/oh-my-pi/issues/6566)).
### Fixed
- Native compaction now keeps implicit role and largest-context fallbacks on the active provider, preventing a provider-native request from silently becoming another provider's generic summary. Explicit compaction models and soft compaction retain their existing fallback behavior.
- Native compaction preserves provider-native success and non-authentication failure semantics while retaining authenticated cross-provider fallback when the native provider rejects credentials.
## [17.1.4] - 2026-07-26
@@ -34,7 +34,6 @@ import {
type SummaryOptions,
shouldCompact,
shouldUseOpenAiRemoteCompaction,
shouldUseCompactionV2Streaming,
} from "@oh-my-pi/pi-agent-core/compaction";
import {
DEFAULT_PRUNE_CONFIG,
@@ -568,7 +567,6 @@ export class SessionMaintenance {
let compactionCandidates = this.#getCompactionModelCandidates(
availableModels,
requireProviderRemote ? shouldUseOpenAiRemoteCompaction : undefined,
effectiveSettings,
);
if (requireProviderRemote && compactionCandidates.length === 0) {
this.#host.emitNotice(
@@ -576,7 +574,7 @@ export class SessionMaintenance {
`remote compaction is unavailable for ${this.#model.id} (no remote endpoint configured and no provider-native remote-capable model in the fallback chain) — using a local summary instead`,
"compaction",
);
compactionCandidates = this.#getCompactionModelCandidates(availableModels, undefined, effectiveSettings);
compactionCandidates = this.#getCompactionModelCandidates(availableModels);
}
const pathEntries = this.#host.sessionManager.getBranch();
const preparation = prepareCompaction(pathEntries, effectiveSettings, this.#model);
@@ -1392,73 +1390,46 @@ export class SessionMaintenance {
return candidate;
}
#getCompactionModelCandidates(
availableModels: Model[],
filter: ((model: Model) => boolean) | undefined,
settings: Pick<CompactionSettings, "remoteEnabled" | "remoteStreamingV2Enabled">,
): Model[] {
return this.resolveCompactionModelCandidates(
this.#model,
availableModels,
filter,
settings.remoteEnabled !== false,
settings.remoteStreamingV2Enabled !== false,
);
#getCompactionModelCandidates(availableModels: Model[], filter?: (model: Model) => boolean): Model[] {
return this.resolveCompactionModelCandidates(this.#model, availableModels, filter);
}
resolveCompactionModelCandidates(
preferredModel: Model | null | undefined,
availableModels: Model[],
filter?: (model: Model) => boolean,
remoteEnabled = this.#host.settings.getGroup("compaction").remoteEnabled !== false,
remoteStreamingV2Enabled =
this.#host.settings.getGroup("compaction").remoteStreamingV2Enabled !== false,
): Model[] {
const candidates: Model[] = [];
const seen = new Set<string>();
const hasEffectiveNativeCompaction = (model: Model): boolean =>
remoteEnabled &&
(shouldUseOpenAiRemoteCompaction(model) ||
(remoteStreamingV2Enabled && shouldUseCompactionV2Streaming(model)));
const nativeProvider =
preferredModel && hasEffectiveNativeCompaction(preferredModel) ? preferredModel.provider : undefined;
const addCandidate = (model: Model | undefined, source: "explicit" | "current" | "implicit"): void => {
const addCandidate = (model: Model | undefined): void => {
if (!model) return;
const key = `${model.provider}/${model.id}`;
if (seen.has(key)) return;
seen.add(key);
// Explicit targets and the active model retain their established
// semantics. Implicit role/context fallbacks must not turn a native
// compaction request into a different provider's generic summary.
if (
source === "implicit" &&
nativeProvider !== undefined &&
(model.provider !== nativeProvider || !hasEffectiveNativeCompaction(model))
) {
return;
}
// `seen` still tracks rejected models so the largest-context fallback
// scan below doesn't reintroduce them; the filter just suppresses
// inclusion in this caller's candidate chain.
if (filter && !filter(model)) return;
candidates.push(model);
};
if (preferredModel) {
addCandidate(resolveCompactionConfiguredTarget(preferredModel, availableModels), "explicit");
addCandidate(resolveCompactionConfiguredTarget(preferredModel, availableModels));
}
addCandidate(preferredModel ?? undefined, "current");
addCandidate(preferredModel ?? undefined);
for (const role of MODEL_ROLE_IDS) {
addCandidate(
resolveRoleModelFull(this.#host.settings, role, availableModels, preferredModel ?? undefined).model,
"implicit",
);
}
const sortedByContext = [...availableModels].sort((a, b) => (b.contextWindow ?? 0) - (a.contextWindow ?? 0));
for (const model of sortedByContext) {
if (seen.has(`${model.provider}/${model.id}`)) continue;
const candidateCount = candidates.length;
addCandidate(model, "implicit");
if (candidates.length > candidateCount) break;
if (!seen.has(`${model.provider}/${model.id}`)) {
addCandidate(model);
break;
}
}
return candidates;
@@ -1485,8 +1456,7 @@ export class SessionMaintenance {
precomputedCandidates?: Model[],
): Promise<CompactionResult> {
const candidates =
precomputedCandidates ??
this.#getCompactionModelCandidates(this.#host.modelRegistry.getAvailable(), undefined, preparation.settings);
precomputedCandidates ?? this.#getCompactionModelCandidates(this.#host.modelRegistry.getAvailable());
const telemetry = resolveTelemetry(this.#host.agent.telemetry, this.#host.sessionId());
for (const candidate of candidates) {
@@ -2502,7 +2472,7 @@ export class SessionMaintenance {
details = snapcompactResult.details;
preserveData = { ...(compactionPrep.preserveData ?? {}), ...(snapcompactResult.preserveData ?? {}) };
} else {
const candidates = this.#getCompactionModelCandidates(availableModels, undefined, compactionSettings);
const candidates = this.#getCompactionModelCandidates(availableModels);
const retrySettings = this.#host.settings.getGroup("retry");
const telemetry = resolveTelemetry(this.#host.agent.telemetry, this.#host.sessionId());
let compactResult: CompactionResult | undefined;
@@ -37,13 +37,7 @@ describe("issue #986 compaction auth fallback", () => {
throw new Error("Expected bundled test models to exist");
}
const settings = Settings.isolated({
"compaction.keepRecentTokens": 1,
"compaction.strategy": "context-full",
// This suite covers the portable summarizer's auth fallback. Native
// compaction keeps its implicit candidate chain provider-isolated.
"compaction.remoteEnabled": false,
});
const settings = Settings.isolated({ "compaction.keepRecentTokens": 1, "compaction.strategy": "context-full" });
if (options?.fallbackModelRole) {
settings.setModelRole(options.fallbackModelRole, `${fallbackModel.provider}/${fallbackModel.id}`);
}
@@ -87,7 +81,7 @@ describe("issue #986 compaction auth fallback", () => {
return { currentModel, fallbackModel };
}
it("falls back to an authenticated role model when the current provider returns auth_unavailable", async () => {
it("falls back across providers when native compaction returns auth_unavailable", async () => {
const { currentModel, fallbackModel } = await createSession({ fallbackModelRole: "smol" });
const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => {
if (model.provider === currentModel.provider && model.id === currentModel.id) {
@@ -1,97 +0,0 @@
import { describe, expect, it } from "bun:test";
import type { Model } from "@oh-my-pi/pi-ai";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import {
SessionMaintenance,
type SessionMaintenanceHost,
} from "@oh-my-pi/pi-coding-agent/session/session-maintenance";
function model(
id: string,
provider: string,
contextWindow: number,
remoteCompaction?: Model["remoteCompaction"],
): Model {
return buildModel({
id,
name: id,
api: "openai-responses",
provider,
baseUrl: "https://example.test/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow,
maxTokens: 4096,
remoteCompaction,
});
}
function maintenance(settings: Settings): SessionMaintenance {
return new SessionMaintenance({ settings } as SessionMaintenanceHost);
}
describe("native compaction provider isolation", () => {
it("keeps implicit role and context fallbacks on the current native provider", () => {
const settings = Settings.isolated();
const current = model("native-current", "native-provider", 100_000, { enabled: true });
const genericRole = model("generic-role", "generic-provider", 90_000);
const nativeFallback = model("native-fallback", "native-provider", 80_000, { enabled: true });
settings.setModelRole("smol", `${genericRole.provider}/${genericRole.id}`);
const candidates = maintenance(settings).resolveCompactionModelCandidates(
current,
[current, genericRole, nativeFallback],
undefined,
true,
true,
);
expect(candidates.map(candidate => `${candidate.provider}/${candidate.id}`)).toEqual([
"native-provider/native-current",
"native-provider/native-fallback",
]);
});
it("retains generic implicit fallbacks when provider-native compaction is disabled", () => {
const settings = Settings.isolated();
const current = model("native-current", "native-provider", 100_000, { enabled: true });
const genericRole = model("generic-role", "generic-provider", 90_000);
settings.setModelRole("smol", `${genericRole.provider}/${genericRole.id}`);
const candidates = maintenance(settings).resolveCompactionModelCandidates(
current,
[current, genericRole],
undefined,
false,
true,
);
expect(candidates.map(candidate => `${candidate.provider}/${candidate.id}`)).toEqual([
"native-provider/native-current",
"generic-provider/generic-role",
]);
});
it("recognizes V2-only native capability under the effective streaming setting", () => {
const settings = Settings.isolated();
const current = model("v2-current", "v2-provider", 100_000, { v2StreamingEnabled: true });
const genericRole = model("generic-role", "generic-provider", 90_000);
const nativeFallback = model("v2-fallback", "v2-provider", 80_000, { v2StreamingEnabled: true });
settings.setModelRole("smol", `${genericRole.provider}/${genericRole.id}`);
const candidates = maintenance(settings).resolveCompactionModelCandidates(
current,
[current, genericRole, nativeFallback],
undefined,
true,
true,
);
expect(candidates.map(candidate => `${candidate.provider}/${candidate.id}`)).toEqual([
"v2-provider/v2-current",
"v2-provider/v2-fallback",
]);
});
});