From 99c307d3735913d22de9939d6037f5339c577606 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Kaln=C3=BD?= Date: Mon, 13 Jul 2026 21:24:09 +0200 Subject: [PATCH] Add gpt-5 verbosity model option support across providers --- .../src/provider/Layers/CodexAdapter.test.ts | 4 + .../src/provider/Layers/CodexAdapter.ts | 35 +++--- .../src/provider/Layers/CodexProvider.test.ts | 106 ++++++++++++++++++ .../src/provider/Layers/CodexProvider.ts | 21 ++++ .../provider/Layers/OpenCodeAdapter.test.ts | 91 ++++++++------- .../src/provider/Layers/OpenCodeAdapter.ts | 2 + .../provider/Layers/OpenCodeProvider.test.ts | 88 +++++++++++++++ .../src/provider/Layers/OpenCodeProvider.ts | 24 ++++ 8 files changed, 314 insertions(+), 57 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 515a7c6fcbb..42e691c69db 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -345,6 +345,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ { id: "reasoningEffort", value: "high" }, { id: "serviceTier", value: "priority" }, + { id: "verbosity", value: "high" }, ]), attachments: [], }), @@ -355,6 +356,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { model: "gpt-5.3-codex", effort: "high", serviceTier: "priority", + verbosity: "high", }); }), ); @@ -399,6 +401,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { [ { id: "reasoningEffort", value: "high" }, { id: "serviceTier", value: "flex" }, + { id: "verbosity", value: "medium" }, ], ), attachments: [], @@ -410,6 +413,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { model: "gpt-5.3-codex", effort: "high", serviceTier: "flex", + verbosity: "medium", }); }).pipe(Effect.provide(customLayer)); }); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 270126e934b..93888ebdc22 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -55,6 +55,7 @@ import { ServerConfig } from "../../config.ts"; import { CodexResumeCursorSchema, CodexSessionRuntimeThreadIdMissingError, + type CodexSessionRuntimeSendTurnInput, makeCodexSessionRuntime, type CodexSessionRuntimeError, type CodexSessionRuntimeOptions, @@ -1535,21 +1536,27 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( input.modelSelection?.instanceId === boundInstanceId ? getCodexServiceTierOptionValue(input.modelSelection) : undefined; + const verbosity = + input.modelSelection?.instanceId === boundInstanceId + ? getModelSelectionStringOptionValue(input.modelSelection, "verbosity") + : undefined; + const turnInput = { + ...(input.input !== undefined ? { input: input.input } : {}), + ...(input.modelSelection?.instanceId === boundInstanceId + ? { model: input.modelSelection.model } + : {}), + ...(reasoningEffort + ? { + effort: reasoningEffort as EffectCodexSchema.V2TurnStartParams__ReasoningEffort, + } + : {}), + ...(serviceTier ? { serviceTier } : {}), + ...(verbosity ? { verbosity } : {}), + ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), + ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}), + } as CodexSessionRuntimeSendTurnInput & { readonly verbosity?: string }; return yield* session.runtime - .sendTurn({ - ...(input.input !== undefined ? { input: input.input } : {}), - ...(input.modelSelection?.instanceId === boundInstanceId - ? { model: input.modelSelection.model } - : {}), - ...(reasoningEffort - ? { - effort: reasoningEffort as EffectCodexSchema.V2TurnStartParams__ReasoningEffort, - } - : {}), - ...(serviceTier ? { serviceTier } : {}), - ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), - ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}), - }) + .sendTurn(turnInput) .pipe(Effect.mapError((cause) => mapCodexRuntimeError(input.threadId, "turn/start", cause))); }); diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 0e21b76306b..0359bdbe1c8 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -64,6 +64,86 @@ it("maps current Codex model capability fields", () => { ]); }); +it("adds verbosity for GPT-5 Codex models", () => { + const capabilities = mapCodexModelCapabilities({ + additionalSpeedTiers: [], + defaultReasoningEffort: "medium", + description: "Test model", + displayName: "GPT-5.3-Codex-Spark", + hidden: false, + id: "gpt-5.3-codex-spark", + isDefault: true, + model: "gpt-5.3-codex-spark", + defaultServiceTier: "fast", + serviceTiers: [ + { + id: "priority", + name: "Priority", + description: "Lower latency responses.", + }, + { + id: "fast", + name: "Fast", + description: "Lower-cost asynchronous routing.", + }, + ], + supportedReasoningEfforts: [ + { + description: "Balanced", + reasoningEffort: "medium", + }, + { + description: "Best", + reasoningEffort: "high", + }, + ], + }); + + assert.deepStrictEqual(capabilities.optionDescriptors, [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [ + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + ], + currentValue: "medium", + }, + { + id: "serviceTier", + label: "Service Tier", + type: "select", + options: [ + { id: "default", label: "Standard" }, + { + id: "priority", + label: "Priority", + description: "Lower latency responses.", + }, + { + id: "fast", + label: "Fast", + description: "Lower-cost asynchronous routing.", + isDefault: true, + }, + ], + currentValue: "fast", + }, + { + id: "verbosity", + label: "Verbosity", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + ], + currentValue: "medium", + }, + ]); +}); + it("uses standard routing when the catalog has no default service tier", () => { const capabilities = mapCodexModelCapabilities({ additionalSpeedTiers: ["fast"], @@ -102,3 +182,29 @@ it("uses standard routing when the catalog has no default service tier", () => { }, ]); }); + +it("does not expose verbosity for non-GPT-5 models", () => { + const capabilities = mapCodexModelCapabilities({ + additionalSpeedTiers: ["fast"], + defaultReasoningEffort: "medium", + description: "Test model", + displayName: "GPT Test", + hidden: false, + id: "gpt-test", + isDefault: true, + model: "gpt-test", + serviceTiers: [ + { + id: "priority", + name: "Fast", + description: "1.5x speed, increased usage", + }, + ], + supportedReasoningEfforts: [], + }); + + assert.deepStrictEqual( + capabilities.optionDescriptors?.some((descriptor) => descriptor.id === "verbosity"), + false, + ); +}); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index a2182cfb73c..68cdcc22338 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -61,6 +61,9 @@ const REASONING_EFFORT_LABELS: Readonly> = { }; const DEFAULT_SERVICE_TIER_ID = "default"; +function isGpt5Model(modelID: string): boolean { + return modelID.toLowerCase().startsWith("gpt-5"); +} function reasoningEffortLabel(reasoningEffort: string): string { return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; @@ -109,6 +112,7 @@ function codexAccountEmail(account: CodexSchema.V2GetAccountResponse["account"]) export function mapCodexModelCapabilities( model: CodexSchema.V2ModelListResponse__Model, ): ModelCapabilities { + const hasGpt5Verbosity = isGpt5Model(model.id); const reasoningOptions = model.supportedReasoningEfforts.map(({ reasoningEffort }) => reasoningEffort === model.defaultReasoningEffort ? { @@ -136,6 +140,14 @@ export function mapCodexModelCapabilities( ? model.defaultServiceTier : null; const defaultServiceTier = catalogDefaultServiceTier ?? DEFAULT_SERVICE_TIER_ID; + const verbosityOptions = [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ]; + const defaultVerbosity = hasGpt5Verbosity + ? (verbosityOptions.find((option) => option.id === "medium")?.id ?? verbosityOptions[0]?.id) + : undefined; const optionDescriptors: ProviderOptionDescriptor[] = []; if (reasoningOptions.length > 0) { @@ -168,6 +180,15 @@ export function mapCodexModelCapabilities( currentValue: defaultServiceTier, }); } + if (hasGpt5Verbosity) { + optionDescriptors.push({ + id: "verbosity", + label: "Verbosity", + type: "select", + options: verbosityOptions, + ...(defaultVerbosity ? { currentValue: defaultVerbosity } : {}), + }); + } return createModelCapabilities({ optionDescriptors, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index d0475e25284..849f6c50a0f 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -474,52 +474,57 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); - it.effect("passes agent and variant options for the adapter's bound custom instance id", () => { - const instanceId = ProviderInstanceId.make("opencode_zen"); - const adapterLayer = Layer.effect( - OpenCodeAdapter, - makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), - ).pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(providerSessionDirectoryTestLayer), - Layer.provideMerge(NodeServices.layer), - ); + it.effect( + "passes agent, variant, and verbosity options for the adapter's bound custom instance id", + () => { + const instanceId = ProviderInstanceId.make("opencode_zen"); + const adapterLayer = Layer.effect( + OpenCodeAdapter, + makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), + ).pipe( + Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); - return Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId: asThreadId("thread-custom-instance"), - runtimeMode: "full-access", - }); + return Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-custom-instance"), + runtimeMode: "full-access", + }); - yield* adapter.sendTurn({ - threadId: asThreadId("thread-custom-instance"), - input: "Fix it", - modelSelection: createModelSelection( - ProviderInstanceId.make("opencode_zen"), - "anthropic/claude-sonnet-4-5", - [ - { id: "agent", value: "github-copilot" }, - { id: "variant", value: "high" }, - ], - ), - }); + yield* adapter.sendTurn({ + threadId: asThreadId("thread-custom-instance"), + input: "Fix it", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode_zen"), + "anthropic/claude-sonnet-4-5", + [ + { id: "agent", value: "github-copilot" }, + { id: "variant", value: "high" }, + { id: "verbosity", value: "high" }, + ], + ), + }); - NodeAssert.deepEqual(runtimeMock.state.promptCalls.at(-1), { - sessionID: "http://127.0.0.1:9999/session", - model: { - providerID: "anthropic", - modelID: "claude-sonnet-4-5", - }, - agent: "github-copilot", - variant: "high", - parts: [{ type: "text", text: "Fix it" }], - }); - }).pipe(Effect.provide(adapterLayer)); - }); + NodeAssert.deepEqual(runtimeMock.state.promptCalls.at(-1), { + sessionID: "http://127.0.0.1:9999/session", + model: { + providerID: "anthropic", + modelID: "claude-sonnet-4-5", + }, + agent: "github-copilot", + variant: "high", + verbosity: "high", + parts: [{ type: "text", text: "Fix it" }], + }); + }).pipe(Effect.provide(adapterLayer)); + }, + ); it.effect("uses the bound custom instance id for fallback sendTurn model selection", () => { const instanceId = ProviderInstanceId.make("opencode_zen"); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 1eb6e47bc19..3a953a679c5 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -1213,6 +1213,7 @@ export function makeOpenCodeAdapter( const agent = getModelSelectionStringOptionValue(modelSelection, "agent"); const variant = getModelSelectionStringOptionValue(modelSelection, "variant"); + const verbosity = getModelSelectionStringOptionValue(modelSelection, "verbosity"); context.activeTurnId = turnId; context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined); @@ -1244,6 +1245,7 @@ export function makeOpenCodeAdapter( model: parsedModel, ...(context.activeAgent ? { agent: context.activeAgent } : {}), ...(context.activeVariant ? { variant: context.activeVariant } : {}), + ...(verbosity ? { verbosity } : {}), parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], }), ).pipe( diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index b0e785512dc..aae667d9153 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -186,6 +186,15 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { variantDescriptor.options.find((option) => option.isDefault === true)?.id, "medium", ); + const verbosityDescriptor = model.capabilities?.optionDescriptors?.find( + (descriptor) => descriptor.id === "verbosity" && descriptor.type === "select", + ); + NodeAssert.ok(verbosityDescriptor && verbosityDescriptor.type === "select"); + NodeAssert.deepEqual( + verbosityDescriptor.options.map((option) => option.id), + ["low", "medium", "high"], + ); + NodeAssert.equal(verbosityDescriptor.currentValue, "medium"); const agentDescriptor = model.capabilities?.optionDescriptors?.find( (descriptor) => descriptor.id === "agent" && descriptor.type === "select", ); @@ -197,6 +206,85 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }), ); + it.effect("does not expose verbosity for non-GPT-5 models", () => + Effect.gen(function* () { + runtimeMock.state.inventory = { + providerList: { + connected: ["openai"], + all: [ + { + id: "openai", + name: "OpenAI", + models: { + "gpt-4.1": { + id: "gpt-4.1", + name: "GPT-4.1", + variants: { + low: {}, + high: {}, + }, + }, + }, + }, + ], + default: {}, + }, + agents: [], + }; + + const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-4.1"); + + NodeAssert.ok(model); + const verbosityDescriptor = model.capabilities?.optionDescriptors?.find( + (descriptor) => descriptor.id === "verbosity", + ); + NodeAssert.equal(verbosityDescriptor, undefined); + }), + ); + + it.effect("exposes verbosity for GPT-5 models across providers", () => + Effect.gen(function* () { + runtimeMock.state.inventory = { + providerList: { + connected: ["partner-ai"], + all: [ + { + id: "partner-ai", + name: "Partner AI", + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + variants: { + low: {}, + medium: {}, + }, + }, + }, + }, + ], + default: {}, + }, + agents: [], + }; + + const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + const model = snapshot.models.find((entry) => entry.slug === "partner-ai/gpt-5.4"); + + NodeAssert.ok(model); + const verbosityDescriptor = model.capabilities?.optionDescriptors?.find( + (descriptor) => descriptor.id === "verbosity" && descriptor.type === "select", + ); + NodeAssert.ok(verbosityDescriptor && verbosityDescriptor.type === "select"); + NodeAssert.deepEqual( + verbosityDescriptor.options.map((option) => option.id), + ["low", "medium", "high"], + ); + NodeAssert.equal(verbosityDescriptor.currentValue, "medium"); + }), + ); + it.effect("closes the local OpenCode server scope after provider refresh", () => Effect.gen(function* () { yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index a8285e960fc..5f878701675 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -170,11 +170,16 @@ const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabi optionDescriptors: [], }); +function isGpt5Model(modelID: string): boolean { + return modelID.startsWith("gpt-5"); +} + function openCodeCapabilitiesForModel(input: { readonly providerID: string; readonly model: ProviderListResponse["all"][number]["models"][string]; readonly agents: ReadonlyArray; }): ModelCapabilities { + const hasGpt5Verbosity = isGpt5Model(input.model.id); const variantValues = Object.keys(input.model.variants ?? {}); const defaultVariant = inferDefaultVariant(input.providerID, variantValues); const variantOptions = variantValues.map((value) => @@ -191,6 +196,14 @@ function openCodeCapabilitiesForModel(input: { ? { id: agent.name, label: titleCaseSlug(agent.name), isDefault: true as const } : { id: agent.name, label: titleCaseSlug(agent.name) }, ); + const verbosityOptions = [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ]; + const defaultVerbosity = hasGpt5Verbosity + ? (verbosityOptions.find((option) => option.id === "medium")?.id ?? verbosityOptions[0]?.id) + : undefined; return createModelCapabilities({ optionDescriptors: [ ...(variantOptions.length > 0 @@ -215,6 +228,17 @@ function openCodeCapabilitiesForModel(input: { }, ] : []), + ...(hasGpt5Verbosity + ? [ + { + id: "verbosity", + label: "Verbosity", + type: "select" as const, + options: verbosityOptions, + ...(defaultVerbosity ? { currentValue: defaultVerbosity } : {}), + }, + ] + : []), ], }); }