Fix fast service tier state detection

This commit is contained in:
murashit 2026-05-29 09:44:25 +09:00
parent 4c0b8d30c3
commit d80d9789e8
8 changed files with 218 additions and 16 deletions

View file

@ -2,13 +2,21 @@ export type ServiceTier = string;
export type RequestedServiceTier = "fast" | "off";
export type ServiceTierRequest = string | null | undefined;
export interface ServiceTierMetadata {
id: string;
name: string;
}
export function parseServiceTier(value: unknown): ServiceTier | null {
if (typeof value === "string" && value.length > 0) return value;
return null;
}
export function requestedServiceTierRequestValue(value: RequestedServiceTier | null): "fast" | null | undefined {
if (value === "fast") return "fast";
export function requestedServiceTierRequestValue(
value: RequestedServiceTier | null,
fastServiceTierId = "fast",
): string | null | undefined {
if (value === "fast") return fastServiceTierId;
if (value === "off") return null;
return undefined;
}
@ -16,3 +24,10 @@ export function requestedServiceTierRequestValue(value: RequestedServiceTier | n
export function configuredServiceTierRequestValue(value: ServiceTier | null): string | undefined {
return value ?? undefined;
}
export function isFastServiceTier(value: ServiceTier | null | undefined, serviceTiers: readonly ServiceTierMetadata[] = []): boolean {
if (!value) return false;
if (value === "fast") return true;
if (serviceTiers.length === 0) return value === "priority";
return serviceTiers.some((tier) => tier.id === value && tier.name.trim().toLowerCase() === "fast");
}

View file

@ -8,7 +8,8 @@ import { collaborationModeToggleMessage, nextCollaborationMode } from "../../run
import { readRuntimeConfig } from "../../runtime/config";
import {
autoReviewActive,
currentServiceTier,
fastModeActive,
fastServiceTierRequestValue,
requestedTurnRuntimeSettings,
runtimeOverridePayload,
type RuntimeSnapshot,
@ -78,8 +79,9 @@ export class ChatRuntimeSettingsController {
}
async toggleFastMode(): Promise<void> {
const current = currentServiceTier(this.host.runtimeSnapshot(), readRuntimeConfig(this.state.effectiveConfig));
const next: RequestedServiceTier = current === "fast" ? "off" : "fast";
const snapshot = this.host.runtimeSnapshot();
const config = readRuntimeConfig(this.state.effectiveConfig);
const next: RequestedServiceTier = fastModeActive(snapshot, config) ? "off" : "fast";
this.dispatch({ type: "runtime/requested-service-tier-set", serviceTier: next });
this.dispatch({ type: "ui/panel-set", panel: null });
if (!(await this.applyPendingThreadSettings())) return;
@ -112,7 +114,8 @@ export class ChatRuntimeSettingsController {
private pendingThreadSettingsUpdate(): ThreadSettingsUpdate {
const update: ThreadSettingsUpdate = {};
const state = this.state;
const turnSettings = requestedTurnRuntimeSettings(this.host.runtimeSnapshot());
const snapshot = this.host.runtimeSnapshot();
const turnSettings = requestedTurnRuntimeSettings(snapshot);
if (state.requestedModel.kind !== "default") {
const model = runtimeOverridePayload(state.requestedModel);
@ -123,7 +126,10 @@ export class ChatRuntimeSettingsController {
if (effort !== undefined) update.effort = effort;
}
if (state.requestedServiceTier !== null) {
const serviceTier = requestedServiceTierRequestValue(state.requestedServiceTier);
const serviceTier = requestedServiceTierRequestValue(
state.requestedServiceTier,
fastServiceTierRequestValue(snapshot, readRuntimeConfig(state.effectiveConfig)),
);
if (serviceTier !== undefined) update.serviceTier = serviceTier;
}
if (state.requestedApprovalsReviewer !== null) {

View file

@ -5,7 +5,7 @@ import {
autoReviewActive,
currentModel,
currentReasoningEffort,
currentServiceTier,
fastModeActive,
runtimeOverrideLabel,
runtimeSummaryLabel,
serviceTierLabel,
@ -156,7 +156,7 @@ export function toolbarViewModel(input: ToolbarViewModelInput): ToolbarViewModel
runtimeOpen,
planActive: state.requestedCollaborationMode === "plan",
autoReviewActive: autoReviewActive(snapshot, config),
fastActive: currentServiceTier(snapshot, config) === "fast",
fastActive: fastModeActive(snapshot, config),
runtimeSummary: runtimeSummaryLabel(model, effort),
runtimeTitle: `Model: ${model ?? "(Codex default)"}; Effort: ${effort ?? "(Codex default)"}`,
runtimeEmphasized: false,

View file

@ -10,6 +10,7 @@ import type { RateLimitSnapshot } from "../generated/app-server/v2/RateLimitSnap
import type { ThreadTokenUsage } from "../generated/app-server/v2/ThreadTokenUsage";
import {
configuredServiceTierRequestValue,
isFastServiceTier,
requestedServiceTierRequestValue,
type RequestedServiceTier,
type ServiceTier,
@ -61,7 +62,9 @@ export function requestedOrConfiguredServiceTier(
snapshot: RuntimeSnapshot,
config: RuntimeConfigProjection = readRuntimeConfig(snapshot.effectiveConfig),
): ServiceTierRequest {
if (snapshot.requestedServiceTier !== null) return requestedServiceTierRequestValue(snapshot.requestedServiceTier);
if (snapshot.requestedServiceTier !== null) {
return requestedServiceTierRequestValue(snapshot.requestedServiceTier, fastServiceTierRequestValue(snapshot, config));
}
return configuredServiceTierRequestValue(config.serviceTier);
}
@ -138,16 +141,30 @@ export function serviceTierLabel(
return currentServiceTier(snapshot, config) ?? "(Codex default)";
}
export function fastModeActive(
snapshot: RuntimeSnapshot,
config: RuntimeConfigProjection = readRuntimeConfig(snapshot.effectiveConfig),
): boolean {
return isFastServiceTier(currentServiceTier(snapshot, config), currentModelServiceTiers(snapshot, config));
}
export function fastModeLabel(
snapshot: RuntimeSnapshot,
config: RuntimeConfigProjection = readRuntimeConfig(snapshot.effectiveConfig),
): string {
if (snapshot.requestedServiceTier === "off") return "off";
if (fastModeActive(snapshot, config)) return "on";
const serviceTier = currentServiceTier(snapshot, config);
if (serviceTier === "fast") return "on";
return serviceTier ? "off" : "Codex default";
}
export function fastServiceTierRequestValue(
snapshot: RuntimeSnapshot,
config: RuntimeConfigProjection = readRuntimeConfig(snapshot.effectiveConfig),
): string {
return currentModelServiceTiers(snapshot, config).find((tier) => tier.name.trim().toLowerCase() === "fast")?.id ?? "fast";
}
export function defaultRuntimeOverride<T>(): RuntimeOverride<T> {
return { kind: "default" };
}
@ -175,3 +192,10 @@ export function runtimeOverrideLabel<T>(override: RuntimeOverride<T>): string {
function isAutoReviewReviewer(value: ApprovalsReviewer | null): boolean {
return value === "auto_review" || value === "guardian_subagent";
}
function currentModelServiceTiers(
snapshot: RuntimeSnapshot,
config: RuntimeConfigProjection = readRuntimeConfig(snapshot.effectiveConfig),
): Model["serviceTiers"] {
return findModelByIdOrName(snapshot.availableModels, currentModel(snapshot, config))?.serviceTiers ?? [];
}

View file

@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest";
import { configuredServiceTierRequestValue, parseServiceTier, requestedServiceTierRequestValue } from "../../src/app-server/service-tier";
import {
configuredServiceTierRequestValue,
isFastServiceTier,
parseServiceTier,
requestedServiceTierRequestValue,
} from "../../src/app-server/service-tier";
describe("service tier", () => {
it("accepts non-empty service tier ids from config and app-server reports", () => {
@ -20,6 +25,7 @@ describe("service tier", () => {
it("serializes panel fast mode off as an explicit null request", () => {
expect(requestedServiceTierRequestValue("fast")).toBe("fast");
expect(requestedServiceTierRequestValue("fast", "priority")).toBe("priority");
expect(requestedServiceTierRequestValue("off")).toBeNull();
expect(requestedServiceTierRequestValue(null)).toBeUndefined();
});
@ -29,4 +35,23 @@ describe("service tier", () => {
expect(configuredServiceTierRequestValue("catalog-tier")).toBe("catalog-tier");
expect(configuredServiceTierRequestValue(null)).toBeUndefined();
});
it("recognizes Codex fast tier aliases without rejecting other tier ids", () => {
expect(isFastServiceTier("fast")).toBe(true);
expect(isFastServiceTier("priority")).toBe(true);
expect(isFastServiceTier("catalog-fast", [{ id: "catalog-fast", name: "Fast" }])).toBe(true);
expect(isFastServiceTier("priority", [{ id: "priority", name: "Priority" }])).toBe(false);
expect(isFastServiceTier("flex", [{ id: "flex", name: "Flex" }])).toBe(false);
});
it("locks the Codex 0.134.0 Fast catalog semantics observed from app-server", () => {
// Codex app-server 0.134.0 reports Fast as serviceTiers[{ id: "priority", name: "Fast" }].
const codex01340FastTier = { id: "priority", name: "Fast" };
expect(requestedServiceTierRequestValue("fast", codex01340FastTier.id)).toBe("priority");
expect(isFastServiceTier("priority", [codex01340FastTier])).toBe(true);
// Clearing Fast with serviceTier: null is reported back by 0.134.0 as "default", not null.
expect(isFastServiceTier("default", [codex01340FastTier])).toBe(false);
});
});

View file

@ -1,8 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { ChatRuntimeSettingsController } from "../../../src/features/chat/runtime-settings-controller";
import { createChatState, createChatStateStore } from "../../../src/features/chat/chat-state";
import { createChatState, createChatStateStore, type ChatAction } from "../../../src/features/chat/chat-state";
import type { AppServerClient } from "../../../src/app-server/client";
import type { Model } from "../../../src/generated/app-server/v2/Model";
describe("ChatRuntimeSettingsController", () => {
it("applies pending runtime overrides through thread settings and commits them", async () => {
@ -62,6 +63,30 @@ describe("ChatRuntimeSettingsController", () => {
expect(messages).toEqual(["Fast mode on for subsequent turns."]);
});
it("requests the catalog Fast tier id and toggles it off from the reported effective id", async () => {
const state = createChatState();
state.activeThreadId = "thread";
state.activeModel = "gpt-5.5";
// Codex app-server 0.134.0 advertises Fast as id "priority" and reports that id as the effective service tier.
state.availableModels = [modelFixture("gpt-5.5", "priority")];
const store = createChatStateStore(state);
const client = clientFixture();
const messages: string[] = [];
const controller = runtimeControllerFixture(store, client, messages);
await controller.toggleFastMode();
expect(client.updateThreadSettings).toHaveBeenLastCalledWith("thread", { serviceTier: "priority" });
expect(store.getState().activeServiceTier).toBe("priority");
store.dispatch({ type: "thread/settings-applied", ...threadSettings("priority") });
await controller.toggleFastMode();
expect(client.updateThreadSettings).toHaveBeenLastCalledWith("thread", { serviceTier: null });
expect(store.getState().activeServiceTier).toBeNull();
expect(messages).toEqual(["Fast mode on for subsequent turns.", "Fast mode off for subsequent turns."]);
});
it("leaves pending override in place when the app-server update fails", async () => {
const state = createChatState();
state.activeThreadId = "thread";
@ -122,3 +147,37 @@ function clientFixture(
...overrides,
};
}
function modelFixture(model: string, fastTierId: string): Model {
return {
id: model,
model,
upgrade: null,
upgradeInfo: null,
availabilityNux: null,
displayName: model,
description: "",
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: "medium",
inputModalities: [],
supportsPersonality: true,
additionalSpeedTiers: ["fast"],
serviceTiers: [{ id: fastTierId, name: "Fast", description: "" }],
defaultServiceTier: null,
isDefault: true,
};
}
function threadSettings(serviceTier: string | null): Omit<Extract<ChatAction, { type: "thread/settings-applied" }>, "type"> {
return {
cwd: "/vault",
model: "gpt-5.5",
reasoningEffort: "high",
collaborationMode: "default",
serviceTier,
approvalPolicy: "on-request",
approvalsReviewer: "user",
activePermissionProfile: null,
};
}

View file

@ -51,6 +51,30 @@ describe("chat view model", () => {
expect(model.modelChoices).toHaveLength(1);
});
it("marks fast active for the catalog Fast service tier id", () => {
const state = createChatState();
state.activeModel = "gpt-5.5";
state.activeServiceTier = "priority";
state.effectiveConfig = effectiveConfigFixture({ model: "gpt-5.5", model_reasoning_effort: "high" });
state.availableModels = [modelFixture("gpt-5.5", "priority")];
const model = toolbarViewModel({
state,
snapshot: runtimeSnapshotForChatState({ state }),
connected: true,
turnBusy: false,
vaultPath: "/vault",
configuredCommand: "codex",
archiveConfirmThreadId: null,
archiveExportEnabled: true,
modelChoices: [],
effortChoices: [],
renameState: () => null,
});
expect(model.fastActive).toBe(true);
});
it("builds slash-command status lines from chat state", () => {
const state = createChatState();
state.activeThreadId = "thread-1";
@ -147,7 +171,7 @@ function threadFixture(id: string, name: string | null): Thread {
};
}
function modelFixture(model: string): Model {
function modelFixture(model: string, fastTierId?: string): Model {
return {
id: model,
model,
@ -161,8 +185,8 @@ function modelFixture(model: string): Model {
defaultReasoningEffort: "high",
inputModalities: [],
supportsPersonality: false,
additionalSpeedTiers: [],
serviceTiers: [],
additionalSpeedTiers: fastTierId ? ["fast"] : [],
serviceTiers: fastTierId ? [{ id: fastTierId, name: "Fast", description: "" }] : [],
defaultServiceTier: null,
isDefault: true,
};

View file

@ -19,6 +19,8 @@ import {
currentModel,
currentReasoningEffort,
currentServiceTier,
fastModeActive,
fastServiceTierRequestValue,
fastModeLabel,
requestedOrConfiguredServiceTier,
requestedTurnRuntimeSettings,
@ -241,6 +243,40 @@ describe("runtime settings", () => {
expect(fastModeLabel(snapshot)).toBe("off");
});
it("treats the catalog Fast service tier id as fast mode while preserving the id", () => {
const model = modelFixture("gpt-5.5");
// This mirrors Codex app-server 0.134.0 model/list: Fast is named "Fast" but its id is "priority".
model.serviceTiers = [{ id: "priority", name: "Fast", description: "1.5x speed, increased usage" }];
const snapshot = runtimeSnapshot({
activeModel: "gpt-5.5",
activeServiceTier: "priority",
effectiveConfig: effectiveConfigFixture({ model: "gpt-5.5" }),
availableModels: [model],
});
expect(currentServiceTier(snapshot)).toBe("priority");
expect(serviceTierLabel(snapshot)).toBe("priority");
expect(fastModeActive(snapshot)).toBe(true);
expect(fastModeLabel(snapshot)).toBe("on");
expect(fastServiceTierRequestValue(snapshot)).toBe("priority");
});
it("treats the Codex 0.134.0 reported default tier after clearing Fast as fast mode off", () => {
const model = modelFixture("gpt-5.5");
model.serviceTiers = [{ id: "priority", name: "Fast", description: "1.5x speed, increased usage" }];
const snapshot = runtimeSnapshot({
activeModel: "gpt-5.5",
activeServiceTier: "default",
effectiveConfig: effectiveConfigFixture({ model: "gpt-5.5" }),
availableModels: [model],
});
expect(currentServiceTier(snapshot)).toBe("default");
expect(serviceTierLabel(snapshot)).toBe("default");
expect(fastModeActive(snapshot)).toBe(false);
expect(fastModeLabel(snapshot)).toBe("off");
});
it("uses requested service tier above active and configured service tiers", () => {
const snapshot = runtimeSnapshot({
requestedServiceTier: "off",
@ -494,6 +530,19 @@ describe("runtime settings", () => {
expect(requestedOrConfiguredServiceTier(snapshot)).toBeNull();
});
it("serializes requested fast mode using the catalog Fast service tier id", () => {
const model = modelFixture("gpt-5.5");
model.serviceTiers = [{ id: "priority", name: "Fast", description: "1.5x speed, increased usage" }];
const snapshot = runtimeSnapshot({
requestedServiceTier: "fast",
effectiveConfig: effectiveConfigFixture({ model: "gpt-5.5" }),
availableModels: [model],
});
expect(fastModeLabel(snapshot)).toBe("on");
expect(requestedOrConfiguredServiceTier(snapshot)).toBe("priority");
});
it("omits service tier when neither config nor override selects one", () => {
expect(requestedOrConfiguredServiceTier(runtimeSnapshot({ effectiveConfig: effectiveConfigFixture({}) }))).toBeUndefined();
});