Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion @omniroute/opencode-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"scripts": {
"build": "tsup",
"clean": "rm -rf dist",
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts",
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts",
"prepublishOnly": "npm run clean && npm run build && npm test"
},
"keywords": [
Expand Down
29 changes: 26 additions & 3 deletions @omniroute/opencode-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1121,6 +1121,11 @@ export interface OmniRouteRawAutoCombo {
candidatePool?: string[];
/** Number of candidates resolved at fetch time. */
candidateCount?: number;
/** MAX of candidates' context windows, served by newer OmniRoute builds.
* Absent on older servers — mapper falls back to a safe positive default. */
context_length?: number;
/** MAX of candidates' max output tokens (same provenance as context_length). */
max_output_tokens?: number;
/** Whether this auto combo should be hidden from the picker. */
isHidden?: boolean;
/** Auto-combo configuration. */
Expand Down Expand Up @@ -1211,24 +1216,42 @@ export const defaultOmniRouteAutoCombosFetcher: OmniRouteAutoCombosFetcher =
}
};

/** Fallbacks when the server does not advertise auto-combo limits (older
* OmniRoute builds). MUST be positive: OpenCode's overflow guard treats
* `limit.context === 0` as "never overflow" and silently DISABLES smart
* auto-compaction, letting the session grow until the gateway's destructive
* history purge kicks in (the "agent keeps forgetting things" bug). */
const AUTO_COMBO_FALLBACK_CONTEXT = 128_000;
const AUTO_COMBO_FALLBACK_OUTPUT = 8_192;

/**
* Convert a raw auto combo into a static model entry for the OpenCode picker.
* Auto combos have tool_call=true, reasoning=true by default (they route
* to capable models). Context/output limits are set to 0 since the actual
* limits depend on which provider is selected at runtime.
* to capable models). Context/output limits come from the server (MAX of
* the candidate pool's windows — the gateway's context pre-filter routes
* oversized requests to large-window candidates); a safe positive fallback
* applies when the server omits them. Never 0.
*/
export function mapAutoComboToStaticEntry(
autoCombo: OmniRouteRawAutoCombo,
): OmniRouteStaticModelEntry {
const variant = autoCombo.variant;
const name = formatAutoComboName(variant, autoCombo.candidateCount);
const context =
typeof autoCombo.context_length === "number" && autoCombo.context_length > 0
? autoCombo.context_length
: AUTO_COMBO_FALLBACK_CONTEXT;
const output =
typeof autoCombo.max_output_tokens === "number" && autoCombo.max_output_tokens > 0
? autoCombo.max_output_tokens
: AUTO_COMBO_FALLBACK_OUTPUT;
return {
name,
attachment: false,
reasoning: true,
temperature: true,
tool_call: true,
limit: { context: 0, output: 0 },
limit: { context, output },
modalities: {
input: ["text"],
output: ["text"],
Expand Down
74 changes: 74 additions & 0 deletions @omniroute/opencode-plugin/tests/auto-combo-context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* TDD regression — auto combos must never advertise `limit.context: 0`.
*
* opencode's overflow guard (packages/opencode/src/session/overflow.ts)
* short-circuits when `model.limit.context === 0`:
*
* if (input.model.limit.context === 0) return false // never overflow
*
* so a zero context silently DISABLES opencode's smart auto-compaction for
* auto combos. The session then grows unbounded until OmniRoute's
* server-side purifyHistory() destructively drops old messages — the
* "coding agent keeps forgetting things" bug.
*
* Fix under test: mapAutoComboToStaticEntry consumes the context_length /
* max_output_tokens now served by GET /api/combos/auto, and falls back to a
* safe positive default (128000 / 8192) for older servers that do not send
* the fields yet.
*/
import test from "node:test";
import assert from "node:assert/strict";

import { mapAutoComboToStaticEntry } from "../src/index.ts";
import type { OmniRouteRawAutoCombo } from "../src/index.ts";

test("uses server-provided context_length and max_output_tokens", () => {
const raw = {
id: "auto/coding",
name: "Auto Coding",
variant: "coding",
candidateCount: 5,
context_length: 1048576,
max_output_tokens: 65536,
} as OmniRouteRawAutoCombo;

const entry = mapAutoComboToStaticEntry(raw);
assert.equal(entry.limit?.context, 1048576);
assert.equal(entry.limit?.output, 65536);
});

test("falls back to a safe positive default when the server omits limits (old servers)", () => {
const raw = {
id: "auto",
name: "Auto",
candidateCount: 3,
} as OmniRouteRawAutoCombo;

const entry = mapAutoComboToStaticEntry(raw);
assert.ok(
typeof entry.limit?.context === "number" && entry.limit.context > 0,
`context must be a positive number (never 0 — zero disables opencode auto-compaction), got ${entry.limit?.context}`
);
assert.ok(
typeof entry.limit?.output === "number" && entry.limit.output > 0,
`output must be a positive number, got ${entry.limit?.output}`
);
});

test("ignores non-positive server values and keeps the safe fallback", () => {
const raw = {
id: "auto/fast",
name: "Auto Fast",
variant: "fast",
candidateCount: 2,
context_length: 0,
max_output_tokens: -1,
} as OmniRouteRawAutoCombo;

const entry = mapAutoComboToStaticEntry(raw);
assert.ok(
typeof entry.limit?.context === "number" && entry.limit.context > 0,
"zero/negative server values must not propagate"
);
assert.ok(typeof entry.limit?.output === "number" && entry.limit.output > 0);
});
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@

### 🔧 Bug Fixes

- **Combos / Auto-Combo: premature context compaction ("agent keeps forgetting things")** ([#3680]): two related context-window bugs fixed. (1) `GET /api/combos/auto` now advertises `context_length` / `max_output_tokens` (MAX across the candidate pool — safe because the auto-combo context pre-filter routes oversized requests to large-window candidates), and the opencode plugin consumes them instead of hardcoding `limit: { context: 0 }` — a zero context silently disables opencode's smart auto-compaction, letting sessions grow until the gateway's destructive history purge kicks in. (2) chatCore's proactive compression for DB combos (incl. quota-shared pools) no longer compresses at `min(...allTargets)`: it now uses the EXECUTING target's own window (`resolveComboContextLimit`), keeping min-of-targets only as a defensive fallback when the current provider/model resolves no specific limit. TDD: 8 server tests (`tests/unit/auto-combo-context-advertising.test.ts`) + 3 plugin tests (`tests/auto-combo-context.test.ts`).
- **Obsidian/WebDAV**: add the `/api/settings/obsidian/webdav` config route (enable/disable vault sync), encrypt WebDAV credentials at rest, and remove the duplicate UI block (#3485, part 1).

- **OpenCode Free / passthrough**: "Test all models" now respects "Auto-hide failed models" and switches the list to the visible filter so hidden models actually disappear (#3610). Three related bugs fixed: `autoHideFailed` is now threaded from the outer component into `PassthroughModelsSection` via a prop (single shared checkbox); the `/api/models/test-all` request body now includes `autoHideFailed: true` so the server persists the hide; and after the loop, `visibilityFilter` is switched to `"visible"` when ≥1 model was hidden. Two pure-function helpers (`buildPassthroughTestBody`, `shouldSwitchToVisibleFilter`) extracted to `providerPageHelpers.ts` with 7 unit tests.
Expand Down
2 changes: 2 additions & 0 deletions docs/routing/AUTO-COMBO.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ There is **no dedicated `POST /api/combos/auto` endpoint** — Auto-Combo is con

2. **Persisted combo with `strategy: "auto"`:** Create a regular combo via `POST /api/combos` and set `strategy: "auto"` plus `config.auto.weights` / `config.auto.candidatePool`. The same scoring engine is used; the combo is stored in `combos` and reusable by ID.

For discovery, `GET /api/combos/auto` lists every variant with its resolved candidate pool plus `context_length` / `max_output_tokens` — the MAX across the candidate pool's windows. Clients (e.g. the opencode plugin) must advertise these values instead of `0`: a zero context disables opencode's auto-compaction entirely, letting sessions grow until the gateway's history purge destroys context. MAX is safe to advertise because the auto-combo context pre-filter routes oversized requests to large-window candidates.

```bash
# Zero-config usage (no combo creation)
curl -X POST http://localhost:20128/v1/chat/completions \
Expand Down
26 changes: 20 additions & 6 deletions open-sse/handlers/chatCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,12 @@ import {
getModelFamily,
} from "../services/modelFamilyFallback.ts";
import { computeRequestHash, deduplicate, shouldDeduplicate } from "../services/requestDedup.ts";
import { compressContext, estimateTokens, getTokenLimit } from "../services/contextManager.ts";
import {
compressContext,
estimateTokens,
getTokenLimit,
resolveComboContextLimit,
} from "../services/contextManager.ts";
import {
getBackgroundTaskReason,
getDegradedModel,
Expand Down Expand Up @@ -2741,21 +2746,30 @@ export async function handleChatCore({
if (!comboConfig && comboName.startsWith("combo/")) {
comboConfig = await getComboByName(comboName.substring(6));
}
let comboTargetLimits: number[] = [];
if (comboConfig) {
const allCombosData = await getCombosCached();
const targets = resolveComboTargets(
comboConfig as unknown as { name: string; models: unknown[] },
allCombosData as unknown as { name: string; models: unknown[] }[]
);
const limits = targets.map((t: { modelStr?: string }) => {
comboTargetLimits = targets.map((t: { modelStr?: string }) => {
const parsed = parseModel(t.modelStr);
return getTokenLimit(parsed.provider, parsed.model);
});
if (limits.length > 0) {
contextLimit = Math.min(...limits);
log?.info?.("CONTEXT", `Combo min limit: ${contextLimit}`);
}
}
// chatCore executes per concrete target (handleSingleModel resolves
// provider/effectiveModel before delegating). Compress against THIS
// target's window; min(...allTargets) is only a defensive fallback —
// the old unconditional min compressed a 1M-target request at the
// smallest sibling's window ("agent keeps forgetting things").
const resolved = resolveComboContextLimit({
provider,
model: effectiveModel,
comboTargetLimits,
});
contextLimit = resolved.limit;
log?.info?.("CONTEXT", `Combo context limit: ${resolved.limit} (source=${resolved.source})`);
} catch (err) {
log?.warn?.("CONTEXT", "Failed to resolve combo limits for compression: " + err);
}
Expand Down
53 changes: 53 additions & 0 deletions open-sse/services/autoCombo/virtualFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type { ConnectionFields } from "@/lib/db/encryption";
import { NOAUTH_PROVIDERS } from "@/shared/constants/providers";
import { hasUsableWebSessionCredential } from "@/shared/providers/webSessionCredentials";
import { defaultLogger as log } from "@omniroute/open-sse/utils/logger";
import { getTokenLimit } from "../contextManager";
import { getResolvedModelCapabilities } from "@/lib/modelCapabilities";

/** Minimal connection shape needed for virtual auto-combo factory */
interface VirtualFactoryConn extends ConnectionFields {
Expand Down Expand Up @@ -45,6 +47,11 @@ type VirtualAutoCombo = AutoComboConfig & {
weight: number;
label: string;
}>;
/** MAX of candidates' context windows — safe to advertise because the
* auto-combo context pre-filter routes oversized requests to large-window
* candidates. null when the pool is empty. */
advertisedContextLength: number | null;
advertisedMaxOutputTokens: number | null;
autoConfig: {
candidatePool: string[];
weights: ScoringWeights;
Expand Down Expand Up @@ -156,6 +163,46 @@ function getNoAuthCandidates(excludedProviders: Set<string>): VirtualAutoComboCa
* Creates a virtual AutoCombo configuration dynamically based on connected providers and a specified variant.
* This combo is not persisted in the DB.
*/
/**
* Aggregate the context window / max output to ADVERTISE for an auto combo.
*
* MAX across candidates (not min): the auto-combo context pre-filter
* (combo.ts::filterTargetsByRequestCompatibility + the estimated-tokens
* pre-filter) already routes oversized requests away from small-window
* candidates, so advertising the largest window lets clients (e.g. opencode)
* keep their smart auto-compaction calibrated to the best candidate instead
* of compacting prematurely — or, worse, receiving 0 and disabling
* compaction entirely (the "agent keeps forgetting things" bug).
*
* Unknown candidates resolve through getTokenLimit()'s fallback chain, so a
* non-empty pool always yields a positive contextLength.
*/
export function computeAdvertisedLimits(candidates: Array<{ provider: string; model: string }>): {
contextLength: number | null;
maxOutputTokens: number | null;
} {
if (!Array.isArray(candidates) || candidates.length === 0) {
return { contextLength: null, maxOutputTokens: null };
}

let contextLength: number | null = null;
let maxOutputTokens: number | null = null;
for (const candidate of candidates) {
const limit = getTokenLimit(candidate.provider, candidate.model);
if (Number.isFinite(limit) && limit > 0) {
contextLength = contextLength === null ? limit : Math.max(contextLength, limit);
}
const output = getResolvedModelCapabilities({
provider: candidate.provider,
model: candidate.model,
}).maxOutputTokens;
if (typeof output === "number" && Number.isFinite(output) && output > 0) {
maxOutputTokens = maxOutputTokens === null ? output : Math.max(maxOutputTokens, output);
}
}
return { contextLength, maxOutputTokens };
}

export async function createVirtualAutoCombo(
variant: AutoVariant | undefined
): Promise<VirtualAutoCombo> {
Expand Down Expand Up @@ -209,6 +256,8 @@ export async function createVirtualAutoCombo(
routerStrategy: autoConfig.routerStrategy,
autoConfig,
config: { auto: autoConfig },
advertisedContextLength: null,
advertisedMaxOutputTokens: null,
};
}

Expand Down Expand Up @@ -259,6 +308,8 @@ export async function createVirtualAutoCombo(
routerStrategy,
};

const advertisedLimits = computeAdvertisedLimits(candidatePool);

return {
id: `virtual-auto-${variant || "default"}`,
name: `Auto ${variant || "Default"}`,
Expand All @@ -271,5 +322,7 @@ export async function createVirtualAutoCombo(
routerStrategy,
autoConfig,
config: { auto: autoConfig },
advertisedContextLength: advertisedLimits.contextLength,
advertisedMaxOutputTokens: advertisedLimits.maxOutputTokens,
};
}
60 changes: 53 additions & 7 deletions open-sse/services/contextManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,39 +61,85 @@ export function estimateTokens(text: string | object | null | undefined): number
* Priority: Env override > models.dev DB > Registry defaultContextLength > DEFAULT_LIMITS
*/
export function getTokenLimit(provider: string, model: string | null = null): number {
return resolveTokenLimit(provider, model).limit;
}

/**
* Same chain as getTokenLimit, but also reports whether the limit came from
* a provider/model-specific source (env override, synced DB, registry,
* name heuristic, curated per-provider default) or only from the generic
* catch-all default.
*/
function resolveTokenLimit(
provider: string,
model: string | null = null
): { limit: number; specific: boolean } {
// 1. Check environment variable override first
const envOverride = getEnvOverride(provider);
if (envOverride) return envOverride;
if (envOverride) return { limit: envOverride, specific: true };

// 2. Check models.dev synced DB for per-model context limit
if (model) {
const dbLimit = getModelContextLimit(provider, model);
if (dbLimit && dbLimit > 0) return dbLimit;
if (dbLimit && dbLimit > 0) return { limit: dbLimit, specific: true };
}

// 3. Check registry for provider default
const registryEntry = REGISTRY[provider];
if (registryEntry?.defaultContextLength) {
return registryEntry.defaultContextLength;
return { limit: registryEntry.defaultContextLength, specific: true };
}

// 4. Check if model name hints at a known limit
if (model) {
const lower = model.toLowerCase();
if (lower.includes("claude")) return DEFAULT_LIMITS.claude;
if (lower.includes("gemini")) return DEFAULT_LIMITS.gemini;
if (lower.includes("claude")) return { limit: DEFAULT_LIMITS.claude, specific: true };
if (lower.includes("gemini")) return { limit: DEFAULT_LIMITS.gemini, specific: true };
if (
lower.includes("gpt") ||
lower.includes("o1") ||
lower.includes("o3") ||
lower.includes("o4") ||
lower.includes("codex")
)
return DEFAULT_LIMITS.codex;
return { limit: DEFAULT_LIMITS.codex, specific: true };
}

// 5. Fallback to DEFAULT_LIMITS or default
return DEFAULT_LIMITS[provider] || DEFAULT_LIMITS.default;
if (DEFAULT_LIMITS[provider]) return { limit: DEFAULT_LIMITS[provider], specific: true };
return { limit: DEFAULT_LIMITS.default, specific: false };
}

/**
* Resolve the context limit to use for proactive compression of a COMBO
* request.
*
* chatCore always executes with the CONCRETE target's provider/model
* (handleSingleModel resolves the target before delegating), so the
* executing target's own limit is authoritative. Using min(...allTargets)
* here — the previous behavior — compressed at the smallest sibling's
* window even when running on the largest target, destructively purging
* history long before the real window filled ("agent keeps forgetting").
*
* min(...comboTargetLimits) is kept only as a defensive fallback for the
* case where the current provider/model resolves no specific limit at all.
*/
export function resolveComboContextLimit(options: {
provider: string;
model: string | null;
comboTargetLimits: number[];
}): { limit: number; source: "target" | "combo-min" | "fallback" } {
const own = resolveTokenLimit(options.provider, options.model ?? null);
if (own.specific) {
return { limit: own.limit, source: "target" };
}
const knownTargets = (options.comboTargetLimits || []).filter(
(value) => Number.isFinite(value) && value > 0
);
if (knownTargets.length > 0) {
return { limit: Math.min(...knownTargets), source: "combo-min" };
}
return { limit: own.limit, source: "fallback" };
}

/**
Expand Down
Loading