feat(coding-agent): added /extended-context slash command and pass settings to model registry
- Add the `/extended-context` builtin slash command to toggle premium long-context windows. - Update `ModelRegistry` to accept settings and apply context-window caps using the finalized configuration. - Add tests covering extended context toggle behavior and session context window capping.
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
- Backgroundable Python — `eval` cells can run async and auto-background like `bash`, with configurable thresholds.
|
||||
- Local Claude token counting — Anthropic-family tokens now count via a native local tokenizer, and every counter (session maintenance, advisor, stats, context tools) uses the active model's own tokenizer.
|
||||
- `extendedContext` setting — pick whether models with premium long-context pricing (272K/1M tiers on Codex-class models) use the extended window or compact early and stay on standard pricing.
|
||||
- `/extended-context` — toggle premium long-context windows without leaving the session.
|
||||
- Speculative compaction — with `compaction.asyncEnabled`, all compaction modes compact in parallel while the session continues, then splice the result in instantly.
|
||||
- `tokenizer` property on custom models and `modelOverrides` to pin the tokenizer family for proxy models.
|
||||
- `qwenTemplateReasoningEffort` in `models.yml` `compat` to disable the Qwen 3.8+ reasoning-effort template parameter for strict local servers.
|
||||
|
||||
@@ -108,7 +108,7 @@ export {
|
||||
|
||||
import { ModelsConfigFile, type ProviderValidationModel, validateProviderConfiguration } from "./models-config";
|
||||
import type { ModelOverride, ModelsConfig, ProviderAuthMode } from "./models-config-schema";
|
||||
import { settings } from "./settings";
|
||||
import { settings, type Settings } from "./settings";
|
||||
|
||||
// DeviceCheck attestation (`x-oai-attestation`) for ChatGPT-OAuth Codex
|
||||
// requests; the pi-ai provider resolves it just-in-time per request.
|
||||
@@ -132,21 +132,21 @@ interface CustomModelsResult {
|
||||
*/
|
||||
type ModifyModelsHook = (models: Model<Api>[], credentials: OAuthCredentials) => Model<Api>[];
|
||||
|
||||
function getDisabledProviderIdsFromSettings(): Set<string> {
|
||||
function getDisabledProviderIdsFromSettings(settingsInstance?: Settings): Set<string> {
|
||||
try {
|
||||
return new Set(settings.get("disabledProviders"));
|
||||
return new Set((settingsInstance ?? settings).get("disabledProviders"));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether premium long-context windows are enabled. Defaults to true when the
|
||||
* settings singleton is not initialized (SDK embedding, early boot).
|
||||
* Whether premium long-context windows are enabled. Defaults to true when no
|
||||
* settings source is available (SDK embedding, early boot).
|
||||
*/
|
||||
function isExtendedContextEnabledFromSettings(): boolean {
|
||||
function isExtendedContextEnabledFromSettings(settingsInstance?: Settings): boolean {
|
||||
try {
|
||||
return settings.get("extendedContext");
|
||||
return (settingsInstance ?? settings).get("extendedContext");
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
@@ -209,6 +209,7 @@ export class ModelRegistry {
|
||||
#runtimeModelManagers: Map<string, { options: ModelManagerOptions<Api>; sourceId: string }> = new Map();
|
||||
#ignoreLocalModelConfig: boolean;
|
||||
#fetch: FetchImpl;
|
||||
#settings: Settings | undefined;
|
||||
|
||||
#resolveCommandBackedApiKey(provider: string, options?: { forceCommandRefresh?: boolean }): CommandApiKeyResolution {
|
||||
const keyConfig = this.#customProviderApiKeys.get(provider);
|
||||
@@ -252,10 +253,13 @@ export class ModelRegistry {
|
||||
* must never apply client-side credential or routing overrides.
|
||||
*/
|
||||
ignoreLocalModelConfig?: boolean;
|
||||
/** Settings source for availability and context-window policies. */
|
||||
settings?: Settings;
|
||||
fetch?: FetchImpl;
|
||||
},
|
||||
) {
|
||||
this.#ignoreLocalModelConfig = options?.ignoreLocalModelConfig ?? false;
|
||||
this.#settings = options?.settings;
|
||||
this.#fetch =
|
||||
options?.fetch ??
|
||||
(isBunTestRuntime()
|
||||
@@ -886,7 +890,7 @@ export class ModelRegistry {
|
||||
}
|
||||
|
||||
#addImplicitDiscoverableProviders(configuredProviders: Set<string>): void {
|
||||
const disabledProviders = getDisabledProviderIdsFromSettings();
|
||||
const disabledProviders = getDisabledProviderIdsFromSettings(this.#settings);
|
||||
if (!configuredProviders.has("ollama") && !disabledProviders.has("ollama")) {
|
||||
this.#discoverableProviders.push({
|
||||
provider: "ollama",
|
||||
@@ -1058,7 +1062,7 @@ export class ModelRegistry {
|
||||
strategy: ModelRefreshStrategy,
|
||||
providerFilter?: ReadonlySet<string>,
|
||||
): Promise<void> {
|
||||
const disabledProviders = getDisabledProviderIdsFromSettings();
|
||||
const disabledProviders = getDisabledProviderIdsFromSettings(this.#settings);
|
||||
const selectedDiscoverableProviders = (
|
||||
providerFilter
|
||||
? this.#discoverableProviders.filter(provider => providerFilter.has(provider.provider))
|
||||
@@ -1367,7 +1371,7 @@ export class ModelRegistry {
|
||||
}),
|
||||
},
|
||||
];
|
||||
const disabledProviders = getDisabledProviderIdsFromSettings();
|
||||
const disabledProviders = getDisabledProviderIdsFromSettings(this.#settings);
|
||||
const standardProviderDescriptors = PROVIDER_DESCRIPTORS.filter(descriptor => {
|
||||
if (disabledProviders.has(descriptor.providerId)) return false;
|
||||
if (configuredDiscoveryProviders.has(descriptor.providerId)) return false;
|
||||
@@ -1592,7 +1596,7 @@ export class ModelRegistry {
|
||||
});
|
||||
}
|
||||
#applyHardcodedModelPolicies(models: Model<Api>[]): Model<Api>[] {
|
||||
const extendedContext = isExtendedContextEnabledFromSettings();
|
||||
const extendedContext = isExtendedContextEnabledFromSettings(this.#settings);
|
||||
return models.map(model => {
|
||||
// Extended context off: cap models with a premium long-context price
|
||||
// tier (e.g. GPT-5.6 bills 2x input above 272K) at the standard-pricing
|
||||
@@ -1683,7 +1687,7 @@ export class ModelRegistry {
|
||||
* full bundled catalog (thousands of models, ~50 providers).
|
||||
*/
|
||||
#createProviderAvailabilityCheck(): (provider: string) => boolean {
|
||||
const disabledProviders = getDisabledProviderIdsFromSettings();
|
||||
const disabledProviders = getDisabledProviderIdsFromSettings(this.#settings);
|
||||
const byProvider = new Map<string, boolean>();
|
||||
return provider => {
|
||||
let available = byProvider.get(provider);
|
||||
@@ -1752,7 +1756,7 @@ export class ModelRegistry {
|
||||
}
|
||||
|
||||
getDiscoverableProviders(): string[] {
|
||||
const disabledProviders = getDisabledProviderIdsFromSettings();
|
||||
const disabledProviders = getDisabledProviderIdsFromSettings(this.#settings);
|
||||
return this.#discoverableProviders
|
||||
.filter(provider => !disabledProviders.has(provider.provider))
|
||||
.map(provider => provider.provider);
|
||||
@@ -1770,7 +1774,7 @@ export class ModelRegistry {
|
||||
hasProvider(providerId: string): boolean {
|
||||
const providerModels = this.#hasFullSnapshot ? this.#models : this.#composeStaticModels(new Set([providerId]));
|
||||
if (providerModels.some(model => model.provider === providerId)) return true;
|
||||
if (getDisabledProviderIdsFromSettings().has(providerId)) return false;
|
||||
if (getDisabledProviderIdsFromSettings(this.#settings).has(providerId)) return false;
|
||||
return (
|
||||
this.#discoverableProviders.some(provider => provider.provider === providerId) ||
|
||||
this.#runtimeModelManagers.has(providerId)
|
||||
|
||||
@@ -1289,9 +1289,9 @@ export async function runRootCommand(
|
||||
// tree; declare it so headless subagent optimizations (e.g. skipping replan
|
||||
// title refresh) can tell a focusable process from a print/RPC/eval one.
|
||||
setInteractiveHost(isInteractive);
|
||||
// Create AuthStorage and ModelRegistry upfront. A configured-but-unreachable
|
||||
// auth broker throws here; convert it to an actionable stderr message + clean
|
||||
// exit instead of a raw uncaught stack trace (issue #8096).
|
||||
// Create AuthStorage upfront. A configured-but-unreachable auth broker throws
|
||||
// here; convert it to an actionable stderr message + clean exit instead of a
|
||||
// raw uncaught stack trace (issue #8096).
|
||||
let authStorage: AuthStorage;
|
||||
try {
|
||||
authStorage = await logger.time("discoverAuthStorage", deps.discoverAuthStorage ?? discoverAuthStorage);
|
||||
@@ -1301,7 +1301,6 @@ export async function runRootCommand(
|
||||
process.stderr.write(`${chalk.red(`Error: ${message}`)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const modelRegistry = logger.time("modelRegistry:init", () => new ModelRegistry(authStorage));
|
||||
|
||||
const settingsInstance =
|
||||
deps.settings ?? (await logger.time("settings:init", Settings.init, { cwd, configFiles: parsedArgs.config }));
|
||||
@@ -1319,6 +1318,13 @@ export async function runRootCommand(
|
||||
} else if (parsedArgs.mode === "acp") {
|
||||
applyAcpDefaultSettingOverrides(settingsInstance);
|
||||
}
|
||||
|
||||
// The registry composes policy-dependent metadata synchronously, including
|
||||
// extended-context window caps, so it must receive the finalized settings.
|
||||
const modelRegistry = logger.time(
|
||||
"modelRegistry:init",
|
||||
() => new ModelRegistry(authStorage, undefined, { settings: settingsInstance }),
|
||||
);
|
||||
if (parsedArgs.noPty || parsedArgs.mode === "rpc-ui") {
|
||||
Bun.env.PI_NO_PTY = "1";
|
||||
}
|
||||
|
||||
@@ -1241,12 +1241,19 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
|
||||
registerSshCleanup();
|
||||
registerEvalCleanup();
|
||||
|
||||
const settings = await (options.settings ??
|
||||
options.settingsManager ??
|
||||
logger.time("settings", Settings.init, { cwd, agentDir }));
|
||||
logger.time("initializeWithSettings", initializeWithSettings, settings);
|
||||
|
||||
// Pin authStorage to modelRegistry.authStorage: ModelRegistry.getApiKey() routes refresh
|
||||
// failures through that instance, so any divergent storage handed to the bridge / mcpManager
|
||||
// / session would silently miss credential_disabled events.
|
||||
const modelRegistry =
|
||||
options.modelRegistry ??
|
||||
new ModelRegistry(options.authStorage ?? (await logger.time("discoverModels", discoverAuthStorage, agentDir)));
|
||||
new ModelRegistry(options.authStorage ?? (await logger.time("discoverModels", discoverAuthStorage, agentDir)), undefined, {
|
||||
settings,
|
||||
});
|
||||
// Track whether we internally created the authStorage so we can close it
|
||||
// if construction fails before the session takes ownership.
|
||||
const ownsAuthStorage = !options.authStorage && !options.modelRegistry;
|
||||
@@ -1270,10 +1277,6 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
|
||||
startupCredentialDisabledEvents.push(event);
|
||||
}
|
||||
});
|
||||
const settings = await (options.settings ??
|
||||
options.settingsManager ??
|
||||
logger.time("settings", Settings.init, { cwd, agentDir }));
|
||||
logger.time("initializeWithSettings", initializeWithSettings, settings);
|
||||
if (!options.modelRegistry) {
|
||||
modelRegistry.refreshInBackground();
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
getModelMatchPreferences,
|
||||
resolveCliModel,
|
||||
} from "../config/model-resolver";
|
||||
import type { SettingPath } from "../config/settings";
|
||||
import type { SettingPath, Settings } from "../config/settings";
|
||||
import { describeLoopLimitRuntime } from "../modes/loop-limit";
|
||||
import type { InteractiveModeContext } from "../modes/types";
|
||||
import type { AgentSession } from "../session/agent-session";
|
||||
@@ -54,6 +54,32 @@ function formatFastModeStatus(session: AgentSession): string {
|
||||
return session.isFastModeEnabled() ? "on" : "off";
|
||||
}
|
||||
|
||||
/** `/extended-context status` label for the premium long-context window setting. */
|
||||
function formatExtendedContextStatus(settings: Settings): string {
|
||||
return settings.get("extendedContext") ? "on" : "off";
|
||||
}
|
||||
|
||||
/** Applies an `/extended-context` argument and returns its operator feedback. */
|
||||
function applyExtendedContextCommand(settings: Settings, args: string): string | undefined {
|
||||
const arg = args.trim().toLowerCase();
|
||||
const current = settings.get("extendedContext");
|
||||
if (!arg || arg === "toggle") {
|
||||
const enabled = !current;
|
||||
settings.set("extendedContext", enabled);
|
||||
return `Extended context ${enabled ? "enabled" : "disabled"}.`;
|
||||
}
|
||||
if (arg === "on") {
|
||||
settings.set("extendedContext", true);
|
||||
return "Extended context enabled.";
|
||||
}
|
||||
if (arg === "off") {
|
||||
settings.set("extendedContext", false);
|
||||
return "Extended context disabled.";
|
||||
}
|
||||
if (arg === "status") return `Extended context is ${formatExtendedContextStatus(settings)}.`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Detailed, session-effective `/computer status` diagnostics. */
|
||||
async function formatComputerUseStatus(session: AgentSession): Promise<string> {
|
||||
const enabled = session.settings.get("computer.enabled");
|
||||
@@ -433,6 +459,32 @@ export const BUILTIN_MODE_SLASH_COMMANDS: ReadonlyArray<SlashCommandSpec> = [
|
||||
runtime.ctx.editor.setText("");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "extended-context",
|
||||
description: "Toggle premium long-context windows",
|
||||
acpDescription: "Toggle extended context",
|
||||
acpInputHint: "[on|off|status]",
|
||||
subcommands: [
|
||||
{ name: "on", description: "Enable premium long-context windows" },
|
||||
{ name: "off", description: "Use standard-pricing context windows" },
|
||||
{ name: "status", description: "Show extended context status" },
|
||||
],
|
||||
allowArgs: true,
|
||||
getTuiAutocompleteDescription: runtime =>
|
||||
`Extended context: ${formatExtendedContextStatus(runtime.ctx.settings)}`,
|
||||
handle: async (command, runtime) => {
|
||||
const output = applyExtendedContextCommand(runtime.settings, command.args);
|
||||
if (!output) return usage("Usage: /extended-context [on|off|status]", runtime);
|
||||
await runtime.output(output);
|
||||
return commandConsumed();
|
||||
},
|
||||
handleTui: (command, runtime) => {
|
||||
const output = applyExtendedContextCommand(runtime.ctx.settings, command.args);
|
||||
refreshStatusLine(runtime.ctx);
|
||||
runtime.ctx.showStatus(output ?? "Usage: /extended-context [on|off|status]");
|
||||
runtime.ctx.editor.setText("");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "computer",
|
||||
description: "Toggle the native computer-use tool for this session",
|
||||
|
||||
@@ -243,6 +243,24 @@ describe("ACP builtin slash commands", () => {
|
||||
expect(output).toEqual(["Fast mode is off."]);
|
||||
});
|
||||
|
||||
it("toggles extended context with explicit controls and reports state", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
|
||||
expect(await executeAcpBuiltinSlashCommand("/extended-context off", runtime)).toEqual({ consumed: true });
|
||||
expect(runtime.settings.get("extendedContext")).toBe(false);
|
||||
expect(await executeAcpBuiltinSlashCommand("/extended-context on", runtime)).toEqual({ consumed: true });
|
||||
expect(runtime.settings.get("extendedContext")).toBe(true);
|
||||
expect(await executeAcpBuiltinSlashCommand("/extended-context", runtime)).toEqual({ consumed: true });
|
||||
expect(runtime.settings.get("extendedContext")).toBe(false);
|
||||
expect(await executeAcpBuiltinSlashCommand("/extended-context status", runtime)).toEqual({ consumed: true });
|
||||
expect(output).toEqual([
|
||||
"Extended context disabled.",
|
||||
"Extended context enabled.",
|
||||
"Extended context disabled.",
|
||||
"Extended context is off.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("forces a tool and returns remaining prompt text", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ describe("buildAvailableSlashCommands", () => {
|
||||
expect(byName["reset-usage"]).toBeUndefined();
|
||||
|
||||
expect(byName.fast.description).toBe("Toggle fast mode");
|
||||
expect(byName["extended-context"].description).toBe("Toggle extended context");
|
||||
expect(byName["ext:hello"].description).toBe("Extension hello");
|
||||
expect(byName["custom:hello"].description).toBe("Custom hello");
|
||||
expect(byName["server:prompt"].description).toBe("MCP prompt");
|
||||
|
||||
@@ -1029,6 +1029,40 @@ describe("createAgentSession deferred model pattern resolution", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("caps premium Codex context before a new session starts", async () => {
|
||||
const authStorage = createInMemoryAuthStorage();
|
||||
authStoragesToClose.push(authStorage);
|
||||
authStorage.setRuntimeApiKey("openai-codex", "codex-oauth-token");
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
cwd: tempDir,
|
||||
agentDir: tempDir,
|
||||
authStorage,
|
||||
settings: Settings.isolated({ extendedContext: false }),
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
disableExtensionDiscovery: true,
|
||||
skills: [],
|
||||
contextFiles: [],
|
||||
promptTemplates: [],
|
||||
slashCommands: [],
|
||||
enableMCP: false,
|
||||
enableLsp: false,
|
||||
skipPythonPreflight: true,
|
||||
rules: [],
|
||||
preloadedCustomToolPaths: [],
|
||||
toolNames: ["read"],
|
||||
modelPattern: "openai-codex/gpt-5.6-sol",
|
||||
});
|
||||
|
||||
try {
|
||||
expect(session.model?.provider).toBe("openai-codex");
|
||||
expect(session.model?.id).toBe("gpt-5.6-sol");
|
||||
expect(session.model?.contextWindow).toBe(272_000);
|
||||
} finally {
|
||||
await session.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("restores role model max selector from extension provider after startup resume", async () => {
|
||||
const defaultModel = getBundledModel("anthropic", "claude-sonnet-4-5");
|
||||
if (!defaultModel) {
|
||||
|
||||
Reference in New Issue
Block a user