fix(server): Codex returns the tier it resolved and drops a speed the new model lacks

A live service_tier: fast is stored as the catalog's Fast tier id instead of
a value the control cannot show, and a model switch to a model without the
stored speed reports Normal so choosing a supporting model again does not
bring the paid tier back.
This commit is contained in:
Mohamed Boudra
2026-11-01 15:34:06 +01:00
parent 301b49e381
commit c0c181425f
2 changed files with 62 additions and 0 deletions
@@ -563,6 +563,39 @@ describe("Codex app-server provider controls", () => {
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "priority" });
});
test("a live service_tier: fast is returned as the model's Fast tier", async () => {
const { session, appServer } = await createConnectedSession();
await expect(session.applyOptions?.({ service_tier: "fast" })).resolves.toEqual({
service_tier: "priority",
});
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "priority" });
});
test("switching to a model without the speed tier says Normal and keeps it so on the way back", async () => {
const models = [
...TEST_SPEED_MODELS,
{ id: "plain", isDefault: false, defaultReasoningEffort: "medium", serviceTiers: [] },
];
const { session, appServer } = await createConnectedSession(
{ model: "gpt-5.4", providerOptions: { service_tier: "priority" } },
{ models },
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.setModel?.("plain");
await session.setModel?.("gpt-5.4");
expect(events.filter((event) => event.type === "options_changed")).toEqual([
{ type: "options_changed", provider: "codex", patch: { service_tier: "default" } },
]);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
});
describe("a pre-v0.10 fast_mode: true option", () => {
function createLegacyFastHarness(configDefaults: Record<string, unknown> = {}) {
const appServer = createFakeCodexAppServer({
@@ -4665,6 +4665,26 @@ export class CodexAppServerAgentSession implements AgentSession {
this.config.model = modelId ?? undefined;
this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
this.dropUnavailableServiceTier();
}
/**
* A speed the new model does not offer turns into Normal and is reported, so choosing a model
* that does offer it again does not bring the paid tier back. It is reported as Normal rather
* than removed so a configured default cannot fill the old speed back in.
*/
private dropUnavailableServiceTier(): void {
const requested = this.requestedServiceTier;
if (requested === null || this.speedModels.length === 0) return;
if (this.isServiceTierAvailable(requested)) return;
this.requestedServiceTier = "default";
const { fast_mode: legacyFast, ...running } = this.providerOptions;
this.providerOptions = { ...running, service_tier: "default" };
this.emitEvent({
type: "options_changed",
provider: CODEX_PROVIDER,
patch: { service_tier: "default", ...(legacyFast === undefined ? {} : { fast_mode: null }) },
});
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void | AgentProviderNotice> {
@@ -4707,6 +4727,15 @@ export class CodexAppServerAgentSession implements AgentSession {
if ("fast_mode" in options && !("service_tier" in options) && options.fast_mode !== null) {
return tier === null ? {} : { service_tier: tier, fast_mode: null };
}
// The catalog may name the tier differently from the request (`fast` is the Fast tier's id).
if (
"service_tier" in options &&
tier !== null &&
requestedTier !== null &&
tier !== requestedTier
) {
return { service_tier: tier };
}
return {};
}