revert(codex): remove Luna Reserve fallback (#1780)

Revert 8357da0a9d and its later shared-runtime integration.

Remove automatic model fallback, account usage polling, and the related UI, protocol fields, tests, and documentation without adding replacement quota handling.

Keep generic model pagination and method probing required by the current shared-session architecture. Cover idle sessions staying online without usage polling or agent-state churn.
This commit is contained in:
weishu
2026-09-12 14:36:45 +08:00
parent c75cf8154f
commit d18c01b4b6
18 changed files with 43 additions and 921 deletions
-10
View File
@@ -332,13 +332,3 @@ export function buildTurnStartParams(args: {
return params;
}
/** Resume the actual task settings before reconciling HAPI's queued configuration. */
export function withoutCodexModelOverrides(params: ThreadStartParams): ThreadStartParams {
const { model, serviceTier, ...rest } = params;
const config = { ...rest.config };
delete config.model;
delete config.model_reasoning_effort;
delete config.service_tier;
return { ...rest, config };
}
-175
View File
@@ -1,175 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { LunaReserve } from './lunaReserve';
import { withoutCodexModelOverrides } from './appServerConfig';
import type { EnhancedMode } from '../loop';
const threadId = '01900000-0000-7000-8000-000000000001';
const mode: EnhancedMode = { permissionMode: 'yolo', collaborationMode: 'plan', model: 'gpt-5.6', modelReasoningEffort: 'high' };
const dirs: string[] = [];
afterEach(async () => { await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))); });
async function fixture() {
const dir = await mkdtemp(join(tmpdir(), 'hapi-reserve-'));
dirs.push(dir);
const response = {
accountId: 'account-a', ordinaryUsageAllowed: true as boolean | null,
rateLimits: { limitId: 'codex', primary: { usedPercent: 40 }, secondary: { usedPercent: 60 }, spendControlReached: false, rateLimitReachedType: null as string | null },
rateLimitsByLimitId: {
base_model_inference: { limitId: 'base_model_inference', limitName: 'gpt-reserve', primary: { usedPercent: 10 as unknown, resetsAt: 2000000000, windowDurationMins: 123 }, secondary: { usedPercent: 25 } }
},
rateLimitUpsell: null as unknown
};
const client = {
readAccountRateLimits: vi.fn(async () => structuredClone(response) as unknown),
supportsMethod: vi.fn(async () => true),
listModels: vi.fn(async () => ({ data: [
{ id: 'gpt-reserve', hidden: true, defaultReasoningEffort: 'medium', supportedReasoningEfforts: [{ reasoningEffort: 'medium' }] },
{ id: 'gpt-5.6', hidden: false, defaultReasoningEffort: 'medium', supportedReasoningEfforts: [{ reasoningEffort: 'high' }] }
] })),
updateThreadSettings: vi.fn(async () => {})
};
const sync = vi.fn(); const publish = vi.fn(); const notice = vi.fn();
const reserve = new LunaReserve(client, sync, publish, notice, dir);
await reserve.initialize();
reserve.attach(threadId, { model: 'gpt-5.6', reasoningEffort: 'high', serviceTier: 'priority' });
const enter = async () => {
response.ordinaryUsageAllowed = false;
response.rateLimitUpsell = { banner_type: 'luna_reserve', blocked_model_slug: 'gpt-5.6' };
await reserve.refresh(mode, () => true);
};
const usage = () => publish.mock.lastCall?.[0];
return { dir, response, client, reserve, sync, publish, notice, enter, usage };
}
describe('Luna Reserve authoritative transitions', () => {
it('hides an available bucket and does not infer eligibility from exhausted percentages', async () => {
const f = await fixture();
f.response.rateLimits.primary.usedPercent = 100;
f.response.rateLimits.secondary.usedPercent = 100;
await f.reserve.refresh(mode, () => true);
expect(f.usage().reserve).toBeNull();
expect(f.client.updateThreadSettings).not.toHaveBeenCalled();
f.response.ordinaryUsageAllowed = false;
await f.reserve.refresh(mode, () => true);
expect(f.client.updateThreadSettings).not.toHaveBeenCalled();
});
it('activates only after acceptance, separates buckets, preserves return settings, and hides on recovery', async () => {
const f = await fixture();
await f.enter();
expect(f.client.readAccountRateLimits).toHaveBeenLastCalledWith(true);
expect(f.client.updateThreadSettings).toHaveBeenCalledWith({ threadId, model: 'gpt-reserve', effort: 'medium', serviceTier: null,
collaborationMode: { mode: 'plan', settings: { model: 'gpt-reserve', reasoning_effort: 'medium', developer_instructions: null } } });
expect(f.usage().ordinary.secondary.remainingPercent).toBe(40);
expect(f.usage().reserve.secondary.remainingPercent).toBe(75);
expect(f.usage().reserve.primary).toEqual({ remainingPercent: 90, resetsAt: 2000000000, windowDurationMins: 123 });
expect(f.sync).toHaveBeenLastCalledWith('gpt-5.6-luna', 'medium', 'standard');
expect(JSON.parse(await readFile(join(f.dir, 'tui-luna-reserve', `${threadId}.json`), 'utf8'))).toEqual({ account_id: 'account-a', model: 'gpt-5.6', effort: 'high' });
expect(f.reserve.turnMode(mode)).toMatchObject({ model: 'gpt-reserve', modelReasoningEffort: 'medium', serviceTier: 'standard' });
f.response.ordinaryUsageAllowed = true;
f.response.rateLimitUpsell = null;
await f.reserve.refresh(mode, () => true);
expect(f.sync).toHaveBeenLastCalledWith('gpt-5.6', 'high', 'standard');
expect(f.usage().reserve).toBeNull();
expect(f.notice).toHaveBeenCalledTimes(2);
});
it('retains exhausted Reserve without another switch or retry and treats missing/invalid usage as unknown', async () => {
const f = await fixture(); await f.enter();
for (const used of [undefined, null, '50', NaN, Infinity, -1, 101, 100]) {
f.response.rateLimitsByLimitId.base_model_inference.primary.usedPercent = used;
await f.reserve.refresh(mode, () => true);
expect(f.usage().reserve.primary.remainingPercent).toBe(used === 100 ? 0 : null);
}
expect(f.client.updateThreadSettings).toHaveBeenCalledTimes(1);
});
it('requires a fresh account-bound recovery read; sparse notifications and unknown banners cannot recover', async () => {
const f = await fixture(); await f.enter();
f.response.ordinaryUsageAllowed = true;
f.response.rateLimitUpsell = { banner_type: 'unknown_future_banner' };
await f.reserve.refresh(mode, () => true);
f.response.rateLimitUpsell = null;
f.response.accountId = 'account-b';
await f.reserve.refresh(mode, () => true);
expect(f.client.updateThreadSettings).toHaveBeenCalledTimes(1);
f.response.accountId = 'account-a';
let finish!: (value: unknown) => void;
f.client.readAccountRateLimits.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
const pending = f.reserve.refresh(mode, () => true);
await vi.waitFor(() => expect(finish).toBeTypeOf('function'));
f.reserve.invalidate();
finish(f.response); await pending;
expect(f.client.updateThreadSettings).toHaveBeenCalledTimes(1);
});
it('reconciles a resumed Reserve task with the TUI return target and ignores another thread settings', async () => {
const f = await fixture(); await f.enter(); f.reserve.dispose();
const resumed = new LunaReserve(f.client, f.sync, f.publish, f.notice, f.dir);
await resumed.initialize();
resumed.attach(threadId, { model: 'gpt-reserve', reasoningEffort: 'medium' });
resumed.onSettings('other-thread', { model: 'gpt-5.6', effort: 'high' });
expect(resumed.turnMode(mode).model).toBe('gpt-reserve');
f.response.ordinaryUsageAllowed = true; f.response.rateLimitUpsell = null;
await resumed.refresh(mode, () => true);
expect(f.sync).toHaveBeenLastCalledWith('gpt-5.6', 'high', 'standard');
expect(f.usage().reserve).toBeNull();
});
it('copies the native fork return target without deleting the parent recovery record', async () => {
const f = await fixture(); await f.enter();
const forkId = '01900000-0000-7000-8000-000000000002';
f.reserve.attach(forkId, { model: 'gpt-reserve', reasoningEffort: 'medium', thread: { forkedFromId: threadId } });
await f.reserve.refresh(mode, () => true);
const parent = join(f.dir, 'tui-luna-reserve', `${threadId}.json`);
const child = join(f.dir, 'tui-luna-reserve', `${forkId}.json`);
expect(await readFile(child, 'utf8')).toBe(await readFile(parent, 'utf8'));
f.response.ordinaryUsageAllowed = true; f.response.rateLimitUpsell = null;
await f.reserve.refresh(mode, () => true);
expect(f.client.updateThreadSettings).toHaveBeenLastCalledWith(expect.objectContaining({ threadId: forkId, model: 'gpt-5.6' }));
expect(JSON.parse(await readFile(parent, 'utf8')).model).toBe('gpt-5.6');
await expect(readFile(child, 'utf8')).rejects.toThrow();
f.reserve.detach();
expect(f.usage()).toBeNull();
});
it('keeps selection and visibility unchanged on a rejected settings update without looping', async () => {
const f = await fixture();
f.client.updateThreadSettings.mockRejectedValue(new Error('Method not found'));
await f.enter(); await f.reserve.refresh(mode, () => true);
expect(f.client.updateThreadSettings).toHaveBeenCalledTimes(1);
expect(f.usage().reserve).toBeNull();
expect(f.reserve.turnMode(mode).model).toBe('gpt-5.6');
expect(f.notice).toHaveBeenCalledTimes(1);
});
it('does not advertise or activate Reserve on an older protocol', async () => {
const f = await fixture();
f.client.readAccountRateLimits.mockImplementation(async () => ({ rateLimits: f.response.rateLimits, accountId: 'account-a', rateLimitUpsell: { banner_type: 'luna_reserve' } }));
const old = new LunaReserve(f.client, f.sync, f.publish, f.notice, f.dir);
await old.initialize(); old.attach(threadId, { model: 'gpt-5.6' });
await old.refresh(mode, () => true);
expect(f.client.readAccountRateLimits).toHaveBeenLastCalledWith(false);
expect(f.client.updateThreadSettings).not.toHaveBeenCalled();
});
it('waits until idle and hides state after exit/disposal', async () => {
const f = await fixture();
f.response.ordinaryUsageAllowed = false; f.response.rateLimitUpsell = { banner_type: 'luna_reserve' };
await f.reserve.refresh(mode, () => false);
expect(f.client.updateThreadSettings).not.toHaveBeenCalled();
await f.enter();
f.reserve.onSettings(threadId, { model: 'gpt-5.6', effort: 'high' });
expect(f.usage().reserve).toBeNull();
f.reserve.dispose();
expect(f.usage()).toBeNull();
});
it('omits model overrides on resume while retaining permissions, tools and instructions', () => {
expect(withoutCodexModelOverrides({ model: 'stale', serviceTier: 'priority', sandbox: 'workspace-write', config: { model_reasoning_effort: 'high', 'mcp_servers.hapi': {}, developer_instructions: 'hapi' } }))
.toEqual({ sandbox: 'workspace-write', config: { 'mcp_servers.hapi': {}, developer_instructions: 'hapi' } });
});
});
-301
View File
@@ -1,301 +0,0 @@
import { mkdir, open, rename, rm } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { z } from 'zod';
import { logger } from '@/ui/logger';
import type { AgentState } from '@hapi/protocol';
import type { CodexAppServerClient } from '../codexAppServerClient';
import type { EnhancedMode } from '../loop';
import type { ModelListItem, ThreadSettingsUpdateParams } from '../appServerTypes';
// This quota alias belongs only at the Codex boundary, never in HAPI's model picker.
const RESERVE_MODEL = 'gpt-reserve';
const LUNA_MODEL = 'gpt-5.6-luna';
const WindowSchema = z.object({
usedPercent: z.unknown().optional(),
windowDurationMins: z.unknown().optional(),
resetsAt: z.unknown().optional()
});
const BucketSchema = z.object({
limitId: z.string().nullish(),
limitName: z.string().nullish(),
normalModelSlug: z.string().nullish(),
primary: WindowSchema.nullish(),
secondary: WindowSchema.nullish(),
credits: z.object({ hasCredits: z.boolean(), unlimited: z.boolean() }).nullish(),
spendControlReached: z.boolean().nullish(),
rateLimitReachedType: z.string().nullish()
});
const UsageSchema = z.object({
accountId: z.string().nullish(),
ordinaryUsageAllowed: z.boolean().nullish(),
rateLimits: BucketSchema,
rateLimitsByLimitId: z.record(z.string(), BucketSchema).nullish(),
rateLimitUpsell: z.unknown().optional()
});
const BannerSchema = z.object({
banner_type: z.literal('luna_reserve'),
blocked_model_slug: z.string().min(1).max(256).nullish()
});
const ReturnSchema = z.object({
account_id: z.string().min(1),
model: z.string().min(1).max(256),
effort: z.string().nullable()
});
const SettingsSchema = z.object({
model: z.string().min(1),
effort: z.string().nullish(),
serviceTier: z.string().nullish()
});
type Usage = z.infer<typeof UsageSchema>;
type Bucket = z.infer<typeof BucketSchema>;
type Settings = z.infer<typeof SettingsSchema>;
type ReserveClient = Pick<CodexAppServerClient, 'readAccountRateLimits' | 'listModels' | 'supportsMethod' | 'updateThreadSettings'>;
function usageBucket(bucket?: Bucket | null): NonNullable<AgentState['codexUsage']>['ordinary'] {
const window = (value: Bucket['primary']) => {
if (!value) return null;
const finite = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n);
return {
remainingPercent: finite(value.usedPercent) && value.usedPercent >= 0 && value.usedPercent <= 100
? 100 - value.usedPercent : null,
windowDurationMins: finite(value.windowDurationMins) && value.windowDurationMins > 0 ? value.windowDurationMins : null,
resetsAt: finite(value.resetsAt) && value.resetsAt >= 0 ? value.resetsAt : null
};
};
return { primary: window(bucket?.primary), secondary: window(bucket?.secondary) };
}
/** Account reads authorize transitions; settings accepted by the thread establish activation. */
export class LunaReserve {
private threadId: string | null = null;
private settings: Settings | null = null;
private settingsRevision = 0;
private forkedFrom: string | null = null;
private snapshot: Usage | null = null;
private models: ModelListItem[] = [];
private supported = false;
private revision = 0;
private disposed = false;
private queue: Promise<void> = Promise.resolve();
constructor(
private readonly client: ReserveClient,
private readonly sync: (model: string, effort: string | null, serviceTier: string | null | undefined) => void,
private readonly publish: (usage: AgentState['codexUsage']) => void,
private readonly notice: (message: string) => void,
private readonly codexHome = process.env.CODEX_HOME || join(homedir(), '.codex')
) {}
async initialize(): Promise<void> {
this.supported = false;
this.models = [];
try {
const response = UsageSchema.parse(await this.client.readAccountRateLimits());
this.snapshot = response;
// Field presence is a protocol capability, not account eligibility.
if ('ordinaryUsageAllowed' in response && await this.client.supportsMethod('thread/settings/update')) {
let cursor: string | undefined;
do {
const page = await this.client.listModels({ includeHidden: true, ...(cursor ? { cursor } : {}) });
this.models.push(...(page.data ?? []));
cursor = page.nextCursor ?? undefined;
} while (cursor);
this.supported = this.models.some(model => (model.model ?? model.id) === RESERVE_MODEL);
}
this.publishUsage();
} catch {
// Older app-servers and API-key accounts can lack the usage endpoint.
this.publish(null);
}
}
attach(threadId: string, response: unknown, forkedFrom?: string): void {
const parsed = z.object({ model: z.string(), reasoningEffort: z.string().nullish(), serviceTier: z.string().nullish(), thread: z.object({ forkedFromId: z.string().nullish() }).optional() }).safeParse(response);
this.threadId = threadId;
this.forkedFrom = forkedFrom ?? (parsed.success ? parsed.data.thread?.forkedFromId : null) ?? null;
this.invalidate();
this.snapshot = null;
if (parsed.success) {
this.acceptSettings({ ...parsed.data, effort: parsed.data.reasoningEffort });
} else {
this.settings = null;
}
this.publishUsage();
}
onSettings(threadId: string, value: unknown): void {
if (threadId !== this.threadId || this.disposed) return;
const parsed = SettingsSchema.safeParse(value);
if (!parsed.success) return;
this.invalidate();
this.settingsRevision++;
this.acceptSettings(parsed.data);
this.publishUsage();
}
private acceptSettings(settings: Settings): void {
this.settings = settings;
this.sync(settings.model === RESERVE_MODEL ? LUNA_MODEL : settings.model, settings.effort ?? null,
settings.serviceTier === 'priority' ? 'fast' : settings.serviceTier === null ? 'standard' : undefined);
}
invalidate(): void {
this.revision++;
}
detach(): void {
this.invalidate();
this.threadId = null;
this.settings = null;
this.snapshot = null;
this.publishUsage();
}
dispose(): void {
this.disposed = true;
this.invalidate();
this.publish(null);
}
private publishUsage(): void {
const response = this.snapshot;
if (!response || this.disposed) {
this.publish(null);
return;
}
// Never substitute a model-specific bucket for ordinary account windows.
const ordinary = response.rateLimitsByLimitId?.codex
?? (!response.rateLimits.limitId || response.rateLimits.limitId === 'codex' ? response.rateLimits : null);
const reserve = Object.values(response.rateLimitsByLimitId ?? {}).find(bucket => bucket.limitName === RESERVE_MODEL)
?? (response.rateLimits.limitName === RESERVE_MODEL ? response.rateLimits : null);
this.publish({
ordinary: usageBucket(ordinary),
reserve: this.settings?.model === RESERVE_MODEL && response.accountId
? usageBucket(reserve) : null
});
}
/** Serialize polling with pre-submission reconciliation; never submit or replay a message here. */
refresh(mode: EnhancedMode, canSwitch: () => boolean): Promise<void> {
this.queue = this.queue.then(() => this.readAndReconcile(mode, canSwitch));
return this.queue;
}
private returnPath(threadId = this.threadId): string | null {
if (!z.string().uuid().safeParse(threadId).success) return null;
// Shared with the official TUI so local/remote handoff preserves the task's return target.
return join(this.codexHome, 'tui-luna-reserve', `${threadId}.json`);
}
private async loadReturn(threadId = this.threadId): Promise<z.infer<typeof ReturnSchema> | null> {
const path = this.returnPath(threadId);
if (!path) return null;
try {
const handle = await open(path, 'r');
try {
if ((await handle.stat()).size > 4096) return null;
return ReturnSchema.parse(JSON.parse(await handle.readFile('utf8')));
} finally {
await handle.close();
}
} catch { return null; }
}
private async saveReturn(value: z.infer<typeof ReturnSchema>): Promise<void> {
const path = this.returnPath();
if (!path) throw new Error('Invalid Codex thread ID');
await mkdir(join(this.codexHome, 'tui-luna-reserve'), { recursive: true });
const temporary = `${path}.${randomUUID()}.tmp`;
try {
const handle = await open(temporary, 'wx', 0o600);
try {
await handle.writeFile(JSON.stringify(value));
await handle.sync();
} finally { await handle.close(); }
await rename(temporary, path);
} finally { await rm(temporary, { force: true }); }
}
private async readAndReconcile(mode: EnhancedMode, canSwitch: () => boolean): Promise<void> {
if (this.disposed) return;
const revision = this.revision;
let applying = false;
try {
const response = UsageSchema.parse(await this.client.readAccountRateLimits(this.supported));
if (this.disposed || revision !== this.revision) return;
this.snapshot = response;
this.publishUsage();
if (!this.supported || !this.threadId || !this.settings || !response.accountId || !canSwitch()) return;
const current = this.settings;
const inReserve = current.model === RESERVE_MODEL;
let previous = inReserve ? await this.loadReturn() : null;
if (inReserve && !previous && this.forkedFrom) {
const inherited = await this.loadReturn(this.forkedFrom);
if (inherited?.account_id === response.accountId) {
await this.saveReturn(inherited);
previous = inherited;
}
}
const ordinary = response.rateLimits;
const recovered = response.ordinaryUsageAllowed != null
&& (response.ordinaryUsageAllowed || ordinary.credits?.hasCredits || ordinary.credits?.unlimited)
&& response.rateLimitUpsell == null
&& ordinary.spendControlReached !== true && ordinary.rateLimitReachedType == null;
const banner = BannerSchema.safeParse(response.rateLimitUpsell);
const entering = !inReserve && (!mode.model || mode.model === current.model)
&& response.ordinaryUsageAllowed === false && banner.success
&& (!banner.data.blocked_model_slug || banner.data.blocked_model_slug === current.model);
const targetId = entering ? RESERVE_MODEL
: inReserve && recovered && previous?.account_id === response.accountId ? previous.model : null;
const target = this.models.find(model => (model.model ?? model.id) === targetId && (entering || model.hidden !== true));
if (!target || !targetId || revision !== this.revision || !canSwitch()) return;
const desiredEffort = entering ? current.effort : previous?.effort;
const effort = target.supportedReasoningEfforts?.some(item => item.reasoningEffort === desiredEffort)
? desiredEffort : target.defaultReasoningEffort;
applying = true;
if (entering) await this.saveReturn({ account_id: response.accountId, model: current.model, effort: current.effort ?? null });
if (this.disposed || revision !== this.revision || !canSwitch()) return;
const params: ThreadSettingsUpdateParams = {
threadId: this.threadId,
model: targetId,
effort: effort ?? null,
serviceTier: null,
...(mode.collaborationMode ? { collaborationMode: {
mode: mode.collaborationMode,
settings: { model: targetId, reasoning_effort: effort ?? null, developer_instructions: null }
} } : {})
};
const settingsRevision = this.settingsRevision;
await this.client.updateThreadSettings(params);
if (this.disposed || params.threadId !== this.threadId) return;
if (this.settingsRevision !== settingsRevision
&& (this.settings?.model !== targetId || (this.settings.effort ?? null) !== (effort ?? null))) return;
this.acceptSettings({ model: targetId, effort, serviceTier: null });
if (!entering) {
const path = this.returnPath();
if (path) await rm(path, { force: true }).catch(error => logger.debug('[Codex] Failed to remove Reserve return record', error));
}
this.publishUsage();
this.notice(entering
? 'Ordinary Codex usage is exhausted. Switched to GPT-5.6 Luna using Luna Reserve.'
: `Ordinary Codex usage is available again. Switched back to ${targetId}.`);
} catch (error) {
logger.debug('[Codex] Reserve reconciliation unavailable', error);
if (applying) {
this.supported = false;
this.notice('Codex could not apply the Reserve transition. Task settings are unchanged; no message was retried.');
}
this.publishUsage();
}
}
turnMode(mode: EnhancedMode): EnhancedMode {
if (this.settings?.model !== RESERVE_MODEL) return mode;
const target = this.models.find(model => (model.model ?? model.id) === RESERVE_MODEL);
const effort = target?.supportedReasoningEfforts?.some(item => item.reasoningEffort === mode.modelReasoningEffort)
? mode.modelReasoningEffort : this.settings.effort ?? undefined;
return { ...mode, model: RESERVE_MODEL, modelReasoningEffort: effort, serviceTier: 'standard' };
}
}