fix: add explicit max_output_tokens for GPT-5 Responses API

- Added max_output_tokens parameter to GPT-5 request body using model.maxTokens
- This prevents GPT-5 from defaulting to very large token limits (e.g., 120k)
- Updated tests to expect max_output_tokens in GPT-5 request bodies
- Fixed test for handling unhandled stream events by properly mocking SDK fallback
This commit is contained in:
hannesrudolph 2025-08-08 18:57:31 -06:00
parent ad0e33e2d9
commit d3ad4f2379
31 changed files with 2004 additions and 269 deletions

View file

@ -176,6 +176,17 @@ export const clineMessageSchema = z.object({
contextCondense: contextCondenseSchema.optional(),
isProtected: z.boolean().optional(),
apiProtocol: z.union([z.literal("openai"), z.literal("anthropic")]).optional(),
metadata: z
.object({
gpt5: z
.object({
previous_response_id: z.string().optional(),
instructions: z.string().optional(),
reasoning_summary: z.string().optional(),
})
.optional(),
})
.optional(),
})
export type ClineMessage = z.infer<typeof clineMessageSchema>

View file

@ -3,6 +3,11 @@ import { z } from "zod"
import { reasoningEffortsSchema, verbosityLevelsSchema, modelInfoSchema } from "./model.js"
import { codebaseIndexProviderSchema } from "./codebase-index.js"
// Extended schema that includes "minimal" for GPT-5 models
export const extendedReasoningEffortsSchema = z.union([reasoningEffortsSchema, z.literal("minimal")])
export type ReasoningEffortWithMinimal = z.infer<typeof extendedReasoningEffortsSchema>
/**
* ProviderName
*/
@ -76,7 +81,7 @@ const baseProviderSettingsSchema = z.object({
// Model reasoning.
enableReasoningEffort: z.boolean().optional(),
reasoningEffort: reasoningEffortsSchema.optional(),
reasoningEffort: extendedReasoningEffortsSchema.optional(),
modelMaxTokens: z.number().optional(),
modelMaxThinkingTokens: z.number().optional(),

View file

@ -12,6 +12,7 @@ export const openAiNativeModels = {
supportsImages: true,
supportsPromptCache: true,
supportsReasoningEffort: true,
reasoningEffort: "medium",
inputPrice: 1.25,
outputPrice: 10.0,
cacheReadsPrice: 0.13,
@ -23,6 +24,7 @@ export const openAiNativeModels = {
supportsImages: true,
supportsPromptCache: true,
supportsReasoningEffort: true,
reasoningEffort: "medium",
inputPrice: 0.25,
outputPrice: 2.0,
cacheReadsPrice: 0.03,
@ -34,6 +36,7 @@ export const openAiNativeModels = {
supportsImages: true,
supportsPromptCache: true,
supportsReasoningEffort: true,
reasoningEffort: "medium",
inputPrice: 0.05,
outputPrice: 0.4,
cacheReadsPrice: 0.01,
@ -229,5 +232,6 @@ export const openAiModelInfoSaneDefaults: ModelInfo = {
export const azureOpenAiDefaultApiVersion = "2024-08-01-preview"
export const OPENAI_NATIVE_DEFAULT_TEMPERATURE = 0
export const GPT5_DEFAULT_TEMPERATURE = 1.0
export const OPENAI_AZURE_AI_INFERENCE_PATH = "/models/chat/completions"

View file

@ -44,6 +44,7 @@ export interface SingleCompletionHandler {
export interface ApiHandlerCreateMessageMetadata {
mode?: string
taskId: string
previousResponseId?: string
}
export interface ApiHandler {

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -305,7 +305,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
],
stream: true,
...(isGrokXAI ? {} : { stream_options: { include_usage: true } }),
reasoning_effort: modelInfo.reasoningEffort,
reasoning_effort: modelInfo.reasoningEffort as "low" | "medium" | "high" | undefined,
temperature: undefined,
}
@ -330,7 +330,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
},
...convertToOpenAiMessages(messages),
],
reasoning_effort: modelInfo.reasoningEffort,
reasoning_effort: modelInfo.reasoningEffort as "low" | "medium" | "high" | undefined,
temperature: undefined,
}

View file

@ -116,7 +116,7 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan
model,
max_tokens,
temperature,
...(reasoning_effort && { reasoning_effort }),
...(reasoning_effort && reasoning_effort !== "minimal" && { reasoning_effort }),
...(thinking && { thinking }),
stream: true,
stream_options: { include_usage: true },

View file

@ -2,6 +2,7 @@ import {
type ModelInfo,
type ProviderSettings,
type VerbosityLevel,
type ReasoningEffortWithMinimal,
ANTHROPIC_DEFAULT_MAX_TOKENS,
} from "@roo-code/types"
@ -38,7 +39,7 @@ type GetModelParamsOptions<T extends Format> = {
type BaseModelParams = {
maxTokens: number | undefined
temperature: number | undefined
reasoningEffort: "low" | "medium" | "high" | undefined
reasoningEffort: ReasoningEffortWithMinimal | undefined
reasoningBudget: number | undefined
verbosity: VerbosityLevel | undefined
}
@ -128,7 +129,8 @@ export function getModelParams({
temperature = 1.0
} else if (shouldUseReasoningEffort({ model, settings })) {
// "Traditional" reasoning models use the `reasoningEffort` parameter.
reasoningEffort = customReasoningEffort ?? model.reasoningEffort
const effort = customReasoningEffort ?? model.reasoningEffort
reasoningEffort = effort as ReasoningEffortWithMinimal
}
const params: BaseModelParams = { maxTokens, temperature, reasoningEffort, reasoningBudget, verbosity }

View file

@ -2,7 +2,7 @@ import { BetaThinkingConfigParam } from "@anthropic-ai/sdk/resources/beta"
import OpenAI from "openai"
import type { GenerateContentConfig } from "@google/genai"
import type { ModelInfo, ProviderSettings } from "@roo-code/types"
import type { ModelInfo, ProviderSettings, ReasoningEffortWithMinimal } from "@roo-code/types"
import { shouldUseReasoningBudget, shouldUseReasoningEffort } from "../../shared/api"
@ -23,7 +23,7 @@ export type GeminiReasoningParams = GenerateContentConfig["thinkingConfig"]
export type GetModelReasoningOptions = {
model: ModelInfo
reasoningBudget: number | undefined
reasoningEffort: ReasoningEffort | undefined
reasoningEffort: ReasoningEffortWithMinimal | undefined
settings: ProviderSettings
}
@ -36,7 +36,9 @@ export const getOpenRouterReasoning = ({
shouldUseReasoningBudget({ model, settings })
? { max_tokens: reasoningBudget }
: shouldUseReasoningEffort({ model, settings })
? { effort: reasoningEffort }
? reasoningEffort !== "minimal"
? { effort: reasoningEffort }
: undefined
: undefined
export const getAnthropicReasoning = ({
@ -51,7 +53,9 @@ export const getOpenAiReasoning = ({
reasoningEffort,
settings,
}: GetModelReasoningOptions): OpenAiReasoningParams | undefined =>
shouldUseReasoningEffort({ model, settings }) ? { reasoning_effort: reasoningEffort } : undefined
shouldUseReasoningEffort({ model, settings }) && reasoningEffort && reasoningEffort !== "minimal"
? { reasoning_effort: reasoningEffort }
: undefined
export const getGeminiReasoning = ({
model,

View file

@ -252,6 +252,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
didCompleteReadingStream = false
assistantMessageParser?: AssistantMessageParser
isAssistantMessageParserEnabled = false
private lastUsedInstructions?: string
private skipPrevResponseIdOnce: boolean = false
constructor({
provider,
@ -824,6 +826,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
progressStatus?: ToolProgressStatus,
options: {
isNonInteractive?: boolean
metadata?: Record<string, unknown>
} = {},
contextCondense?: ContextCondense,
): Promise<undefined> {
@ -861,6 +864,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
images,
partial,
contextCondense,
metadata: options.metadata,
})
}
} else {
@ -876,6 +880,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
lastMessage.images = images
lastMessage.partial = false
lastMessage.progressStatus = progressStatus
if (options.metadata) {
;(lastMessage as any).metadata = options.metadata
}
// Instead of streaming partialMessage events, we do a save
// and post like normal to persist to disk.
@ -891,7 +898,15 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.lastMessageTs = sayTs
}
await this.addToClineMessages({ ts: sayTs, type: "say", say: type, text, images, contextCondense })
await this.addToClineMessages({
ts: sayTs,
type: "say",
say: type,
text,
images,
contextCondense,
metadata: options.metadata,
})
}
}
} else {
@ -1736,6 +1751,30 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
presentAssistantMessage(this)
}
// Persist GPT5 per-turn metadata (previous_response_id, instructions)
try {
const modelId = this.api.getModel().id
if (modelId && modelId.startsWith("gpt-5")) {
const lastResponseId: string | undefined = (this.api as any)?.getLastResponseId?.()
const idx = findLastIndex(
this.clineMessages,
(m) => m.type === "say" && (m as any).say === "text" && m.partial !== true,
)
if (idx !== -1) {
const msg = this.clineMessages[idx] as any
msg.metadata = msg.metadata ?? {}
msg.metadata.gpt5 = {
...(msg.metadata.gpt5 ?? {}),
previous_response_id: lastResponseId,
instructions: this.lastUsedInstructions,
reasoning_summary: (reasoningMessage ?? "").trim() || undefined,
}
}
}
} catch {
// Non-fatal
}
updateApiReqMsg()
await this.saveClineMessages()
await this.providerRef.deref()?.postStateToWebview()
@ -1954,6 +1993,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
Task.lastGlobalApiRequestTime = Date.now()
const systemPrompt = await this.getSystemPrompt()
this.lastUsedInstructions = systemPrompt
const { contextTokens } = this.getTokenUsage()
if (contextTokens) {
@ -1992,6 +2032,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
if (truncateResult.error) {
await this.say("condense_context_error", truncateResult.error)
} else if (truncateResult.summary) {
// A condense operation occurred; for the next GPT5 API call we should NOT
// send previous_response_id so the request reflects the fresh condensed context.
this.skipPrevResponseIdOnce = true
const { summary, cost, prevContextTokens, newContextTokens = 0 } = truncateResult
const contextCondense: ContextCondense = { summary, cost, newContextTokens, prevContextTokens }
await this.say(
@ -2008,7 +2052,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
const messagesSinceLastSummary = getMessagesSinceLastSummary(this.apiConversationHistory)
const cleanConversationHistory = maybeRemoveImageBlocks(messagesSinceLastSummary, this.api).map(
let cleanConversationHistory = maybeRemoveImageBlocks(messagesSinceLastSummary, this.api).map(
({ role, content }) => ({ role, content }),
)
@ -2024,9 +2068,37 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
throw new Error("Auto-approval limit reached and user did not approve continuation")
}
// Determine GPT5 previous_response_id from last persisted assistant turn (if available),
// unless a condense just occurred (skip once after condense).
let previousResponseId: string | undefined = undefined
try {
const modelId = this.api.getModel().id
if (modelId && modelId.startsWith("gpt-5") && !this.skipPrevResponseIdOnce) {
const idx = findLastIndex(
this.clineMessages,
(m) =>
m.type === "say" &&
(m as any).say === "text" &&
(m as any).metadata?.gpt5?.previous_response_id,
)
if (idx !== -1) {
previousResponseId = ((this.clineMessages[idx] as any).metadata.gpt5.previous_response_id ||
undefined) as string | undefined
}
}
} catch {
// non-fatal
}
const metadata: ApiHandlerCreateMessageMetadata = {
mode: mode,
taskId: this.taskId,
...(previousResponseId ? { previousResponseId } : {}),
}
// Reset skip flag after applying (it only affects the immediate next call)
if (this.skipPrevResponseIdOnce) {
this.skipPrevResponseIdOnce = false
}
const stream = this.api.createMessage(systemPrompt, cleanConversationHistory, metadata)

View file

@ -6,8 +6,15 @@ import {
} from "@roo-code/types"
// ApiHandlerOptions
export type ApiHandlerOptions = Omit<ProviderSettings, "apiProvider">
// Extend ProviderSettings (minus apiProvider) with handler-specific toggles.
export type ApiHandlerOptions = Omit<ProviderSettings, "apiProvider"> & {
/**
* When true and using GPT5 Responses API, include reasoning.summary: "auto"
* so the API returns reasoning summaries (we already parse and surface them).
* Defaults to false to preserve existing request body expectations.
*/
enableGpt5ReasoningSummary?: boolean
}
// RouterName

View file

@ -576,6 +576,12 @@ const ApiOptions = ({
if (value !== "custom-arn" && selectedProvider === "bedrock") {
setApiConfigurationField("awsCustomArn", "")
}
// Clear reasoning effort when switching models to allow the new model's default to take effect
// This is especially important for GPT-5 models which default to "medium"
if (selectedProvider === "openai-native") {
setApiConfigurationField("reasoningEffort", undefined)
}
}}>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("settings:common.select")} />
@ -617,11 +623,13 @@ const ApiOptions = ({
modelInfo={selectedModelInfo}
/>
<Verbosity
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
modelInfo={selectedModelInfo}
/>
{selectedModelId?.startsWith("gpt-5") && (
<Verbosity
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
modelInfo={selectedModelInfo}
/>
)}
{!fromWelcomeView && (
<Collapsible open={isAdvancedSettingsOpen} onOpenChange={setIsAdvancedSettingsOpen}>

View file

@ -1,7 +1,12 @@
import { useEffect } from "react"
import { Checkbox } from "vscrui"
import { type ProviderSettings, type ModelInfo, type ReasoningEffort, reasoningEfforts } from "@roo-code/types"
import {
type ProviderSettings,
type ModelInfo,
type ReasoningEffortWithMinimal,
reasoningEfforts,
} from "@roo-code/types"
import {
DEFAULT_HYBRID_REASONING_MODEL_MAX_TOKENS,
@ -27,10 +32,35 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod
const isGemini25Pro = selectedModelId && selectedModelId.includes("gemini-2.5-pro")
const minThinkingTokens = isGemini25Pro ? GEMINI_25_PRO_MIN_THINKING_TOKENS : 1024
// Check if this is a GPT-5 model to show "minimal" option
// Only show minimal for OpenAI Native provider GPT-5 models
const isOpenAiNativeProvider = apiConfiguration.apiProvider === "openai-native"
const isGpt5Model = isOpenAiNativeProvider && selectedModelId && selectedModelId.startsWith("gpt-5")
// Add "minimal" option for GPT-5 models
// Spread to convert readonly tuple into a mutable array, then expose as readonly for safety
const baseEfforts = [...reasoningEfforts] as ReasoningEffortWithMinimal[]
const availableReasoningEfforts: ReadonlyArray<ReasoningEffortWithMinimal> = isGpt5Model
? (["minimal", ...baseEfforts] as ReasoningEffortWithMinimal[])
: baseEfforts
// Default reasoning effort - use model's default if available
// GPT-5 models have "medium" as their default in the model configuration
const modelDefaultReasoningEffort = modelInfo?.reasoningEffort as ReasoningEffortWithMinimal | undefined
const defaultReasoningEffort: ReasoningEffortWithMinimal = modelDefaultReasoningEffort || "medium"
const currentReasoningEffort: ReasoningEffortWithMinimal =
(apiConfiguration.reasoningEffort as ReasoningEffortWithMinimal | undefined) || defaultReasoningEffort
const isReasoningBudgetSupported = !!modelInfo && modelInfo.supportsReasoningBudget
const isReasoningBudgetRequired = !!modelInfo && modelInfo.requiredReasoningBudget
const isReasoningEffortSupported = !!modelInfo && modelInfo.supportsReasoningEffort
// Set default reasoning effort when model supports it and no value is set
useEffect(() => {
if (isReasoningEffortSupported && !apiConfiguration.reasoningEffort && defaultReasoningEffort) {
setApiConfigurationField("reasoningEffort", defaultReasoningEffort)
}
}, [isReasoningEffortSupported, apiConfiguration.reasoningEffort, defaultReasoningEffort, setApiConfigurationField])
const enableReasoningEffort = apiConfiguration.enableReasoningEffort
const customMaxOutputTokens = apiConfiguration.modelMaxTokens || DEFAULT_HYBRID_REASONING_MODEL_MAX_TOKENS
const customMaxThinkingTokens =
@ -109,13 +139,21 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod
<label className="block font-medium mb-1">{t("settings:providers.reasoningEffort.label")}</label>
</div>
<Select
value={apiConfiguration.reasoningEffort}
onValueChange={(value) => setApiConfigurationField("reasoningEffort", value as ReasoningEffort)}>
value={currentReasoningEffort}
onValueChange={(value: ReasoningEffortWithMinimal) => {
setApiConfigurationField("reasoningEffort", value)
}}>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("settings:common.select")} />
<SelectValue
placeholder={
currentReasoningEffort
? t(`settings:providers.reasoningEffort.${currentReasoningEffort}`)
: t("settings:common.select")
}
/>
</SelectTrigger>
<SelectContent>
{reasoningEfforts.map((value) => (
{availableReasoningEfforts.map((value) => (
<SelectItem key={value} value={value}>
{t(`settings:providers.reasoningEffort.${value}`)}
</SelectItem>

View file

@ -429,6 +429,7 @@
},
"reasoningEffort": {
"label": "Esforç de raonament del model",
"minimal": "Mínim (el més ràpid)",
"high": "Alt",
"medium": "Mitjà",
"low": "Baix"

View file

@ -429,6 +429,7 @@
},
"reasoningEffort": {
"label": "Modell-Denkaufwand",
"minimal": "Minimal (schnellste)",
"high": "Hoch",
"medium": "Mittel",
"low": "Niedrig"

View file

@ -428,9 +428,10 @@
},
"reasoningEffort": {
"label": "Model Reasoning Effort",
"high": "High",
"minimal": "Minimal (Fastest)",
"low": "Low",
"medium": "Medium",
"low": "Low"
"high": "High"
},
"verbosity": {
"label": "Output Verbosity",

View file

@ -429,6 +429,7 @@
},
"reasoningEffort": {
"label": "Esfuerzo de razonamiento del modelo",
"minimal": "Mínimo (el más rápido)",
"high": "Alto",
"medium": "Medio",
"low": "Bajo"

View file

@ -429,6 +429,7 @@
},
"reasoningEffort": {
"label": "Effort de raisonnement du modèle",
"minimal": "Minimal (le plus rapide)",
"high": "Élevé",
"medium": "Moyen",
"low": "Faible"

View file

@ -429,6 +429,7 @@
},
"reasoningEffort": {
"label": "मॉडल तर्क प्रयास",
"minimal": "न्यूनतम (सबसे तेज़)",
"high": "उच्च",
"medium": "मध्यम",
"low": "निम्न"

View file

@ -429,6 +429,7 @@
"label": "Batas Kesalahan & Pengulangan",
"description": "Jumlah kesalahan berturut-turut atau tindakan berulang sebelum menampilkan dialog 'Roo mengalami masalah'",
"unlimitedDescription": "Percobaan ulang tak terbatas diaktifkan (lanjut otomatis). Dialog tidak akan pernah muncul.",
"minimal": "Minimal (tercepat)",
"warning": "⚠️ Mengatur ke 0 memungkinkan percobaan ulang tak terbatas yang dapat menghabiskan penggunaan API yang signifikan"
},
"reasoningEffort": {

View file

@ -429,6 +429,7 @@
},
"reasoningEffort": {
"label": "Sforzo di ragionamento del modello",
"minimal": "Minimo (più veloce)",
"high": "Alto",
"medium": "Medio",
"low": "Basso"

View file

@ -429,6 +429,7 @@
},
"reasoningEffort": {
"label": "モデル推論の労力",
"minimal": "最小 (最速)",
"high": "高",
"medium": "中",
"low": "低"

View file

@ -429,6 +429,7 @@
},
"reasoningEffort": {
"label": "모델 추론 노력",
"minimal": "최소 (가장 빠름)",
"high": "높음",
"medium": "중간",
"low": "낮음"

View file

@ -429,6 +429,7 @@
},
"reasoningEffort": {
"label": "Wysiłek rozumowania modelu",
"minimal": "Minimalny (najszybszy)",
"high": "Wysoki",
"medium": "Średni",
"low": "Niski"

View file

@ -429,6 +429,7 @@
},
"reasoningEffort": {
"label": "Esforço de raciocínio do modelo",
"minimal": "Mínimo (mais rápido)",
"high": "Alto",
"medium": "Médio",
"low": "Baixo"

View file

@ -429,6 +429,7 @@
},
"reasoningEffort": {
"label": "Усилия по рассуждению модели",
"minimal": "Минимальный (самый быстрый)",
"high": "Высокие",
"medium": "Средние",
"low": "Низкие"

View file

@ -429,6 +429,7 @@
},
"reasoningEffort": {
"label": "Model Akıl Yürütme Çabası",
"minimal": "Minimal (en hızlı)",
"high": "Yüksek",
"medium": "Orta",
"low": "Düşük"

View file

@ -429,6 +429,7 @@
},
"reasoningEffort": {
"label": "Nỗ lực suy luận của mô hình",
"minimal": "Tối thiểu (nhanh nhất)",
"high": "Cao",
"medium": "Trung bình",
"low": "Thấp"

View file

@ -429,6 +429,7 @@
},
"reasoningEffort": {
"label": "模型推理强度",
"minimal": "最小 (最快)",
"high": "高",
"medium": "中",
"low": "低"

View file

@ -429,6 +429,7 @@
},
"reasoningEffort": {
"label": "模型推理強度",
"minimal": "最小 (最快)",
"high": "高",
"medium": "中",
"low": "低"