diff --git a/src/app-server/service-tier.ts b/src/app-server/service-tier.ts index e1380741..5991fb1a 100644 --- a/src/app-server/service-tier.ts +++ b/src/app-server/service-tier.ts @@ -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"); +} diff --git a/src/features/chat/runtime-settings-controller.ts b/src/features/chat/runtime-settings-controller.ts index 47e2d00c..37aa9d3d 100644 --- a/src/features/chat/runtime-settings-controller.ts +++ b/src/features/chat/runtime-settings-controller.ts @@ -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 { - 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) { diff --git a/src/features/chat/view-model.ts b/src/features/chat/view-model.ts index a74f5e6c..57e6c183 100644 --- a/src/features/chat/view-model.ts +++ b/src/features/chat/view-model.ts @@ -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, diff --git a/src/runtime/state.ts b/src/runtime/state.ts index f2b074ea..9dad0594 100644 --- a/src/runtime/state.ts +++ b/src/runtime/state.ts @@ -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(): RuntimeOverride { return { kind: "default" }; } @@ -175,3 +192,10 @@ export function runtimeOverrideLabel(override: RuntimeOverride): 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 ?? []; +} diff --git a/tests/app-server/service-tier.test.ts b/tests/app-server/service-tier.test.ts index f1e723c4..e51ebac5 100644 --- a/tests/app-server/service-tier.test.ts +++ b/tests/app-server/service-tier.test.ts @@ -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); + }); }); diff --git a/tests/features/chat/runtime-settings-controller.test.ts b/tests/features/chat/runtime-settings-controller.test.ts index 83dbc21c..9442e67c 100644 --- a/tests/features/chat/runtime-settings-controller.test.ts +++ b/tests/features/chat/runtime-settings-controller.test.ts @@ -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, "type"> { + return { + cwd: "/vault", + model: "gpt-5.5", + reasoningEffort: "high", + collaborationMode: "default", + serviceTier, + approvalPolicy: "on-request", + approvalsReviewer: "user", + activePermissionProfile: null, + }; +} diff --git a/tests/features/chat/view-model.test.ts b/tests/features/chat/view-model.test.ts index 8c1eafd8..6b2d6bad 100644 --- a/tests/features/chat/view-model.test.ts +++ b/tests/features/chat/view-model.test.ts @@ -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, }; diff --git a/tests/runtime/runtime-settings.test.ts b/tests/runtime/runtime-settings.test.ts index 173d923e..e5a3be02 100644 --- a/tests/runtime/runtime-settings.test.ts +++ b/tests/runtime/runtime-settings.test.ts @@ -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(); });