mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Fix GPT-5 Responses API issues with condensing and image support (#7067)
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> Co-authored-by: Roo Code <roomote@roocode.com> Co-authored-by: Hannes Rudolph <hrudolph@gmail.com>
This commit is contained in:
parent
d4a16f469c
commit
2204457cc5
11 changed files with 856 additions and 1000 deletions
|
|
@ -55,6 +55,8 @@ export const modelInfoSchema = z.object({
|
|||
// Capability flag to indicate whether the model supports an output verbosity parameter
|
||||
supportsVerbosity: z.boolean().optional(),
|
||||
supportsReasoningBudget: z.boolean().optional(),
|
||||
// Capability flag to indicate whether the model supports temperature parameter
|
||||
supportsTemperature: z.boolean().optional(),
|
||||
requiredReasoningBudget: z.boolean().optional(),
|
||||
supportsReasoningEffort: z.boolean().optional(),
|
||||
supportedParameters: z.array(modelParametersSchema).optional(),
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export const openAiNativeModels = {
|
|||
description: "GPT-5: The best model for coding and agentic tasks across domains",
|
||||
// supportsVerbosity is a new capability; ensure ModelInfo includes it
|
||||
supportsVerbosity: true,
|
||||
supportsTemperature: false,
|
||||
},
|
||||
"gpt-5-mini-2025-08-07": {
|
||||
maxTokens: 128000,
|
||||
|
|
@ -44,6 +45,7 @@ export const openAiNativeModels = {
|
|||
cacheReadsPrice: 0.03,
|
||||
description: "GPT-5 Mini: A faster, more cost-efficient version of GPT-5 for well-defined tasks",
|
||||
supportsVerbosity: true,
|
||||
supportsTemperature: false,
|
||||
},
|
||||
"gpt-5-nano-2025-08-07": {
|
||||
maxTokens: 128000,
|
||||
|
|
@ -57,6 +59,7 @@ export const openAiNativeModels = {
|
|||
cacheReadsPrice: 0.01,
|
||||
description: "GPT-5 Nano: Fastest, most cost-efficient version of GPT-5",
|
||||
supportsVerbosity: true,
|
||||
supportsTemperature: false,
|
||||
},
|
||||
"gpt-4.1": {
|
||||
maxTokens: 32_768,
|
||||
|
|
@ -66,6 +69,7 @@ export const openAiNativeModels = {
|
|||
inputPrice: 2,
|
||||
outputPrice: 8,
|
||||
cacheReadsPrice: 0.5,
|
||||
supportsTemperature: true,
|
||||
},
|
||||
"gpt-4.1-mini": {
|
||||
maxTokens: 32_768,
|
||||
|
|
@ -75,6 +79,7 @@ export const openAiNativeModels = {
|
|||
inputPrice: 0.4,
|
||||
outputPrice: 1.6,
|
||||
cacheReadsPrice: 0.1,
|
||||
supportsTemperature: true,
|
||||
},
|
||||
"gpt-4.1-nano": {
|
||||
maxTokens: 32_768,
|
||||
|
|
@ -84,6 +89,7 @@ export const openAiNativeModels = {
|
|||
inputPrice: 0.1,
|
||||
outputPrice: 0.4,
|
||||
cacheReadsPrice: 0.025,
|
||||
supportsTemperature: true,
|
||||
},
|
||||
o3: {
|
||||
maxTokens: 100_000,
|
||||
|
|
@ -95,6 +101,7 @@ export const openAiNativeModels = {
|
|||
cacheReadsPrice: 0.5,
|
||||
supportsReasoningEffort: true,
|
||||
reasoningEffort: "medium",
|
||||
supportsTemperature: false,
|
||||
},
|
||||
"o3-high": {
|
||||
maxTokens: 100_000,
|
||||
|
|
@ -105,6 +112,7 @@ export const openAiNativeModels = {
|
|||
outputPrice: 8.0,
|
||||
cacheReadsPrice: 0.5,
|
||||
reasoningEffort: "high",
|
||||
supportsTemperature: false,
|
||||
},
|
||||
"o3-low": {
|
||||
maxTokens: 100_000,
|
||||
|
|
@ -115,6 +123,7 @@ export const openAiNativeModels = {
|
|||
outputPrice: 8.0,
|
||||
cacheReadsPrice: 0.5,
|
||||
reasoningEffort: "low",
|
||||
supportsTemperature: false,
|
||||
},
|
||||
"o4-mini": {
|
||||
maxTokens: 100_000,
|
||||
|
|
@ -126,6 +135,7 @@ export const openAiNativeModels = {
|
|||
cacheReadsPrice: 0.275,
|
||||
supportsReasoningEffort: true,
|
||||
reasoningEffort: "medium",
|
||||
supportsTemperature: false,
|
||||
},
|
||||
"o4-mini-high": {
|
||||
maxTokens: 100_000,
|
||||
|
|
@ -136,6 +146,7 @@ export const openAiNativeModels = {
|
|||
outputPrice: 4.4,
|
||||
cacheReadsPrice: 0.275,
|
||||
reasoningEffort: "high",
|
||||
supportsTemperature: false,
|
||||
},
|
||||
"o4-mini-low": {
|
||||
maxTokens: 100_000,
|
||||
|
|
@ -146,6 +157,7 @@ export const openAiNativeModels = {
|
|||
outputPrice: 4.4,
|
||||
cacheReadsPrice: 0.275,
|
||||
reasoningEffort: "low",
|
||||
supportsTemperature: false,
|
||||
},
|
||||
"o3-mini": {
|
||||
maxTokens: 100_000,
|
||||
|
|
@ -157,6 +169,7 @@ export const openAiNativeModels = {
|
|||
cacheReadsPrice: 0.55,
|
||||
supportsReasoningEffort: true,
|
||||
reasoningEffort: "medium",
|
||||
supportsTemperature: false,
|
||||
},
|
||||
"o3-mini-high": {
|
||||
maxTokens: 100_000,
|
||||
|
|
@ -167,6 +180,7 @@ export const openAiNativeModels = {
|
|||
outputPrice: 4.4,
|
||||
cacheReadsPrice: 0.55,
|
||||
reasoningEffort: "high",
|
||||
supportsTemperature: false,
|
||||
},
|
||||
"o3-mini-low": {
|
||||
maxTokens: 100_000,
|
||||
|
|
@ -177,6 +191,7 @@ export const openAiNativeModels = {
|
|||
outputPrice: 4.4,
|
||||
cacheReadsPrice: 0.55,
|
||||
reasoningEffort: "low",
|
||||
supportsTemperature: false,
|
||||
},
|
||||
o1: {
|
||||
maxTokens: 100_000,
|
||||
|
|
@ -186,6 +201,7 @@ export const openAiNativeModels = {
|
|||
inputPrice: 15,
|
||||
outputPrice: 60,
|
||||
cacheReadsPrice: 7.5,
|
||||
supportsTemperature: false,
|
||||
},
|
||||
"o1-preview": {
|
||||
maxTokens: 32_768,
|
||||
|
|
@ -195,6 +211,7 @@ export const openAiNativeModels = {
|
|||
inputPrice: 15,
|
||||
outputPrice: 60,
|
||||
cacheReadsPrice: 7.5,
|
||||
supportsTemperature: false,
|
||||
},
|
||||
"o1-mini": {
|
||||
maxTokens: 65_536,
|
||||
|
|
@ -204,6 +221,7 @@ export const openAiNativeModels = {
|
|||
inputPrice: 1.1,
|
||||
outputPrice: 4.4,
|
||||
cacheReadsPrice: 0.55,
|
||||
supportsTemperature: false,
|
||||
},
|
||||
"gpt-4o": {
|
||||
maxTokens: 16_384,
|
||||
|
|
@ -213,6 +231,7 @@ export const openAiNativeModels = {
|
|||
inputPrice: 2.5,
|
||||
outputPrice: 10,
|
||||
cacheReadsPrice: 1.25,
|
||||
supportsTemperature: true,
|
||||
},
|
||||
"gpt-4o-mini": {
|
||||
maxTokens: 16_384,
|
||||
|
|
@ -222,6 +241,7 @@ export const openAiNativeModels = {
|
|||
inputPrice: 0.15,
|
||||
outputPrice: 0.6,
|
||||
cacheReadsPrice: 0.075,
|
||||
supportsTemperature: true,
|
||||
},
|
||||
"codex-mini-latest": {
|
||||
maxTokens: 16_384,
|
||||
|
|
@ -231,6 +251,7 @@ export const openAiNativeModels = {
|
|||
inputPrice: 1.5,
|
||||
outputPrice: 6,
|
||||
cacheReadsPrice: 0,
|
||||
supportsTemperature: false,
|
||||
description:
|
||||
"Codex Mini: Cloud-based software engineering agent powered by codex-1, a version of o3 optimized for coding tasks. Trained with reinforcement learning to generate human-style code, adhere to instructions, and iteratively run tests.",
|
||||
},
|
||||
|
|
|
|||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
|
|
@ -683,8 +683,8 @@ importers:
|
|||
specifier: ^0.5.17
|
||||
version: 0.5.17
|
||||
openai:
|
||||
specifier: ^5.0.0
|
||||
version: 5.5.1(ws@8.18.3)(zod@3.25.61)
|
||||
specifier: ^5.12.2
|
||||
version: 5.12.2(ws@8.18.3)(zod@3.25.61)
|
||||
os-name:
|
||||
specifier: ^6.0.0
|
||||
version: 6.1.0
|
||||
|
|
@ -7939,8 +7939,8 @@ packages:
|
|||
resolution: {integrity: sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
openai@5.5.1:
|
||||
resolution: {integrity: sha512-5i19097mGotHA1eFsM6Tjd/tJ8uo9sa5Ysv4Q6bKJ2vtN6rc0MzMrUefXnLXYAJcmMQrC1Efhj0AvfIkXrQamw==}
|
||||
openai@5.12.2:
|
||||
resolution: {integrity: sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
ws: ^8.18.0
|
||||
|
|
@ -18191,7 +18191,7 @@ snapshots:
|
|||
is-inside-container: 1.0.0
|
||||
is-wsl: 3.1.0
|
||||
|
||||
openai@5.5.1(ws@8.18.3)(zod@3.25.61):
|
||||
openai@5.12.2(ws@8.18.3)(zod@3.25.61):
|
||||
optionalDependencies:
|
||||
ws: 8.18.3
|
||||
zod: 3.25.61
|
||||
|
|
|
|||
|
|
@ -56,6 +56,14 @@ export interface ApiHandlerCreateMessageMetadata {
|
|||
* Used to enforce "skip once" after a condense operation.
|
||||
*/
|
||||
suppressPreviousResponseId?: boolean
|
||||
/**
|
||||
* Controls whether the response should be stored for 30 days in OpenAI's Responses API.
|
||||
* When true (default), responses are stored and can be referenced in future requests
|
||||
* using the previous_response_id for efficient conversation continuity.
|
||||
* Set to false to opt out of response storage for privacy or compliance reasons.
|
||||
* @default true
|
||||
*/
|
||||
store?: boolean
|
||||
}
|
||||
|
||||
export interface ApiHandler {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -28,6 +28,9 @@ export type OpenAiNativeModel = ReturnType<OpenAiNativeHandler["getModel"]>
|
|||
|
||||
// GPT-5 specific types
|
||||
|
||||
// Constants for model identification
|
||||
const GPT5_MODEL_PREFIX = "gpt-5"
|
||||
|
||||
export class OpenAiNativeHandler extends BaseProvider implements SingleCompletionHandler {
|
||||
protected options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
|
|
@ -35,8 +38,8 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
private responseIdPromise: Promise<string | undefined> | undefined
|
||||
private responseIdResolver: ((value: string | undefined) => void) | undefined
|
||||
|
||||
// Event types handled by the shared GPT-5 event processor to avoid duplication
|
||||
private readonly gpt5CoreHandledTypes = new Set<string>([
|
||||
// Event types handled by the shared event processor to avoid duplication
|
||||
private readonly coreHandledEventTypes = new Set<string>([
|
||||
"response.text.delta",
|
||||
"response.output_text.delta",
|
||||
"response.reasoning.delta",
|
||||
|
|
@ -60,7 +63,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
this.client = new OpenAI({ baseURL: this.options.openAiNativeBaseUrl, apiKey })
|
||||
}
|
||||
|
||||
private normalizeGpt5Usage(usage: any, model: OpenAiNativeModel): ApiStreamUsageChunk | undefined {
|
||||
private normalizeUsage(usage: any, model: OpenAiNativeModel): ApiStreamUsageChunk | undefined {
|
||||
if (!usage) return undefined
|
||||
|
||||
const totalInputTokens = usage.input_tokens ?? usage.prompt_tokens ?? 0
|
||||
|
|
@ -103,114 +106,9 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
const model = this.getModel()
|
||||
let id: "o3-mini" | "o3" | "o4-mini" | undefined
|
||||
|
||||
if (model.id.startsWith("o3-mini")) {
|
||||
id = "o3-mini"
|
||||
} else if (model.id.startsWith("o3")) {
|
||||
id = "o3"
|
||||
} else if (model.id.startsWith("o4-mini")) {
|
||||
id = "o4-mini"
|
||||
}
|
||||
|
||||
if (id) {
|
||||
yield* this.handleReasonerMessage(model, id, systemPrompt, messages)
|
||||
} else if (model.id.startsWith("o1")) {
|
||||
yield* this.handleO1FamilyMessage(model, systemPrompt, messages)
|
||||
} else if (this.isResponsesApiModel(model.id)) {
|
||||
// Both GPT-5 and Codex Mini use the v1/responses endpoint
|
||||
yield* this.handleResponsesApiMessage(model, systemPrompt, messages, metadata)
|
||||
} else {
|
||||
yield* this.handleDefaultModelMessage(model, systemPrompt, messages)
|
||||
}
|
||||
}
|
||||
|
||||
private async *handleO1FamilyMessage(
|
||||
model: OpenAiNativeModel,
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
): ApiStream {
|
||||
// o1 supports developer prompt with formatting
|
||||
// o1-preview and o1-mini only support user messages
|
||||
const isOriginalO1 = model.id === "o1"
|
||||
const { reasoning } = this.getModel()
|
||||
|
||||
const response = await this.client.chat.completions.create({
|
||||
model: model.id,
|
||||
messages: [
|
||||
{
|
||||
role: isOriginalO1 ? "developer" : "user",
|
||||
content: isOriginalO1 ? `Formatting re-enabled\n${systemPrompt}` : systemPrompt,
|
||||
},
|
||||
...convertToOpenAiMessages(messages),
|
||||
],
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
...(reasoning && reasoning),
|
||||
})
|
||||
|
||||
yield* this.handleStreamResponse(response, model)
|
||||
}
|
||||
|
||||
private async *handleReasonerMessage(
|
||||
model: OpenAiNativeModel,
|
||||
family: "o3-mini" | "o3" | "o4-mini",
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
): ApiStream {
|
||||
const { reasoning } = this.getModel()
|
||||
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: family,
|
||||
messages: [
|
||||
{
|
||||
role: "developer",
|
||||
content: `Formatting re-enabled\n${systemPrompt}`,
|
||||
},
|
||||
...convertToOpenAiMessages(messages),
|
||||
],
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
...(reasoning && reasoning),
|
||||
})
|
||||
|
||||
yield* this.handleStreamResponse(stream, model)
|
||||
}
|
||||
|
||||
private async *handleDefaultModelMessage(
|
||||
model: OpenAiNativeModel,
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
): ApiStream {
|
||||
const { reasoning, verbosity } = this.getModel()
|
||||
|
||||
// Prepare the request parameters
|
||||
const params: any = {
|
||||
model: model.id,
|
||||
temperature: this.options.modelTemperature ?? OPENAI_NATIVE_DEFAULT_TEMPERATURE,
|
||||
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
...(reasoning && reasoning),
|
||||
}
|
||||
|
||||
// Add verbosity only if the model supports it
|
||||
if (verbosity && model.info.supportsVerbosity) {
|
||||
params.verbosity = verbosity
|
||||
}
|
||||
|
||||
const stream = await this.client.chat.completions.create(params)
|
||||
|
||||
if (typeof (stream as any)[Symbol.asyncIterator] !== "function") {
|
||||
throw new Error(
|
||||
"OpenAI SDK did not return an AsyncIterable for streaming response. Please check SDK version and usage.",
|
||||
)
|
||||
}
|
||||
|
||||
yield* this.handleStreamResponse(
|
||||
stream as unknown as AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>,
|
||||
model,
|
||||
)
|
||||
// Use Responses API for ALL models
|
||||
yield* this.handleResponsesApiMessage(model, systemPrompt, messages, metadata)
|
||||
}
|
||||
|
||||
private async *handleResponsesApiMessage(
|
||||
|
|
@ -219,20 +117,24 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
messages: Anthropic.Messages.MessageParam[],
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
// Prefer the official SDK Responses API with streaming; fall back to fetch-based SSE if needed.
|
||||
const { verbosity } = this.getModel()
|
||||
// Use Responses API for ALL models
|
||||
const { verbosity, reasoning } = this.getModel()
|
||||
|
||||
// Both GPT-5 and Codex Mini use the same v1/responses endpoint format
|
||||
|
||||
// Resolve reasoning effort (supports "minimal" for GPT‑5)
|
||||
const reasoningEffort = this.getGpt5ReasoningEffort(model)
|
||||
// Resolve reasoning effort for models that support it
|
||||
const reasoningEffort = this.getReasoningEffort(model)
|
||||
|
||||
// Wait for any pending response ID from a previous request to be available
|
||||
// This handles the race condition with fast nano model responses
|
||||
let effectivePreviousResponseId = metadata?.previousResponseId
|
||||
|
||||
// Only allow fallback to pending/last response id when not explicitly suppressed
|
||||
if (!metadata?.suppressPreviousResponseId) {
|
||||
// Check if we should suppress previous response ID (e.g., after condense or message edit)
|
||||
if (metadata?.suppressPreviousResponseId) {
|
||||
// Clear the stored lastResponseId to prevent it from being used in future requests
|
||||
this.lastResponseId = undefined
|
||||
effectivePreviousResponseId = undefined
|
||||
} else {
|
||||
// Only try to get fallback response IDs if not suppressing
|
||||
|
||||
// If we have a pending response ID promise, wait for it to resolve
|
||||
if (!effectivePreviousResponseId && this.responseIdPromise) {
|
||||
try {
|
||||
|
|
@ -250,52 +152,102 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
}
|
||||
|
||||
// Fall back to the last known response ID if still not available
|
||||
if (!effectivePreviousResponseId) {
|
||||
if (!effectivePreviousResponseId && this.lastResponseId) {
|
||||
effectivePreviousResponseId = this.lastResponseId
|
||||
}
|
||||
}
|
||||
|
||||
// Format input and capture continuity id
|
||||
const { formattedInput, previousResponseId } = this.prepareGpt5Input(systemPrompt, messages, metadata)
|
||||
const requestPreviousResponseId = effectivePreviousResponseId ?? previousResponseId
|
||||
const { formattedInput, previousResponseId } = this.prepareStructuredInput(systemPrompt, messages, metadata)
|
||||
const requestPreviousResponseId = effectivePreviousResponseId || previousResponseId
|
||||
|
||||
// Create a new promise for this request's response ID
|
||||
this.responseIdPromise = new Promise<string | undefined>((resolve) => {
|
||||
this.responseIdResolver = resolve
|
||||
})
|
||||
|
||||
// Build request body
|
||||
const requestBody = this.buildRequestBody(
|
||||
model,
|
||||
formattedInput,
|
||||
requestPreviousResponseId,
|
||||
systemPrompt,
|
||||
verbosity,
|
||||
reasoningEffort,
|
||||
metadata,
|
||||
)
|
||||
|
||||
// Make the request
|
||||
yield* this.executeRequest(requestBody, model, metadata)
|
||||
}
|
||||
|
||||
private buildRequestBody(
|
||||
model: OpenAiNativeModel,
|
||||
formattedInput: any,
|
||||
requestPreviousResponseId: string | undefined,
|
||||
systemPrompt: string,
|
||||
verbosity: any,
|
||||
reasoningEffort: ReasoningEffortWithMinimal | undefined,
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): any {
|
||||
// Build a request body (also used for fallback)
|
||||
// Ensure we explicitly pass max_output_tokens for GPT‑5 based on Roo's reserved model response calculation
|
||||
// so requests do not default to very large limits (e.g., 120k).
|
||||
interface Gpt5RequestBody {
|
||||
model: string
|
||||
input: string
|
||||
input: Array<{ role: "user" | "assistant"; content: any[] }>
|
||||
stream: boolean
|
||||
reasoning?: { effort: ReasoningEffortWithMinimal; summary?: "auto" }
|
||||
text?: { verbosity: VerbosityLevel }
|
||||
temperature?: number
|
||||
max_output_tokens?: number
|
||||
previous_response_id?: string
|
||||
store?: boolean
|
||||
instructions?: string
|
||||
}
|
||||
|
||||
const requestBody: Gpt5RequestBody = {
|
||||
const body: Gpt5RequestBody = {
|
||||
model: model.id,
|
||||
input: formattedInput,
|
||||
stream: true,
|
||||
store: metadata?.store !== false, // Default to true unless explicitly set to false
|
||||
// Always include instructions (system prompt) for Responses API.
|
||||
// Unlike Chat Completions, system/developer roles in input have no special semantics here.
|
||||
// The official way to set system behavior is the top-level `instructions` field.
|
||||
instructions: systemPrompt,
|
||||
...(reasoningEffort && {
|
||||
reasoning: {
|
||||
effort: reasoningEffort,
|
||||
...(this.options.enableGpt5ReasoningSummary ? { summary: "auto" as const } : {}),
|
||||
},
|
||||
}),
|
||||
text: { verbosity: (verbosity || "medium") as VerbosityLevel },
|
||||
temperature: this.options.modelTemperature ?? GPT5_DEFAULT_TEMPERATURE,
|
||||
// Explicitly include the calculated max output tokens for GPT‑5.
|
||||
// Only include temperature if the model supports it
|
||||
...(model.info.supportsTemperature !== false && {
|
||||
temperature:
|
||||
this.options.modelTemperature ??
|
||||
(model.id.startsWith(GPT5_MODEL_PREFIX)
|
||||
? GPT5_DEFAULT_TEMPERATURE
|
||||
: OPENAI_NATIVE_DEFAULT_TEMPERATURE),
|
||||
}),
|
||||
// Explicitly include the calculated max output tokens.
|
||||
// Use the per-request reserved output computed by Roo (params.maxTokens from getModelParams).
|
||||
...(model.maxTokens ? { max_output_tokens: model.maxTokens } : {}),
|
||||
...(requestPreviousResponseId && { previous_response_id: requestPreviousResponseId }),
|
||||
}
|
||||
|
||||
// Include text.verbosity only when the model explicitly supports it
|
||||
if (model.info.supportsVerbosity === true) {
|
||||
body.text = { verbosity: (verbosity || "medium") as VerbosityLevel }
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
private async *executeRequest(
|
||||
requestBody: any,
|
||||
model: OpenAiNativeModel,
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
try {
|
||||
// Use the official SDK
|
||||
const stream = (await (this.client as any).responses.create(requestBody)) as AsyncIterable<any>
|
||||
|
|
@ -307,7 +259,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
}
|
||||
|
||||
for await (const event of stream) {
|
||||
for await (const outChunk of this.processGpt5Event(event, model)) {
|
||||
for await (const outChunk of this.processEvent(event, model)) {
|
||||
yield outChunk
|
||||
}
|
||||
}
|
||||
|
|
@ -320,9 +272,6 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
|
||||
if (is400Error && requestBody.previous_response_id && isPreviousResponseError) {
|
||||
// Log the error and retry without the previous_response_id
|
||||
console.warn(
|
||||
`[GPT-5] Previous response ID not found (${requestBody.previous_response_id}), retrying without it`,
|
||||
)
|
||||
|
||||
// Remove the problematic previous_response_id and retry
|
||||
const retryRequestBody = { ...requestBody }
|
||||
|
|
@ -344,7 +293,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
}
|
||||
|
||||
for await (const event of retryStream) {
|
||||
for await (const outChunk of this.processGpt5Event(event, model)) {
|
||||
for await (const outChunk of this.processEvent(event, model)) {
|
||||
yield outChunk
|
||||
}
|
||||
}
|
||||
|
|
@ -361,52 +310,85 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
}
|
||||
}
|
||||
|
||||
private formatInputForResponsesAPI(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): string {
|
||||
// Format the conversation for the Responses API input field
|
||||
// Use Developer role format for GPT-5 (aligning with o1/o3 Developer role usage per GPT-5 Responses guidance)
|
||||
// This ensures consistent instruction handling across reasoning models
|
||||
let formattedInput = `Developer: ${systemPrompt}\n\n`
|
||||
private formatFullConversation(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): any {
|
||||
// Format the entire conversation history for the Responses API using structured format
|
||||
// This supports both text and images
|
||||
const formattedMessages: any[] = []
|
||||
|
||||
// Do NOT embed the system prompt as a developer message in the Responses API input.
|
||||
// The Responses API treats roles as free-form; use the top-level `instructions` field instead.
|
||||
|
||||
// Process each message
|
||||
for (const message of messages) {
|
||||
const role = message.role === "user" ? "User" : "Assistant"
|
||||
const role = message.role === "user" ? "user" : "assistant"
|
||||
const content: any[] = []
|
||||
|
||||
// Handle text content
|
||||
if (typeof message.content === "string") {
|
||||
formattedInput += `${role}: ${message.content}\n\n`
|
||||
// For user messages, use input_text; for assistant messages, use output_text
|
||||
if (role === "user") {
|
||||
content.push({ type: "input_text", text: message.content })
|
||||
} else {
|
||||
content.push({ type: "output_text", text: message.content })
|
||||
}
|
||||
} else if (Array.isArray(message.content)) {
|
||||
// Handle content blocks
|
||||
const textContent = message.content
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => (block as any).text)
|
||||
.join("\n")
|
||||
if (textContent) {
|
||||
formattedInput += `${role}: ${textContent}\n\n`
|
||||
// For array content with potential images, format properly
|
||||
for (const block of message.content) {
|
||||
if (block.type === "text") {
|
||||
// For user messages, use input_text; for assistant messages, use output_text
|
||||
if (role === "user") {
|
||||
content.push({ type: "input_text", text: (block as any).text })
|
||||
} else {
|
||||
content.push({ type: "output_text", text: (block as any).text })
|
||||
}
|
||||
} else if (block.type === "image") {
|
||||
const image = block as Anthropic.Messages.ImageBlockParam
|
||||
// Format image with proper data URL - images are always input_image
|
||||
const imageUrl = `data:${image.source.media_type};base64,${image.source.data}`
|
||||
content.push({ type: "input_image", image_url: imageUrl })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return formattedInput.trim()
|
||||
}
|
||||
|
||||
private formatSingleMessageForResponsesAPI(message: Anthropic.Messages.MessageParam): string {
|
||||
// Format a single message for the Responses API when using previous_response_id
|
||||
const role = message.role === "user" ? "User" : "Assistant"
|
||||
|
||||
// Handle text content
|
||||
if (typeof message.content === "string") {
|
||||
return `${role}: ${message.content}`
|
||||
} else if (Array.isArray(message.content)) {
|
||||
// Handle content blocks
|
||||
const textContent = message.content
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => (block as any).text)
|
||||
.join("\n")
|
||||
if (textContent) {
|
||||
return `${role}: ${textContent}`
|
||||
if (content.length > 0) {
|
||||
formattedMessages.push({ role, content })
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
return formattedMessages
|
||||
}
|
||||
|
||||
private formatSingleStructuredMessage(message: Anthropic.Messages.MessageParam): any {
|
||||
// Format a single message for the Responses API when using previous_response_id
|
||||
// When using previous_response_id, we only send the latest user message
|
||||
const role = message.role === "user" ? "user" : "assistant"
|
||||
|
||||
if (typeof message.content === "string") {
|
||||
// For simple string content, return structured format with proper type
|
||||
return {
|
||||
role,
|
||||
content: [{ type: "input_text", text: message.content }],
|
||||
}
|
||||
} else if (Array.isArray(message.content)) {
|
||||
// Extract text and image content from blocks
|
||||
const content: any[] = []
|
||||
|
||||
for (const block of message.content) {
|
||||
if (block.type === "text") {
|
||||
// User messages use input_text
|
||||
content.push({ type: "input_text", text: (block as any).text })
|
||||
} else if (block.type === "image") {
|
||||
const image = block as Anthropic.Messages.ImageBlockParam
|
||||
const imageUrl = `data:${image.source.media_type};base64,${image.source.data}`
|
||||
content.push({ type: "input_image", image_url: imageUrl })
|
||||
}
|
||||
}
|
||||
|
||||
if (content.length > 0) {
|
||||
return { role, content }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private async *makeGpt5ResponsesAPIRequest(
|
||||
|
|
@ -456,9 +438,6 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
|
||||
if (response.status === 400 && requestBody.previous_response_id && isPreviousResponseError) {
|
||||
// Log the error and retry without the previous_response_id
|
||||
console.warn(
|
||||
`[GPT-5 SSE] Previous response ID not found (${requestBody.previous_response_id}), retrying without it`,
|
||||
)
|
||||
|
||||
// Remove the problematic previous_response_id and retry
|
||||
const retryRequestBody = { ...requestBody }
|
||||
|
|
@ -482,32 +461,32 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
|
||||
if (!retryResponse.ok) {
|
||||
// If retry also fails, throw the original error
|
||||
throw new Error(`GPT-5 API retry failed (${retryResponse.status})`)
|
||||
throw new Error(`Responses API retry failed (${retryResponse.status})`)
|
||||
}
|
||||
|
||||
if (!retryResponse.body) {
|
||||
throw new Error("GPT-5 Responses API error: No response body from retry request")
|
||||
throw new Error("Responses API error: No response body from retry request")
|
||||
}
|
||||
|
||||
// Handle the successful retry response
|
||||
yield* this.handleGpt5StreamResponse(retryResponse.body, model)
|
||||
yield* this.handleStreamResponse(retryResponse.body, model)
|
||||
return
|
||||
}
|
||||
|
||||
// Provide user-friendly error messages based on status code
|
||||
switch (response.status) {
|
||||
case 400:
|
||||
errorMessage = "Invalid request to GPT-5 API. Please check your input parameters."
|
||||
errorMessage = "Invalid request to Responses API. Please check your input parameters."
|
||||
break
|
||||
case 401:
|
||||
errorMessage = "Authentication failed. Please check your OpenAI API key."
|
||||
break
|
||||
case 403:
|
||||
errorMessage = "Access denied. Your API key may not have access to GPT-5 models."
|
||||
errorMessage = "Access denied. Your API key may not have access to this endpoint."
|
||||
break
|
||||
case 404:
|
||||
errorMessage =
|
||||
"GPT-5 API endpoint not found. The model may not be available yet or requires a different configuration."
|
||||
"Responses API endpoint not found. The endpoint may not be available yet or requires a different configuration."
|
||||
break
|
||||
case 429:
|
||||
errorMessage = "Rate limit exceeded. Please try again later."
|
||||
|
|
@ -518,7 +497,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
errorMessage = "OpenAI service error. Please try again later."
|
||||
break
|
||||
default:
|
||||
errorMessage = `GPT-5 API error (${response.status})`
|
||||
errorMessage = `Responses API error (${response.status})`
|
||||
}
|
||||
|
||||
// Append details if available
|
||||
|
|
@ -530,73 +509,74 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("GPT-5 Responses API error: No response body")
|
||||
throw new Error("Responses API error: No response body")
|
||||
}
|
||||
|
||||
// Handle streaming response
|
||||
yield* this.handleGpt5StreamResponse(response.body, model)
|
||||
yield* this.handleStreamResponse(response.body, model)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
// Re-throw with the original error message if it's already formatted
|
||||
if (error.message.includes("GPT-5")) {
|
||||
if (error.message.includes("Responses API")) {
|
||||
throw error
|
||||
}
|
||||
// Otherwise, wrap it with context
|
||||
throw new Error(`Failed to connect to GPT-5 API: ${error.message}`)
|
||||
throw new Error(`Failed to connect to Responses API: ${error.message}`)
|
||||
}
|
||||
// Handle non-Error objects
|
||||
throw new Error(`Unexpected error connecting to GPT-5 API`)
|
||||
throw new Error(`Unexpected error connecting to Responses API`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the input and conversation continuity parameters for a GPT-5 API call.
|
||||
* Prepares the input and conversation continuity parameters for a Responses API call.
|
||||
* Decides whether to send full conversation or just the latest message based on previousResponseId.
|
||||
*
|
||||
* - If a `previousResponseId` is available (either from metadata or the handler's state),
|
||||
* it formats only the most recent user message for the input and returns the response ID
|
||||
* to maintain conversation context.
|
||||
* - Otherwise, it formats the entire conversation history (system prompt + messages) for the input.
|
||||
*
|
||||
* @returns An object containing the formatted input string and the previous response ID (if used).
|
||||
* @returns An object containing the formatted input and the previous response ID (if used).
|
||||
*/
|
||||
private prepareGpt5Input(
|
||||
private prepareStructuredInput(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): { formattedInput: string; previousResponseId?: string } {
|
||||
// Respect explicit suppression signal for continuity (e.g. immediately after condense)
|
||||
const isFirstMessage = messages.length === 1 && messages[0].role === "user"
|
||||
const allowFallback = !metadata?.suppressPreviousResponseId
|
||||
): { formattedInput: any; previousResponseId?: string } {
|
||||
// Note: suppressPreviousResponseId is handled in handleResponsesApiMessage
|
||||
// This method now only handles formatting based on whether we have a previous response ID
|
||||
|
||||
const previousResponseId =
|
||||
metadata?.previousResponseId ?? (allowFallback && !isFirstMessage ? this.lastResponseId : undefined)
|
||||
// Check for previous response ID from metadata or fallback to lastResponseId
|
||||
const isFirstMessage = messages.length === 1 && messages[0].role === "user"
|
||||
const previousResponseId = metadata?.previousResponseId ?? (!isFirstMessage ? this.lastResponseId : undefined)
|
||||
|
||||
if (previousResponseId) {
|
||||
// When using previous_response_id, only send the latest user message
|
||||
const lastUserMessage = [...messages].reverse().find((msg) => msg.role === "user")
|
||||
const formattedInput = lastUserMessage ? this.formatSingleMessageForResponsesAPI(lastUserMessage) : ""
|
||||
return { formattedInput, previousResponseId }
|
||||
if (lastUserMessage) {
|
||||
const formattedMessage = this.formatSingleStructuredMessage(lastUserMessage)
|
||||
// formatSingleStructuredMessage now always returns an object with role and content
|
||||
if (formattedMessage) {
|
||||
return { formattedInput: [formattedMessage], previousResponseId }
|
||||
}
|
||||
}
|
||||
return { formattedInput: [], previousResponseId }
|
||||
} else {
|
||||
const formattedInput = this.formatInputForResponsesAPI(systemPrompt, messages)
|
||||
// Format full conversation history (returns an array of structured messages)
|
||||
const formattedInput = this.formatFullConversation(systemPrompt, messages)
|
||||
return { formattedInput }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the streaming response from the GPT-5 Responses API.
|
||||
* Handles the streaming response from the Responses API.
|
||||
*
|
||||
* This function iterates through the Server-Sent Events (SSE) stream, parses each event,
|
||||
* and yields structured data chunks (`ApiStream`). It handles a wide variety of event types,
|
||||
* including text deltas, reasoning, usage data, and various status/tool events.
|
||||
*
|
||||
* The following event types are intentionally ignored as they are not currently consumed
|
||||
* by the client application:
|
||||
* - Audio events (`response.audio.*`)
|
||||
* - Most tool call events (e.g., `response.function_call_arguments.*`, `response.mcp_call.*`, etc.)
|
||||
* as the client does not yet support rendering these tool interactions.
|
||||
* - Status events (`response.created`, `response.in_progress`, etc.) as they are informational
|
||||
* and do not affect the final output.
|
||||
*/
|
||||
private async *handleGpt5StreamResponse(body: ReadableStream<Uint8Array>, model: OpenAiNativeModel): ApiStream {
|
||||
private async *handleStreamResponse(body: ReadableStream<Uint8Array>, model: OpenAiNativeModel): ApiStream {
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
|
|
@ -629,8 +609,8 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
}
|
||||
|
||||
// Delegate standard event types to the shared processor to avoid duplication
|
||||
if (parsed?.type && this.gpt5CoreHandledTypes.has(parsed.type)) {
|
||||
for await (const outChunk of this.processGpt5Event(parsed, model)) {
|
||||
if (parsed?.type && this.coreHandledEventTypes.has(parsed.type)) {
|
||||
for await (const outChunk of this.processEvent(parsed, model)) {
|
||||
// Track whether we've emitted any content so fallback handling can decide appropriately
|
||||
if (outChunk.type === "text" || outChunk.type === "reasoning") {
|
||||
hasContent = true
|
||||
|
|
@ -670,7 +650,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
}
|
||||
// Check for usage in the complete response
|
||||
if (parsed.response.usage) {
|
||||
const usageData = this.normalizeGpt5Usage(parsed.response.usage, model)
|
||||
const usageData = this.normalizeUsage(parsed.response.usage, model)
|
||||
if (usageData) {
|
||||
yield usageData
|
||||
}
|
||||
|
|
@ -910,7 +890,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
// Response failed
|
||||
if (parsed.error || parsed.message) {
|
||||
throw new Error(
|
||||
`GPT-5 response failed: ${parsed.error?.message || parsed.message || "Unknown failure"}`,
|
||||
`Response failed: ${parsed.error?.message || parsed.message || "Unknown failure"}`,
|
||||
)
|
||||
}
|
||||
} else if (parsed.type === "response.completed" || parsed.type === "response.done") {
|
||||
|
|
@ -990,7 +970,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
}
|
||||
} else if (parsed.usage) {
|
||||
// Handle usage if it arrives in a separate, non-completed event
|
||||
const usageData = this.normalizeGpt5Usage(parsed.usage, model)
|
||||
const usageData = this.normalizeUsage(parsed.usage, model)
|
||||
if (usageData) {
|
||||
yield usageData
|
||||
}
|
||||
|
|
@ -1026,19 +1006,18 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
// This can happen in certain edge cases and shouldn't break the flow
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`Error processing GPT-5 response stream: ${error.message}`)
|
||||
throw new Error(`Error processing response stream: ${error.message}`)
|
||||
}
|
||||
throw new Error("Unexpected error processing GPT-5 response stream")
|
||||
throw new Error("Unexpected error processing response stream")
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared processor for GPT‑5 Responses API events.
|
||||
* Used by both the official SDK streaming path and (optionally) by the SSE fallback.
|
||||
* Shared processor for Responses API events.
|
||||
*/
|
||||
private async *processGpt5Event(event: any, model: OpenAiNativeModel): ApiStream {
|
||||
private async *processEvent(event: any, model: OpenAiNativeModel): ApiStream {
|
||||
// Persist response id for conversation continuity when available
|
||||
if (event?.response?.id) {
|
||||
this.resolveResponseId(event.response.id)
|
||||
|
|
@ -1096,7 +1075,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
// Completion events that may carry usage
|
||||
if (event?.type === "response.done" || event?.type === "response.completed") {
|
||||
const usage = event?.response?.usage || event?.usage || undefined
|
||||
const usageData = this.normalizeGpt5Usage(usage, model)
|
||||
const usageData = this.normalizeUsage(usage, model)
|
||||
if (usageData) {
|
||||
yield usageData
|
||||
}
|
||||
|
|
@ -1110,87 +1089,30 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
}
|
||||
|
||||
if (event?.usage) {
|
||||
const usageData = this.normalizeGpt5Usage(event.usage, model)
|
||||
const usageData = this.normalizeUsage(event.usage, model)
|
||||
if (usageData) {
|
||||
yield usageData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getGpt5ReasoningEffort(model: OpenAiNativeModel): ReasoningEffortWithMinimal | undefined {
|
||||
private getReasoningEffort(model: OpenAiNativeModel): ReasoningEffortWithMinimal | undefined {
|
||||
const { reasoning, info } = model
|
||||
|
||||
// Check if reasoning effort is configured
|
||||
if (reasoning && "reasoning_effort" in reasoning) {
|
||||
const effort = reasoning.reasoning_effort as string
|
||||
// Support all effort levels including "minimal" for GPT-5
|
||||
// Support all effort levels
|
||||
if (effort === "minimal" || effort === "low" || effort === "medium" || effort === "high") {
|
||||
return effort as ReasoningEffortWithMinimal
|
||||
}
|
||||
}
|
||||
|
||||
// Centralize default: use the model's default from types if available; otherwise undefined
|
||||
// Use the model's default from types if available
|
||||
return info.reasoningEffort as ReasoningEffortWithMinimal | undefined
|
||||
}
|
||||
|
||||
private isGpt5Model(modelId: string): boolean {
|
||||
return modelId.startsWith("gpt-5")
|
||||
}
|
||||
|
||||
private isResponsesApiModel(modelId: string): boolean {
|
||||
// Both GPT-5 and Codex Mini use the v1/responses endpoint
|
||||
return modelId.startsWith("gpt-5") || modelId === "codex-mini-latest"
|
||||
}
|
||||
|
||||
private async *handleStreamResponse(
|
||||
stream: AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>,
|
||||
model: OpenAiNativeModel,
|
||||
): ApiStream {
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta
|
||||
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
yield* this.yieldUsage(model.info, chunk.usage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
|
||||
const inputTokens = usage?.prompt_tokens || 0
|
||||
const outputTokens = usage?.completion_tokens || 0
|
||||
|
||||
// Extract cache tokens from prompt_tokens_details
|
||||
// According to OpenAI API, cached_tokens represents tokens read from cache
|
||||
const cacheReadTokens = usage?.prompt_tokens_details?.cached_tokens || undefined
|
||||
|
||||
// Cache write tokens are not typically reported in the standard streaming response
|
||||
// They would be in cache_creation_input_tokens if available
|
||||
const cacheWriteTokens = (usage as any)?.cache_creation_input_tokens || undefined
|
||||
|
||||
const totalCost = calculateApiCostOpenAI(
|
||||
info,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheWriteTokens || 0,
|
||||
cacheReadTokens || 0,
|
||||
)
|
||||
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: inputTokens,
|
||||
outputTokens: outputTokens,
|
||||
cacheWriteTokens: cacheWriteTokens,
|
||||
cacheReadTokens: cacheReadTokens,
|
||||
totalCost: totalCost,
|
||||
}
|
||||
}
|
||||
// Removed isResponsesApiModel method as ALL models now use the Responses API
|
||||
|
||||
override getModel() {
|
||||
const modelId = this.options.apiModelId
|
||||
|
|
@ -1205,18 +1127,18 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
modelId: id,
|
||||
model: info,
|
||||
settings: this.options,
|
||||
defaultTemperature: this.isGpt5Model(id) ? GPT5_DEFAULT_TEMPERATURE : OPENAI_NATIVE_DEFAULT_TEMPERATURE,
|
||||
defaultTemperature: id.startsWith(GPT5_MODEL_PREFIX)
|
||||
? GPT5_DEFAULT_TEMPERATURE
|
||||
: OPENAI_NATIVE_DEFAULT_TEMPERATURE,
|
||||
})
|
||||
|
||||
// For models using the Responses API (GPT-5 and Codex Mini), ensure we support reasoning effort
|
||||
if (this.isResponsesApiModel(id)) {
|
||||
const effort =
|
||||
(this.options.reasoningEffort as ReasoningEffortWithMinimal | undefined) ??
|
||||
(info.reasoningEffort as ReasoningEffortWithMinimal | undefined)
|
||||
// For models using the Responses API, ensure we support reasoning effort
|
||||
const effort =
|
||||
(this.options.reasoningEffort as ReasoningEffortWithMinimal | undefined) ??
|
||||
(info.reasoningEffort as ReasoningEffortWithMinimal | undefined)
|
||||
|
||||
if (effort) {
|
||||
;(params.reasoning as any) = { reasoning_effort: effort }
|
||||
}
|
||||
if (effort) {
|
||||
;(params.reasoning as any) = { reasoning_effort: effort }
|
||||
}
|
||||
|
||||
// The o3 models are named like "o3-mini-[reasoning-effort]", which are
|
||||
|
|
@ -1225,7 +1147,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
}
|
||||
|
||||
/**
|
||||
* Gets the last GPT-5 response ID captured from the Responses API stream.
|
||||
* Gets the last response ID captured from the Responses API stream.
|
||||
* Used for maintaining conversation continuity across requests.
|
||||
* @returns The response ID, or undefined if not available yet
|
||||
*/
|
||||
|
|
@ -1234,9 +1156,9 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
}
|
||||
|
||||
/**
|
||||
* Sets the last GPT-5 response ID for conversation continuity.
|
||||
* Sets the last response ID for conversation continuity.
|
||||
* Typically only used in tests or special flows.
|
||||
* @param responseId The GPT-5 response ID to store
|
||||
* @param responseId The response ID to store
|
||||
*/
|
||||
setResponseId(responseId: string): void {
|
||||
this.lastResponseId = responseId
|
||||
|
|
@ -1244,31 +1166,74 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
|
||||
async completePrompt(prompt: string): Promise<string> {
|
||||
try {
|
||||
const { id, temperature, reasoning, verbosity } = this.getModel()
|
||||
const isResponsesApi = this.isResponsesApiModel(id)
|
||||
const model = this.getModel()
|
||||
const { verbosity, reasoning } = model
|
||||
|
||||
if (isResponsesApi) {
|
||||
// Models that use the Responses API (GPT-5 and Codex Mini) don't support non-streaming completion
|
||||
throw new Error(`completePrompt is not supported for ${id}. Use createMessage (Responses API) instead.`)
|
||||
// Resolve reasoning effort for models that support it
|
||||
const reasoningEffort = this.getReasoningEffort(model)
|
||||
|
||||
// Build request body for Responses API
|
||||
const requestBody: any = {
|
||||
model: model.id,
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: prompt }],
|
||||
},
|
||||
],
|
||||
stream: false, // Non-streaming for completePrompt
|
||||
store: false, // Don't store prompt completions
|
||||
}
|
||||
|
||||
const params: any = {
|
||||
model: id,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
// Add reasoning if supported
|
||||
if (reasoningEffort) {
|
||||
requestBody.reasoning = {
|
||||
effort: reasoningEffort,
|
||||
...(this.options.enableGpt5ReasoningSummary ? { summary: "auto" as const } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
// Add temperature if supported
|
||||
if (temperature !== undefined) {
|
||||
params.temperature = temperature
|
||||
// Only include temperature if the model supports it
|
||||
if (model.info.supportsTemperature !== false) {
|
||||
requestBody.temperature =
|
||||
this.options.modelTemperature ??
|
||||
(model.id.startsWith(GPT5_MODEL_PREFIX)
|
||||
? GPT5_DEFAULT_TEMPERATURE
|
||||
: OPENAI_NATIVE_DEFAULT_TEMPERATURE)
|
||||
}
|
||||
|
||||
// Add reasoning parameters for models that support them
|
||||
if (reasoning) {
|
||||
Object.assign(params, reasoning)
|
||||
// Include max_output_tokens if available
|
||||
if (model.maxTokens) {
|
||||
requestBody.max_output_tokens = model.maxTokens
|
||||
}
|
||||
|
||||
const response = await this.client.chat.completions.create(params)
|
||||
return response.choices[0]?.message.content || ""
|
||||
// Include text.verbosity only when the model explicitly supports it
|
||||
if (model.info.supportsVerbosity === true) {
|
||||
requestBody.text = { verbosity: (verbosity || "medium") as VerbosityLevel }
|
||||
}
|
||||
|
||||
// Make the non-streaming request
|
||||
const response = await (this.client as any).responses.create(requestBody)
|
||||
|
||||
// Extract text from the response
|
||||
if (response?.output && Array.isArray(response.output)) {
|
||||
for (const outputItem of response.output) {
|
||||
if (outputItem.type === "message" && outputItem.content) {
|
||||
for (const content of outputItem.content) {
|
||||
if (content.type === "output_text" && content.text) {
|
||||
return content.text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check for direct text in response
|
||||
if (response?.text) {
|
||||
return response.text
|
||||
}
|
||||
|
||||
return ""
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`OpenAI Native completion error: ${error.message}`)
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ import { getMessagesSinceLastSummary, summarizeConversation } from "../condense"
|
|||
import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning"
|
||||
import { restoreTodoListForTask } from "../tools/updateTodoListTool"
|
||||
import { AutoApprovalHandler } from "./AutoApprovalHandler"
|
||||
import { Gpt5Metadata, ClineMessageWithMetadata } from "./types"
|
||||
|
||||
const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes
|
||||
const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds
|
||||
|
|
@ -590,6 +591,18 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
|
||||
public async overwriteClineMessages(newMessages: ClineMessage[]) {
|
||||
this.clineMessages = newMessages
|
||||
|
||||
// If deletion or history truncation leaves a condense_context as the last message,
|
||||
// ensure the next API call suppresses previous_response_id so the condensed context is respected.
|
||||
try {
|
||||
const last = this.clineMessages.at(-1)
|
||||
if (last && last.type === "say" && last.say === "condense_context") {
|
||||
this.skipPrevResponseIdOnce = true
|
||||
}
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
|
||||
restoreTodoListForTask(this)
|
||||
await this.saveClineMessages()
|
||||
}
|
||||
|
|
@ -785,14 +798,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Task#${this.taskId}] pWaitFor askResponse(${type}) -> blocking (isStatusMutable = ${isStatusMutable}, statusMutationTimeouts = ${statusMutationTimeouts.length})`,
|
||||
)
|
||||
|
||||
// Wait for askResponse to be set
|
||||
await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 })
|
||||
|
||||
console.log(`[Task#${this.taskId}] pWaitFor askResponse(${type}) -> unblocked (${this.askResponse})`)
|
||||
|
||||
if (this.lastMessageTs !== askTs) {
|
||||
// Could happen if we send multiple asks in a row i.e. with
|
||||
// command_output. It's important that when we know an ask could
|
||||
|
|
@ -871,17 +879,17 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
const systemPrompt = await this.getSystemPrompt()
|
||||
|
||||
// Get condensing configuration
|
||||
// Using type assertion to handle the case where Phase 1 hasn't been implemented yet
|
||||
const state = await this.providerRef.deref()?.getState()
|
||||
const customCondensingPrompt = state ? (state as any).customCondensingPrompt : undefined
|
||||
const condensingApiConfigId = state ? (state as any).condensingApiConfigId : undefined
|
||||
const listApiConfigMeta = state ? (state as any).listApiConfigMeta : undefined
|
||||
// These properties may not exist in the state type yet, but are used for condensing configuration
|
||||
const customCondensingPrompt = state?.customCondensingPrompt
|
||||
const condensingApiConfigId = state?.condensingApiConfigId
|
||||
const listApiConfigMeta = state?.listApiConfigMeta
|
||||
|
||||
// Determine API handler to use
|
||||
let condensingApiHandler: ApiHandler | undefined
|
||||
if (condensingApiConfigId && listApiConfigMeta && Array.isArray(listApiConfigMeta)) {
|
||||
// Using type assertion for the id property to avoid implicit any
|
||||
const matchingConfig = listApiConfigMeta.find((config: any) => config.id === condensingApiConfigId)
|
||||
// Find matching config by ID
|
||||
const matchingConfig = listApiConfigMeta.find((config) => config.id === condensingApiConfigId)
|
||||
if (matchingConfig) {
|
||||
const profile = await this.providerRef.deref()?.providerSettingsManager.getProfile({
|
||||
id: condensingApiConfigId,
|
||||
|
|
@ -923,6 +931,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
return
|
||||
}
|
||||
await this.overwriteApiConversationHistory(messages)
|
||||
|
||||
// Set flag to skip previous_response_id on the next API call after manual condense
|
||||
this.skipPrevResponseIdOnce = true
|
||||
|
||||
const contextCondense: ContextCondense = { summary, cost, newContextTokens, prevContextTokens }
|
||||
await this.say(
|
||||
"condense_context",
|
||||
|
|
@ -1000,7 +1012,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
lastMessage.partial = false
|
||||
lastMessage.progressStatus = progressStatus
|
||||
if (options.metadata) {
|
||||
;(lastMessage as any).metadata = options.metadata
|
||||
// Add metadata to the message
|
||||
const messageWithMetadata = lastMessage as ClineMessage & ClineMessageWithMetadata
|
||||
if (!messageWithMetadata.metadata) {
|
||||
messageWithMetadata.metadata = {}
|
||||
}
|
||||
Object.assign(messageWithMetadata.metadata, options.metadata)
|
||||
}
|
||||
|
||||
// Instead of streaming partialMessage events, we do a save
|
||||
|
|
@ -1098,7 +1115,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
|
||||
let imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images)
|
||||
|
||||
console.log(`[subtasks] task ${this.taskId}.${this.instanceId} starting`)
|
||||
// Task starting
|
||||
|
||||
await this.initiateTaskLoop([
|
||||
{
|
||||
|
|
@ -1137,6 +1154,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
}
|
||||
|
||||
private async resumeTaskFromHistory() {
|
||||
// Resuming task from history
|
||||
|
||||
if (this.enableTaskBridge) {
|
||||
try {
|
||||
this.bridgeService = this.bridgeService || ExtensionBridgeService.getInstance()
|
||||
|
|
@ -1153,6 +1172,18 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
|
||||
const modifiedClineMessages = await this.getSavedClineMessages()
|
||||
|
||||
// Check for any stored GPT-5 response IDs in the message history
|
||||
const gpt5Messages = modifiedClineMessages.filter(
|
||||
(m): m is ClineMessage & ClineMessageWithMetadata =>
|
||||
m.type === "say" &&
|
||||
m.say === "text" &&
|
||||
!!(m as ClineMessageWithMetadata).metadata?.gpt5?.previous_response_id,
|
||||
)
|
||||
if (gpt5Messages.length > 0) {
|
||||
const lastGpt5Message = gpt5Messages[gpt5Messages.length - 1]
|
||||
// The lastGpt5Message contains the previous_response_id that can be used for continuity
|
||||
}
|
||||
|
||||
// Remove any resume messages that may have been added before
|
||||
const lastRelevantMessageIndex = findLastIndex(
|
||||
modifiedClineMessages,
|
||||
|
|
@ -1382,12 +1413,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
|
||||
await this.overwriteApiConversationHistory(modifiedApiConversationHistory)
|
||||
|
||||
console.log(`[subtasks] task ${this.taskId}.${this.instanceId} resuming from history item`)
|
||||
// Task resuming from history item
|
||||
|
||||
await this.initiateTaskLoop(newUserContent)
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
// Disposing task
|
||||
console.log(`[Task] disposing task ${this.taskId}.${this.instanceId}`)
|
||||
|
||||
// Remove all event listeners to prevent memory leaks
|
||||
|
|
@ -1458,7 +1490,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
}
|
||||
|
||||
public async abortTask(isAbandoned = false) {
|
||||
console.log(`[subtasks] aborting task ${this.taskId}.${this.instanceId}`)
|
||||
// Aborting task
|
||||
|
||||
// Will stop any autonomously running promises.
|
||||
if (isAbandoned) {
|
||||
|
|
@ -2331,8 +2363,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
let condensingApiHandler: ApiHandler | undefined
|
||||
|
||||
if (condensingApiConfigId && listApiConfigMeta && Array.isArray(listApiConfigMeta)) {
|
||||
// Using type assertion for the id property to avoid implicit any.
|
||||
const matchingConfig = listApiConfigMeta.find((config: any) => config.id === condensingApiConfigId)
|
||||
// Find matching config by ID
|
||||
const matchingConfig = listApiConfigMeta.find((config) => config.id === condensingApiConfigId)
|
||||
|
||||
if (matchingConfig) {
|
||||
const profile = await this.providerRef.deref()?.providerSettingsManager.getProfile({
|
||||
|
|
@ -2455,25 +2487,29 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
// Find the last assistant message that has a previous_response_id stored
|
||||
const idx = findLastIndex(
|
||||
this.clineMessages,
|
||||
(m) =>
|
||||
(m): m is ClineMessage & ClineMessageWithMetadata =>
|
||||
m.type === "say" &&
|
||||
(m as any).say === "text" &&
|
||||
(m as any).metadata?.gpt5?.previous_response_id,
|
||||
m.say === "text" &&
|
||||
!!(m as ClineMessageWithMetadata).metadata?.gpt5?.previous_response_id,
|
||||
)
|
||||
if (idx !== -1) {
|
||||
// Use the previous_response_id from the last assistant message for this request
|
||||
previousResponseId = ((this.clineMessages[idx] as any).metadata.gpt5.previous_response_id ||
|
||||
undefined) as string | undefined
|
||||
const message = this.clineMessages[idx] as ClineMessage & ClineMessageWithMetadata
|
||||
previousResponseId = message.metadata?.gpt5?.previous_response_id
|
||||
}
|
||||
} else if (this.skipPrevResponseIdOnce) {
|
||||
// Skipping previous_response_id due to recent condense operation - will send full conversation context
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error(`[Task#${this.taskId}] Error retrieving GPT-5 response ID:`, error)
|
||||
// non-fatal
|
||||
}
|
||||
|
||||
const metadata: ApiHandlerCreateMessageMetadata = {
|
||||
mode: mode,
|
||||
taskId: this.taskId,
|
||||
...(previousResponseId ? { previousResponseId } : {}),
|
||||
// Only include previousResponseId if we're NOT suppressing it
|
||||
...(previousResponseId && !this.skipPrevResponseIdOnce ? { previousResponseId } : {}),
|
||||
// If a condense just occurred, explicitly suppress continuity fallback for the next call
|
||||
...(this.skipPrevResponseIdOnce ? { suppressPreviousResponseId: true } : {}),
|
||||
}
|
||||
|
|
@ -2650,22 +2686,28 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
const modelId = this.api.getModel().id
|
||||
if (!modelId || !modelId.startsWith("gpt-5")) return
|
||||
|
||||
const lastResponseId: string | undefined = (this.api as any)?.getLastResponseId?.()
|
||||
// Check if the API handler has a getLastResponseId method (OpenAiNativeHandler specific)
|
||||
const handler = this.api as ApiHandler & { getLastResponseId?: () => string | undefined }
|
||||
const lastResponseId = handler.getLastResponseId?.()
|
||||
const idx = findLastIndex(
|
||||
this.clineMessages,
|
||||
(m) => m.type === "say" && (m as any).say === "text" && m.partial !== true,
|
||||
(m) => m.type === "say" && m.say === "text" && m.partial !== true,
|
||||
)
|
||||
if (idx !== -1) {
|
||||
const msg = this.clineMessages[idx] as any
|
||||
msg.metadata = msg.metadata ?? {}
|
||||
msg.metadata.gpt5 = {
|
||||
const msg = this.clineMessages[idx] as ClineMessage & ClineMessageWithMetadata
|
||||
if (!msg.metadata) {
|
||||
msg.metadata = {}
|
||||
}
|
||||
const gpt5Metadata: Gpt5Metadata = {
|
||||
...(msg.metadata.gpt5 ?? {}),
|
||||
previous_response_id: lastResponseId,
|
||||
instructions: this.lastUsedInstructions,
|
||||
reasoning_summary: (reasoningMessage ?? "").trim() || undefined,
|
||||
}
|
||||
msg.metadata.gpt5 = gpt5Metadata
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error(`[Task#${this.taskId}] Error persisting GPT-5 metadata:`, error)
|
||||
// Non-fatal error in metadata persistence
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1615,6 +1615,69 @@ describe("Cline", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("Conversation continuity after condense and deletion", () => {
|
||||
it("should set suppressPreviousResponseId when last message is condense_context", async () => {
|
||||
// Arrange: create task
|
||||
const task = new Task({
|
||||
provider: mockProvider,
|
||||
apiConfiguration: mockApiConfig,
|
||||
task: "initial task",
|
||||
startTask: false,
|
||||
})
|
||||
|
||||
// Ensure provider state returns required fields for attemptApiRequest
|
||||
mockProvider.getState = vi.fn().mockResolvedValue({
|
||||
apiConfiguration: mockApiConfig,
|
||||
})
|
||||
|
||||
// Simulate deletion that leaves a condense_context as the last message
|
||||
const condenseMsg = {
|
||||
ts: Date.now(),
|
||||
type: "say" as const,
|
||||
say: "condense_context" as const,
|
||||
contextCondense: {
|
||||
summary: "summarized",
|
||||
cost: 0.001,
|
||||
prevContextTokens: 1200,
|
||||
newContextTokens: 400,
|
||||
},
|
||||
}
|
||||
await task.overwriteClineMessages([condenseMsg])
|
||||
|
||||
// Spy and return a minimal successful stream to exercise attemptApiRequest
|
||||
const mockStream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield { type: "text", text: "ok" }
|
||||
},
|
||||
async next() {
|
||||
return { done: true, value: { type: "text", text: "ok" } }
|
||||
},
|
||||
async return() {
|
||||
return { done: true, value: undefined }
|
||||
},
|
||||
async throw(e: any) {
|
||||
throw e
|
||||
},
|
||||
[Symbol.asyncDispose]: async () => {},
|
||||
} as AsyncGenerator<ApiStreamChunk>
|
||||
|
||||
const createMessageSpy = vi.spyOn(task.api, "createMessage").mockReturnValue(mockStream)
|
||||
|
||||
// Act: initiate an API request
|
||||
const iterator = task.attemptApiRequest(0)
|
||||
await iterator.next() // read first chunk to ensure call happened
|
||||
|
||||
// Assert: metadata includes suppressPreviousResponseId set to true
|
||||
expect(createMessageSpy).toHaveBeenCalled()
|
||||
const callArgs = createMessageSpy.mock.calls[0]
|
||||
// Args: [systemPrompt, cleanConversationHistory, metadata]
|
||||
const metadata = callArgs?.[2]
|
||||
expect(metadata?.suppressPreviousResponseId).toBe(true)
|
||||
|
||||
// The skip flag should be reset after the call
|
||||
expect((task as any).skipPrevResponseIdOnce).toBe(false)
|
||||
})
|
||||
})
|
||||
describe("abortTask", () => {
|
||||
it("should set abort flag and emit TaskAborted event", async () => {
|
||||
const task = new Task({
|
||||
|
|
|
|||
37
src/core/task/types.ts
Normal file
37
src/core/task/types.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* Type definitions for Task-related metadata
|
||||
*/
|
||||
|
||||
/**
|
||||
* GPT-5 specific metadata stored with assistant messages
|
||||
* for maintaining conversation continuity across requests
|
||||
*/
|
||||
export interface Gpt5Metadata {
|
||||
/**
|
||||
* The response ID from the previous GPT-5 API response
|
||||
* Used to maintain conversation continuity in subsequent requests
|
||||
*/
|
||||
previous_response_id?: string
|
||||
|
||||
/**
|
||||
* The system instructions/prompt used for this response
|
||||
* Stored to track what instructions were active when the response was generated
|
||||
*/
|
||||
instructions?: string
|
||||
|
||||
/**
|
||||
* The reasoning summary from GPT-5's reasoning process
|
||||
* Contains the model's internal reasoning if reasoning mode was enabled
|
||||
*/
|
||||
reasoning_summary?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended ClineMessage type with GPT-5 metadata
|
||||
*/
|
||||
export interface ClineMessageWithMetadata {
|
||||
metadata?: {
|
||||
gpt5?: Gpt5Metadata
|
||||
[key: string]: any
|
||||
}
|
||||
}
|
||||
|
|
@ -462,7 +462,7 @@
|
|||
"node-cache": "^5.1.2",
|
||||
"node-ipc": "^12.0.0",
|
||||
"ollama": "^0.5.17",
|
||||
"openai": "^5.0.0",
|
||||
"openai": "^5.12.2",
|
||||
"os-name": "^6.0.0",
|
||||
"p-limit": "^6.2.0",
|
||||
"p-wait-for": "^5.0.2",
|
||||
|
|
|
|||
|
|
@ -743,11 +743,13 @@ const ApiOptions = ({
|
|||
fuzzyMatchThreshold={apiConfiguration.fuzzyMatchThreshold}
|
||||
onChange={(field, value) => setApiConfigurationField(field, value)}
|
||||
/>
|
||||
<TemperatureControl
|
||||
value={apiConfiguration.modelTemperature}
|
||||
onChange={handleInputChange("modelTemperature", noTransform)}
|
||||
maxValue={2}
|
||||
/>
|
||||
{selectedModelInfo?.supportsTemperature !== false && (
|
||||
<TemperatureControl
|
||||
value={apiConfiguration.modelTemperature}
|
||||
onChange={handleInputChange("modelTemperature", noTransform)}
|
||||
maxValue={2}
|
||||
/>
|
||||
)}
|
||||
<RateLimitSecondsControl
|
||||
value={apiConfiguration.rateLimitSeconds || 0}
|
||||
onChange={(value) => setApiConfigurationField("rateLimitSeconds", value)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue