mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
feat(codex-dashboard): add manual long-context controls
This commit is contained in:
@@ -91,6 +91,8 @@ export interface CodexSupportMatrixEntry {
|
||||
export interface CodexUserConfigDiagnostics {
|
||||
model: string | null;
|
||||
modelReasoningEffort: string | null;
|
||||
modelContextWindow: number | null;
|
||||
modelAutoCompactTokenLimit: number | null;
|
||||
modelProvider: string | null;
|
||||
activeProfile: string | null;
|
||||
approvalPolicy: string | null;
|
||||
@@ -137,6 +139,8 @@ export interface CodexRawConfigResponse {
|
||||
export interface CodexTopLevelSettingsPatch {
|
||||
model?: string | null;
|
||||
modelReasoningEffort?: string | null;
|
||||
modelContextWindow?: number | null;
|
||||
modelAutoCompactTokenLimit?: number | null;
|
||||
modelProvider?: string | null;
|
||||
approvalPolicy?: string | null;
|
||||
sandboxMode?: string | null;
|
||||
|
||||
@@ -243,6 +243,18 @@ function applyTopLevelSettingsPatch(
|
||||
'model_reasoning_effort'
|
||||
);
|
||||
}
|
||||
if (hasOwn(values, 'modelContextWindow')) {
|
||||
setNumberField(target, 'model_context_window', values.modelContextWindow, {
|
||||
integer: true,
|
||||
min: 1,
|
||||
});
|
||||
}
|
||||
if (hasOwn(values, 'modelAutoCompactTokenLimit')) {
|
||||
setNumberField(target, 'model_auto_compact_token_limit', values.modelAutoCompactTokenLimit, {
|
||||
integer: true,
|
||||
min: 1,
|
||||
});
|
||||
}
|
||||
if (hasOwn(values, 'modelProvider')) {
|
||||
setStringField(target, 'model_provider', values.modelProvider);
|
||||
}
|
||||
@@ -778,6 +790,8 @@ export async function getCodexDashboardDiagnostics(): Promise<CodexDashboardDiag
|
||||
config: {
|
||||
model: asString(config?.model),
|
||||
modelReasoningEffort: asString(config?.model_reasoning_effort),
|
||||
modelContextWindow: asNumber(config?.model_context_window),
|
||||
modelAutoCompactTokenLimit: asNumber(config?.model_auto_compact_token_limit),
|
||||
modelProvider: asString(config?.model_provider),
|
||||
activeProfile,
|
||||
approvalPolicy: summarizeApprovalPolicy(config?.approval_policy),
|
||||
|
||||
@@ -179,6 +179,8 @@ describe('codex-dashboard-service', () => {
|
||||
path.join(codexHome, 'config.toml'),
|
||||
`model = "gpt-5.4"
|
||||
profile = "work"
|
||||
model_context_window = 800000
|
||||
model_auto_compact_token_limit = 700000
|
||||
model_provider = "cliproxy"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "danger-full-access"
|
||||
@@ -217,6 +219,8 @@ model = "gpt-5.4"
|
||||
expect(diagnostics.binary.installed).toBe(true);
|
||||
expect(diagnostics.binary.supportsConfigOverrides).toBe(true);
|
||||
expect(diagnostics.config.model).toBe('gpt-5.4');
|
||||
expect(diagnostics.config.modelContextWindow).toBe(800000);
|
||||
expect(diagnostics.config.modelAutoCompactTokenLimit).toBe(700000);
|
||||
expect(diagnostics.config.activeProfile).toBe('work');
|
||||
expect(diagnostics.config.modelProvider).toBe('cliproxy');
|
||||
expect(diagnostics.config.profileCount).toBe(1);
|
||||
@@ -376,6 +380,8 @@ bearer_token = "secret"
|
||||
values: {
|
||||
model: 'gpt-5.4',
|
||||
modelReasoningEffort: 'high',
|
||||
modelContextWindow: 800000,
|
||||
modelAutoCompactTokenLimit: 700000,
|
||||
approvalPolicy: 'never',
|
||||
sandboxMode: 'workspace-write',
|
||||
webSearch: 'cached',
|
||||
@@ -394,10 +400,14 @@ bearer_token = "secret"
|
||||
const diagnostics = await getCodexDashboardDiagnostics();
|
||||
expect(diagnostics.config.model).toBe('gpt-5.4');
|
||||
expect(diagnostics.config.modelReasoningEffort).toBe('high');
|
||||
expect(diagnostics.config.modelContextWindow).toBe(800000);
|
||||
expect(diagnostics.config.modelAutoCompactTokenLimit).toBe(700000);
|
||||
expect(diagnostics.config.toolOutputTokenLimit).toBe(12000);
|
||||
expect(diagnostics.config.personality).toBe('friendly');
|
||||
expect(diagnostics.config.projectTrust[0]?.path).toBe('/tmp/workspace-a');
|
||||
expect(result.rawText).toContain('model = "gpt-5.4"');
|
||||
expect(result.rawText).toContain('model_context_window = 800000');
|
||||
expect(result.rawText).toContain('model_auto_compact_token_limit = 700000');
|
||||
expect(result.config?.model).toBe('gpt-5.4');
|
||||
});
|
||||
|
||||
@@ -665,4 +675,24 @@ bearer_token = "secret"
|
||||
})
|
||||
).rejects.toThrow(CodexRawConfigValidationError);
|
||||
});
|
||||
|
||||
it('rejects invalid long-context values in structured top-level patches', async () => {
|
||||
await expect(
|
||||
patchCodexConfig({
|
||||
kind: 'top-level',
|
||||
values: {
|
||||
modelContextWindow: 0,
|
||||
},
|
||||
})
|
||||
).rejects.toThrow(CodexRawConfigValidationError);
|
||||
|
||||
await expect(
|
||||
patchCodexConfig({
|
||||
kind: 'top-level',
|
||||
values: {
|
||||
modelAutoCompactTokenLimit: 1.5,
|
||||
},
|
||||
})
|
||||
).rejects.toThrow(CodexRawConfigValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2, SlidersHorizontal } from 'lucide-react';
|
||||
import { CircleAlert, Loader2, SlidersHorizontal } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
@@ -14,6 +15,11 @@ import type { CodexTopLevelSettingsView } from '@/lib/codex-config';
|
||||
import { CodexConfigCardShell } from './codex-config-card-shell';
|
||||
|
||||
const UNSET = '__unset__';
|
||||
const GPT_54_MAX_CONTEXT_WINDOW = 1_050_000;
|
||||
const GPT_54_STANDARD_CONTEXT_WINDOW = 272_000;
|
||||
const CCS_GPT_54_STARTER_CONTEXT_WINDOW = 800_000;
|
||||
const CCS_GPT_54_STARTER_AUTO_COMPACT_TOKEN_LIMIT = 700_000;
|
||||
const INTEGER_FORMATTER = new Intl.NumberFormat('en-US');
|
||||
|
||||
interface CodexTopLevelControlsCardProps {
|
||||
values: CodexTopLevelSettingsView;
|
||||
@@ -32,6 +38,14 @@ function withCurrentValue(options: string[], current: string | null | undefined)
|
||||
return current && !options.includes(current) ? [current, ...options] : options;
|
||||
}
|
||||
|
||||
function formatInteger(value: number) {
|
||||
return INTEGER_FORMATTER.format(value);
|
||||
}
|
||||
|
||||
function isGpt54ModelId(value: string | null | undefined) {
|
||||
return value?.trim().toLowerCase().startsWith('gpt-5.4') ?? false;
|
||||
}
|
||||
|
||||
function buildTopLevelPatch(
|
||||
initialValues: CodexTopLevelSettingsView,
|
||||
draft: CodexTopLevelSettingsView
|
||||
@@ -42,6 +56,12 @@ function buildTopLevelPatch(
|
||||
if (draft.modelReasoningEffort !== initialValues.modelReasoningEffort) {
|
||||
patch.modelReasoningEffort = draft.modelReasoningEffort;
|
||||
}
|
||||
if (draft.modelContextWindow !== initialValues.modelContextWindow) {
|
||||
patch.modelContextWindow = draft.modelContextWindow;
|
||||
}
|
||||
if (draft.modelAutoCompactTokenLimit !== initialValues.modelAutoCompactTokenLimit) {
|
||||
patch.modelAutoCompactTokenLimit = draft.modelAutoCompactTokenLimit;
|
||||
}
|
||||
if (draft.modelProvider !== initialValues.modelProvider) {
|
||||
patch.modelProvider = draft.modelProvider;
|
||||
}
|
||||
@@ -91,6 +111,11 @@ function TopLevelControlsForm({
|
||||
const personalityOptions = withCurrentValue(['none', 'friendly', 'pragmatic'], draft.personality);
|
||||
const patch = buildTopLevelPatch(initialValues, draft);
|
||||
const hasChanges = Object.keys(patch).length > 0;
|
||||
const isGpt54Selected = isGpt54ModelId(draft.model);
|
||||
const parseOptionalInteger = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? Number(trimmed) : null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -266,6 +291,214 @@ function TopLevelControlsForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-xl border border-amber-500/30 bg-amber-500/5 p-4 shadow-sm dark:bg-amber-400/5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CircleAlert className="h-4 w-4 text-amber-600 dark:text-amber-300" />
|
||||
<p className="text-sm font-semibold">Long context override</p>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/40 bg-background/80 text-[10px] uppercase tracking-[0.16em] text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
Manual opt-in only
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] uppercase tracking-[0.16em] text-muted-foreground"
|
||||
>
|
||||
{isGpt54Selected ? 'GPT-5.4 selected' : 'GPT-5.4 reference'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Draft values only. Nothing applies until Save.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
modelContextWindow: CCS_GPT_54_STARTER_CONTEXT_WINDOW,
|
||||
modelAutoCompactTokenLimit: CCS_GPT_54_STARTER_AUTO_COMPACT_TOKEN_LIMIT,
|
||||
}))
|
||||
}
|
||||
>
|
||||
Fill cautious pair
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
modelContextWindow: GPT_54_MAX_CONTEXT_WINDOW,
|
||||
}))
|
||||
}
|
||||
>
|
||||
Set official max window
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
modelContextWindow: null,
|
||||
modelAutoCompactTokenLimit: null,
|
||||
}))
|
||||
}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<div className="rounded-lg border bg-background/85 px-3 py-3 shadow-sm shadow-black/5">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Official max
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-base font-semibold text-foreground">1.05M / 1M</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">GPT-5.4 context cap</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background/85 px-3 py-3 shadow-sm shadow-black/5">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Standard window
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-base font-semibold text-foreground">
|
||||
{formatInteger(GPT_54_STANDARD_CONTEXT_WINDOW)}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">Normal usage window</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background/85 px-3 py-3 shadow-sm shadow-black/5">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Above 272K
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-base font-semibold text-foreground">Counts 2x</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">Usage-limit cost above 272K</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-lg border bg-background/75 px-3 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
One cautious pair
|
||||
</p>
|
||||
<div className="rounded-full border bg-background px-2.5 py-1 font-mono text-[11px] font-medium">
|
||||
Context {formatInteger(CCS_GPT_54_STARTER_CONTEXT_WINDOW)}
|
||||
</div>
|
||||
<div className="rounded-full border bg-background px-2.5 py-1 font-mono text-[11px] font-medium">
|
||||
Auto-compact {formatInteger(CCS_GPT_54_STARTER_AUTO_COMPACT_TOKEN_LIMIT)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-border/70 bg-background/80 text-[10px] uppercase tracking-[0.14em] text-muted-foreground"
|
||||
>
|
||||
Not official
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-border/70 bg-background/80 text-[10px] uppercase tracking-[0.14em] text-muted-foreground"
|
||||
>
|
||||
Draft only
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span>Quick-fill only. Review before saving.</span>
|
||||
{!isGpt54Selected && draft.model ? (
|
||||
<span>
|
||||
<code>{draft.model}</code> should be checked separately.
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">Model context window</p>
|
||||
<Input
|
||||
aria-label="Model context window"
|
||||
type="number"
|
||||
min={1}
|
||||
value={draft.modelContextWindow ?? ''}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
modelContextWindow: parseOptionalInteger(event.target.value),
|
||||
}))
|
||||
}
|
||||
placeholder="Unset"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Writes <code>model_context_window</code>. Leave unset to keep Codex defaults.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">Auto-compact token limit</p>
|
||||
<Input
|
||||
aria-label="Auto-compact token limit"
|
||||
type="number"
|
||||
min={1}
|
||||
value={draft.modelAutoCompactTokenLimit ?? ''}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
modelAutoCompactTokenLimit: parseOptionalInteger(event.target.value),
|
||||
}))
|
||||
}
|
||||
placeholder="Unset"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Writes <code>model_auto_compact_token_limit</code>. Leave unset to keep model
|
||||
defaults.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span className="text-[10px] uppercase tracking-[0.14em]">Docs</span>
|
||||
<a
|
||||
href="https://developers.openai.com/api/docs/models/gpt-5.4"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
GPT-5.4 model page
|
||||
</a>
|
||||
<a
|
||||
href="https://openai.com/index/introducing-gpt-5-4/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
Release notes
|
||||
</a>
|
||||
<a
|
||||
href="https://developers.openai.com/codex/config-reference"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
Config reference
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => onSave(patch)} disabled={disabled || saving || !hasChanges}>
|
||||
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface CodexTopLevelSettingsView {
|
||||
model: string | null;
|
||||
modelReasoningEffort: string | null;
|
||||
modelContextWindow: number | null;
|
||||
modelAutoCompactTokenLimit: number | null;
|
||||
modelProvider: string | null;
|
||||
approvalPolicy: string | null;
|
||||
sandboxMode: string | null;
|
||||
@@ -125,6 +127,8 @@ export function readCodexTopLevelSettings(
|
||||
return {
|
||||
model: asString(config?.model),
|
||||
modelReasoningEffort: asString(config?.model_reasoning_effort),
|
||||
modelContextWindow: asNumber(config?.model_context_window),
|
||||
modelAutoCompactTokenLimit: asNumber(config?.model_auto_compact_token_limit),
|
||||
modelProvider: asString(config?.model_provider),
|
||||
approvalPolicy: asString(config?.approval_policy),
|
||||
sandboxMode: asString(config?.sandbox_mode),
|
||||
|
||||
@@ -30,6 +30,8 @@ function buildDiagnostics(activeProfile: string | null): CodexDashboardDiagnosti
|
||||
config: {
|
||||
model: 'gpt-5.4',
|
||||
modelReasoningEffort: null,
|
||||
modelContextWindow: null,
|
||||
modelAutoCompactTokenLimit: null,
|
||||
modelProvider: 'openai',
|
||||
activeProfile,
|
||||
approvalPolicy: null,
|
||||
|
||||
@@ -11,6 +11,8 @@ describe('CodexTopLevelControlsCard', () => {
|
||||
values={{
|
||||
model: null,
|
||||
modelReasoningEffort: null,
|
||||
modelContextWindow: null,
|
||||
modelAutoCompactTokenLimit: null,
|
||||
modelProvider: null,
|
||||
approvalPolicy: null,
|
||||
sandboxMode: null,
|
||||
@@ -34,4 +36,69 @@ describe('CodexTopLevelControlsCard', () => {
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
expect(onSave).toHaveBeenCalledWith({ model: 'gpt-5.4-mini' });
|
||||
});
|
||||
|
||||
it('submits manual long-context overrides without auto-filling defaults', async () => {
|
||||
const onSave = vi.fn();
|
||||
|
||||
render(
|
||||
<CodexTopLevelControlsCard
|
||||
values={{
|
||||
model: 'gpt-5.4',
|
||||
modelReasoningEffort: null,
|
||||
modelContextWindow: null,
|
||||
modelAutoCompactTokenLimit: null,
|
||||
modelProvider: null,
|
||||
approvalPolicy: null,
|
||||
sandboxMode: null,
|
||||
webSearch: null,
|
||||
toolOutputTokenLimit: null,
|
||||
personality: null,
|
||||
}}
|
||||
providerNames={[]}
|
||||
onSave={onSave}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Model context window'), '800000');
|
||||
await userEvent.type(screen.getByLabelText('Auto-compact token limit'), '700000');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save top-level settings' }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
modelContextWindow: 800000,
|
||||
modelAutoCompactTokenLimit: 700000,
|
||||
});
|
||||
});
|
||||
|
||||
it('fills draft starter values without saving automatically', async () => {
|
||||
const onSave = vi.fn();
|
||||
|
||||
render(
|
||||
<CodexTopLevelControlsCard
|
||||
values={{
|
||||
model: 'gpt-5.4',
|
||||
modelReasoningEffort: null,
|
||||
modelContextWindow: null,
|
||||
modelAutoCompactTokenLimit: null,
|
||||
modelProvider: null,
|
||||
approvalPolicy: null,
|
||||
sandboxMode: null,
|
||||
webSearch: null,
|
||||
toolOutputTokenLimit: null,
|
||||
personality: null,
|
||||
}}
|
||||
providerNames={[]}
|
||||
onSave={onSave}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Manual opt-in only')).toBeInTheDocument();
|
||||
expect(screen.getByText('1.05M / 1M')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Fill cautious pair' }));
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText('Model context window')).toHaveValue(800000);
|
||||
expect(screen.getByLabelText('Auto-compact token limit')).toHaveValue(700000);
|
||||
expect(screen.getByRole('button', { name: 'Save top-level settings' })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,8 @@ const diagnosticsResponse = {
|
||||
config: {
|
||||
model: 'gpt-5.3-codex',
|
||||
modelReasoningEffort: null,
|
||||
modelContextWindow: null,
|
||||
modelAutoCompactTokenLimit: null,
|
||||
modelProvider: null,
|
||||
activeProfile: null,
|
||||
approvalPolicy: null,
|
||||
|
||||
@@ -79,6 +79,8 @@ const diagnostics = {
|
||||
config: {
|
||||
model: 'gpt-5.4',
|
||||
modelReasoningEffort: null,
|
||||
modelContextWindow: null,
|
||||
modelAutoCompactTokenLimit: null,
|
||||
modelProvider: null,
|
||||
activeProfile: null,
|
||||
approvalPolicy: null,
|
||||
|
||||
Reference in New Issue
Block a user