Files
windmill/frontend/src/lib/components/copilot/chat/chatLoop.ts
T
hugocasaandClaude Opus 4.8 7a139ab23e feat(ai-chat): image attachments and agent raw-app screenshots (#10130)
* feat(ai-chat): add image attachments and agent raw-app screenshots

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(ai-chat): generalise take_screenshot fidelity caveat

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): keep compaction boundary on a displayed user message

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(raw-apps): count line boxes by vertical overlap, not rect count

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): enforce vision gating and bound image attachments

Refuse images on known text-only models instead of warning and sending them
anyway; cap input bytes before decode; keep clipboard text when it accompanies
a bitmap; don't queue a message whose images can't ride the plain-text queue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* perf(ai-chat): trim take_screenshot schema and shrink the card's copy

Move the fidelity caveat from the tool def onto the tool result: the def is
re-sent every global iteration (~258 tok), while the caveat only matters once a
capture exists. Keep a downscaled copy in displayMessages when it is actually
smaller — those are never compacted and are re-cloned on every saveChat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): carry attached images through the message queue

Enter during a streaming turn queued the text and silently dropped the images,
so the auto-send was not the message the user submitted. The queue now holds
both, moved together via takeQueue/clearQueue/restoreQueue so none of the three
flush sites, the dequeue-to-composer path, or the two conversation-switch drops
can leak one without the other.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): gate screenshots on vision, narrow when the tool fires

take_screenshot buffered an image unconditionally, so a text-only model got an
image_url and rejected the turn; the attach-time check never covered it, nor a
model switched after attaching. Gate before capture and again at send.

Only reach for the tool when the user raises how the app looks, rather than
after every UI edit.

A collapsed preview keeps the iframe mounted at zero width, passing the ready
checks and then failing inside the rasteriser as '[object Event]'. Name it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): hold sending while attachments decode

addImages read the free-slot count before its await and appended after it, so a
send during the ~50-800ms decode cleared images while the closure still wrote to
them, landing the picture on the following message; two drops also claimed the
same slots and could pass the cap. Reserve slots up front, block sending until
they resolve, and show a placeholder so the held send is explained.

Keep only a bounded copy in the transcript: displayMessages are never compacted
and are re-cloned on every save. Measured 6.1x smaller per attachment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): route screenshots to the visible tab, resend full-res on retry

Every mounted raw-app editor claimed the runtime's single screenshot slot, so
take_screenshot could capture a background tab's app; ownership now follows the
visible tab and only the owner releases it.

restartGeneration resent displayMessages' images, which became a 384px thumbnail
when the transcript copy was bounded — retries downgraded the model's own input.
Recover the sent parts from the API message instead.

Move modelSupportsVision to modelConfig: it was untestable behind lib.ts's monaco
import chain, and the denylist missed bundled text-only defaults (Groq/Together
Llama 3.3, Foundry Phi-4 and Mistral-Large). Llama 3.2 and Phi-4 split by variant,
so both are matched narrowly. Pinned against the shipped defaultModels.

Decode attachments one at a time and derive the preview from the bounded copy: a
12MP bitmap is ~48MB and the batch was held live at once, decoded twice each.

The attach tooltip claimed nothing is uploaded, which is untrue for images.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): keep images out of text-only turns and bound the queue

The vision gate only dropped the current turn's images, so history's image parts
still went out after a switch to a text-only model and failed the request; strip
the outbound copy instead, leaving history intact for a switch back.

queueMessage had no cap, and each queued send clears the composer for another
eight, so repeated sends stacked an unbounded batch into one message.

Editing a message resent displayMessages' bounded copy, downgrading the model's
own input; retries recovered the full-size one but then re-persisted it at full
resolution. storedImages pairs the API message with its transcript entry so both
paths resend the original and re-persist the bounded copy.

Reserve image slots before awaiting text attachments: the gap left sending
enabled with an image pending, measured ~90ms for a 40-file drop, now ~8ms
regardless of batch size.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): treat deepseek-v4 as text-only

deepseek-v4-pro ships as a bundled default and the gate let images through to
it, so an attachment would fail the turn. DeepSeek's vision line is deepseek-vl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): drop a rejected image instead of wedging the conversation

A provider that refuses an image leaves it in history, so every later turn
resends it and fails identically: the chat is stuck until the user edits the
message or starts over, and Retry re-sends the same image. The vision gate only
knows the models we ship, so this is the net for the rest. Strip the parts on an
image-related rejection and say so; unrelated failures keep the image.

Verified at the wire that no provider rejects a base64 data URL: anthropic
(source.base64), openai/gpt-4o (input_image), googleai and aws_bedrock/claude
(image_url passthrough) all 200 and read the image.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): match text-only models exactly, from provider API docs

The gate guessed by substring over model names, which answers the wrong
question. What matters is whether a provider's API accepts image parts, not
whether the model can see: DeepSeek V4 ships vision in its chat product that its
API has no content type for, and o3-mini gained vision in ChatGPT the API never
exposed. Neither is inferable from a name.

Substrings also block working models. 'mistral-large' matches Mistral Large 3,
which takes images; 'phi-4' matches Phi-4-multimodal, which does too. A wrong
entry blocks with no override, while a missing one costs a turn and recovers via
the rejection path, so the list is now exact ids only, each backed by a provider
doc.

Verdicts verified against provider API docs rather than recall. Live-checked
where a doc was contradicted: Bedrock's compatibility matrix claims no Anthropic
model is served over chat completions, but it serves images fine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): stop retry resurrecting a rejected image

The rejection fallback strips the image from history but leaves the bubble's
thumbnail so the user can still see what they sent. storedImages fell back to
that thumbnail when the API message had no parts, so Retry re-attached the very
image the provider had just refused and failed identically — the conversation
stayed wedged through the one control offered to escape it.

Found by retrying in the UI; unit tests, wire tests and four review passes all
missed it, since it only exists between two separate fixes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): harden image rejection recovery and drop-path attachment

* fix(ai-chat): fix image drop race, mid-turn vision gate, retry aliasing

* fix(ai-chat): key vision denylist by provider, flatten alpha before jpeg

* feat(ai-chat): offer take_screenshot on chromium only, ask for one elsewhere

* feat(ai-chat): image-only sends and click-to-expand image previews

* fix(ai-chat): capture screenshots at 2x and expand tool images full-res

* feat(frontend): expandable image previews in composer and result views

* fix(ai-chat): image-only send edge cases from review round

* fix(ai-chat): keep image-only drafts on rollback, track failing model id

* fix(ai-chat): gate rejection recovery on the failing iteration's model

* refactor(ai-chat): record iteration model via onBeforeIteration, trim tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): restore composer draft when beforeSend preflight fails

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): bound cumulative outbound image bytes per request

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): make the image byte bound part-granular so over-cap turns keep a subset

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): evict newest-first within a message in the image byte bound

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): prune over-cap images from stored history, not just requests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): bound history at every save boundary, keep thumbnail pairing across eviction

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): slot-align storedImages so the bubble expands the right image after eviction

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): match rejection keywords as whole words so provisioning errors keep images

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): match input_image rejections, restore images refused by non-GLOBAL modes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): defer non-GLOBAL image refusal restore past the composer clear

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): persist full tool screenshots for post-reload expansion

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(ai-chat): persist chat images out-of-band via blob-store refs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): scope image blobs per chat and stop cap-eviction rotation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): keep blob-cap chronology across drop-oldest compaction

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(ai-chat): derive blob eviction from the saved record, not write times

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): serialize chat history DB writes per manager

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): pin queued history writes to the enqueue-time user database

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): delete stale image blobs only after the chat record commits

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): don't double-restore a queued image-only draft on vision refusal

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): label image-only chats and evicted image-only bubbles

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): keep the in-memory chat mirror hydrated for DB-less sessions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): converge the chat mirror to refs after a successful DB commit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): guard mirror convergence against rewinding newer saves

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): invalidate pending convergences on identity re-init, keep retry image names

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai-chat): bound the screenshot raster before rasterization

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(ai-chat): drop the no-IndexedDB in-memory image fallback

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 19:36:51 +02:00

527 lines
17 KiB
TypeScript

import OpenAI from 'openai'
import Anthropic from '@anthropic-ai/sdk'
import type {
ChatCompletionMessageParam,
ChatCompletionSystemMessageParam,
ChatCompletionUserMessageParam
} from 'openai/resources/chat/completions.mjs'
import { getCompletion, parseOpenAICompletion, providerSupportsWebSearch } from '../lib'
import {
resolveEffectiveReasoning,
resolveRequestReasoning,
type ReasoningProviderModel
} from '../reasoningRegistry'
import { getAnthropicCompletion, parseAnthropicCompletion } from './anthropic'
import { modelSupportsVision, usesAnthropicMessagesApi } from '../modelConfig'
import { boundImagePartBytes, stripImagePartsFromMessages } from './imageUtils'
import { getOpenAIResponsesCompletion, parseOpenAIResponsesCompletion } from './openai-responses'
import type { Tool, ToolCallbacks } from './shared'
import { sanitizeToolCallArguments } from './toolCallArguments'
import { addChatTokenUsage, emptyChatTokenUsage, type ChatTokenUsage } from './tokenUsage'
export interface ChatClients {
openai: OpenAI
anthropic: Anthropic
}
export interface ChatLoopConfig {
messages: ChatCompletionMessageParam[]
/**
* System message, tools, helpers, and modelProvider are re-read from this config
* on every iteration. Callers can use JS getters to provide dynamic values
* (e.g. AIChatManager uses getters so mode changes mid-loop take effect).
*/
systemMessage: ChatCompletionSystemMessageParam
tools: Tool<any>[]
helpers: any
abortController: AbortController
callbacks: ToolCallbacks & {
onNewToken: (token: string) => void
onMessageEnd: () => void
}
modelProvider: ReasoningProviderModel
clients: ChatClients
workspace: string
/**
* Enable provider-native web search. Defaults to true for compatible providers
* and is re-read each iteration so model/provider changes take effect. Explicit
* true is still ignored for providers without native web search support.
*/
webSearch?: boolean
/** Maximum iterations for the loop. undefined = unlimited (production). */
maxIterations?: number
skipResponsesApi?: boolean
onSkipResponsesApi?: () => void
onWebSearchUnavailable?: () => void
/**
* Called when the provider refuses to generate reasoning summaries (OpenAI
* gates them behind organization verification). The request is retried
* without summaries, so reasoning happens but stays hidden.
*/
onReasoningSummaryUnavailable?: () => void
/** Return a pending user message to inject between iterations, or undefined. */
getPendingUserMessage?: () => ChatCompletionUserMessageParam | undefined
/**
* Optional caller-owned accumulator for the messages produced this run —
* lets the caller recover partial output if the loop throws or is aborted.
*/
addedMessages?: ChatCompletionMessageParam[]
/** Called before each iteration (e.g. to refresh tool schemas, or to record
* which model the iteration is about to use). */
onBeforeIteration?: (
tools: Tool<any>[],
helpers: any,
modelProvider: ReasoningProviderModel
) => Promise<void>
}
export interface ChatLoopResult {
addedMessages: ChatCompletionMessageParam[]
/** Sum of usage across all loop iterations (suitable for cost accounting). */
tokenUsage: ChatTokenUsage
lastIterationUsage: ChatTokenUsage | null
hitMaxIterations: boolean
}
/**
* Returns the longest prefix of `messages` that forms a valid request sequence:
* every assistant `tool_calls` batch must be fully answered by following tool
* messages before the next assistant turn. Used to commit the partial output of
* an aborted or failed turn as context for a follow-up, without leaving a
* dangling tool_call (which the provider APIs reject on the next request).
*/
export function truncateToToolPairedPrefix(
messages: ChatCompletionMessageParam[]
): ChatCompletionMessageParam[] {
let lastValidLen = 0
let pending = new Set<string>()
for (let i = 0; i < messages.length; i++) {
const m = messages[i]
if (m.role === 'assistant') {
// A new assistant turn while the previous tool batch is unanswered would
// be invalid — stop at the last known-good boundary.
if (pending.size > 0) break
const toolCalls = m.tool_calls ?? []
if (toolCalls.length === 0) {
lastValidLen = i + 1
} else {
pending = new Set(toolCalls.map((c) => c.id))
}
} else if (m.role === 'tool') {
pending.delete(m.tool_call_id)
// Boundary is valid only once every tool_call in the batch is answered.
if (pending.size === 0) lastValidLen = i + 1
} else {
// user/system message: a valid boundary only if no tool calls are pending.
if (pending.size > 0) break
lastValidLen = i + 1
}
}
return messages.slice(0, lastValidLen)
}
const unsupportedWebSearchCache = new Set<string>()
const WEB_SEARCH_UNAVAILABLE_STATUS_CODES = new Set([400, 403, 404])
// Reasoning-summary availability is an org-level property of the provider
// credentials (OpenAI organization verification), not of the model — key by
// workspace + provider. In-memory on purpose: a page reload re-probes, so a
// freshly verified organization starts getting summaries again.
const unsupportedReasoningSummaryCache = new Set<string>()
const REASONING_SUMMARY_UNAVAILABLE_STATUS_CODES = new Set([400, 403])
function getWebSearchCacheKey(workspace: string, modelProvider: ReasoningProviderModel): string {
return [workspace, modelProvider.provider, modelProvider.model].join(':')
}
function getReasoningSummaryCacheKey(
workspace: string,
modelProvider: ReasoningProviderModel
): string {
return [workspace, modelProvider.provider].join(':')
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function appendTextPart(parts: string[], value: unknown) {
if (typeof value === 'string' && value.trim()) {
parts.push(value)
}
}
function getErrorText(err: unknown): string {
const parts: string[] = []
if (err instanceof Error) {
appendTextPart(parts, err.message)
}
if (typeof err === 'string') {
appendTextPart(parts, err)
}
if (isRecord(err)) {
appendTextPart(parts, err.message)
appendTextPart(parts, err.type)
appendTextPart(parts, err.code)
appendTextPart(parts, err.param)
const nested = err.error
if (isRecord(nested)) {
appendTextPart(parts, nested.message)
appendTextPart(parts, nested.type)
appendTextPart(parts, nested.code)
appendTextPart(parts, nested.param)
} else {
appendTextPart(parts, nested)
}
}
if (parts.length > 0) {
return parts.join(' ')
}
try {
return JSON.stringify(err)
} catch {
return String(err)
}
}
function getErrorStatus(err: unknown): number | undefined {
if (!isRecord(err)) {
return undefined
}
const candidates = [err.status]
if (isRecord(err.response)) {
candidates.push(err.response.status)
}
if (isRecord(err.error)) {
candidates.push(err.error.status)
}
return candidates.find((status): status is number => typeof status === 'number')
}
function hasWebSearchUnavailableSignal(err: unknown): boolean {
const message = getErrorText(err).toLowerCase()
const webSearchTerm = '(?:web[_ -]?search|web search|web-search)'
const unavailableTerm =
'(?:not supported|unsupported|not available|unavailable|disabled|not enabled|enable web search|forbidden|not permitted|permission|policy|blocked|access)'
const patterns = [
new RegExp(`${webSearchTerm}.*${unavailableTerm}`),
new RegExp(`${unavailableTerm}.*${webSearchTerm}`),
/\bmust\s+enable\s+web[_ -]?search\b/,
/\bweb search options\b.*\bnot supported\b/,
/\bhosted tools?\b.*\b(?:not supported|unsupported)\b/,
/\bhosted tool ['"]web_search(?:_preview)?['"].*\b(?:not supported|unsupported)\b/
]
return patterns.some((pattern) => pattern.test(message))
}
function shouldRetryWithoutWebSearch(err: unknown): boolean {
if (!hasWebSearchUnavailableSignal(err)) {
return false
}
const status = getErrorStatus(err)
return status === undefined || WEB_SEARCH_UNAVAILABLE_STATUS_CODES.has(status)
}
function getErrorParam(err: unknown): string | undefined {
if (!isRecord(err)) {
return undefined
}
const candidates = [err.param]
if (isRecord(err.error)) {
candidates.push(err.error.param)
}
return candidates.find((param): param is string => typeof param === 'string')
}
// Unverified OpenAI organizations get a 400 on the reasoning.summary param
// ("Your organization must be verified to generate reasoning summaries").
function shouldRetryWithoutReasoningSummary(err: unknown): boolean {
const status = getErrorStatus(err)
if (status !== undefined && !REASONING_SUMMARY_UNAVAILABLE_STATUS_CODES.has(status)) {
return false
}
if (getErrorParam(err) === 'reasoning.summary') {
return true
}
const message = getErrorText(err).toLowerCase()
return (
message.includes('reasoning.summary') ||
/verified to (?:generate|stream) reasoning summar/.test(message)
)
}
function markReasoningSummaryUnsupported(
cacheKey: string,
err: unknown,
onReasoningSummaryUnavailable?: () => void
) {
unsupportedReasoningSummaryCache.add(cacheKey)
console.warn('Reasoning summaries unavailable; retrying without them:', err)
onReasoningSummaryUnavailable?.()
}
function markWebSearchUnsupported(
cacheKey: string,
err: unknown,
onWebSearchUnavailable?: () => void
) {
unsupportedWebSearchCache.add(cacheKey)
console.warn('Native web search unavailable; retrying without web search:', err)
onWebSearchUnavailable?.()
}
export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResult> {
const {
messages,
abortController,
callbacks,
clients,
workspace,
maxIterations,
onSkipResponsesApi,
onReasoningSummaryUnavailable,
getPendingUserMessage,
onBeforeIteration
} = config
let skipResponsesApi = config.skipResponsesApi ?? false
const addedMessages: ChatCompletionMessageParam[] = config.addedMessages ?? []
let tokenUsage = emptyChatTokenUsage()
let lastIterationUsage: ChatTokenUsage | null = null
let iterations = 0
let hitMaxIterations = false
const trackUsage = (usage: ChatTokenUsage | null | undefined) => {
tokenUsage = addChatTokenUsage(tokenUsage, usage)
// Some providers/paths report no usage (prompt 0); keep the last real one.
if (usage && usage.prompt > 0) {
lastIterationUsage = usage
}
}
while (true) {
if (maxIterations !== undefined && iterations >= maxIterations) {
hitMaxIterations = true
break
}
iterations++
// Re-read these from config each iteration so that mode changes
// (e.g. changeModeTool in Navigator) take effect immediately.
// Callers can use JS getter properties to provide dynamic values.
const tools = config.tools
const helpers = config.helpers
const systemMessage = config.systemMessage
const modelProvider = config.modelProvider
const webSearchCacheKey = getWebSearchCacheKey(workspace, modelProvider)
const webSearch =
(config.webSearch ?? true) &&
providerSupportsWebSearch(modelProvider.provider) &&
!unsupportedWebSearchCache.has(webSearchCacheKey)
if (onBeforeIteration) {
await onBeforeIteration(tools, helpers, modelProvider)
}
const pendingUserMessage = getPendingUserMessage?.()
const isOpenAI =
modelProvider.provider === 'openai' || modelProvider.provider === 'azure_openai'
const isAnthropic = usesAnthropicMessagesApi(modelProvider.provider, modelProvider.model)
// Resolve effort once in chat context (applies the default-on level for
// capable models, and the provider-native disable token for an explicit
// off on reasoning-by-default providers); passed explicitly to each seam
// so background paths (metadata/autocomplete) never inherit it.
const reasoningEffort = resolveRequestReasoning(modelProvider)
// Checked per iteration, like the model itself: the selector stays enabled
// while the loop runs, and a switch to a known text-only model mid-turn
// would otherwise send it the history's image parts and fail the turn.
// The byte bound is also per iteration because screenshots taken by tools
// grow the history mid-loop (see MAX_TOTAL_IMAGE_BYTES).
const visibleMessages = modelSupportsVision(modelProvider.provider, modelProvider.model)
? boundImagePartBytes(messages)
: stripImagePartsFromMessages(messages)
const messageParams = [
systemMessage,
...sanitizeToolCallArguments(visibleMessages),
...(pendingUserMessage ? [pendingUserMessage] : [])
]
const toolDefs = tools.map((t) => t.def)
const parseOptions = { workspace, provider: modelProvider.provider }
if (isOpenAI) {
const reasoningSummaryCacheKey = getReasoningSummaryCacheKey(workspace, modelProvider)
// Gate on the effective (not request) reasoning: an explicit off resolves
// to a truthy disable token like 'none' on the request side, and asking
// for a summary on a non-reasoning request would 400 on unverified orgs.
let reasoningSummary =
resolveEffectiveReasoning(modelProvider) !== undefined &&
!unsupportedReasoningSummaryCache.has(reasoningSummaryCacheKey)
const runOpenAIResponses = async (useWebSearch: boolean): Promise<boolean> => {
const completion = await getOpenAIResponsesCompletion(
messageParams,
abortController,
toolDefs,
{
forceModelProvider: modelProvider,
openaiClient: clients.openai,
webSearch: useWebSearch,
reasoningEffort,
reasoningSummary
}
)
const continueCompletion = await parseOpenAIResponsesCompletion(
completion,
callbacks,
messages,
addedMessages,
tools,
helpers,
parseOptions
)
trackUsage(continueCompletion.tokenUsage)
return continueCompletion.shouldContinue
}
let useCompletionsApi = skipResponsesApi
if (!skipResponsesApi) {
// Retry the Responses call disabling whichever optional feature the
// provider rejected (reasoning summary, web search) in the order the
// errors arrive — a turn can hit both, either one first. Each retry
// permanently disables one feature, so this loops at most twice.
let useWebSearch = webSearch
let outcome: 'break' | 'continue' | undefined
let fallbackError: unknown
while (outcome === undefined) {
try {
outcome = (await runOpenAIResponses(useWebSearch)) ? 'continue' : 'break'
} catch (err) {
if (reasoningSummary && shouldRetryWithoutReasoningSummary(err)) {
markReasoningSummaryUnsupported(
reasoningSummaryCacheKey,
err,
onReasoningSummaryUnavailable
)
reasoningSummary = false
} else if (useWebSearch && shouldRetryWithoutWebSearch(err)) {
markWebSearchUnsupported(webSearchCacheKey, err, config.onWebSearchUnavailable)
useWebSearch = false
} else {
fallbackError = err
break
}
}
}
if (outcome === 'break') {
break
}
if (outcome === 'continue') {
continue
}
console.warn('OpenAI Responses API failed, falling back to Completions API:', fallbackError)
const errorMessage = getErrorText(fallbackError)
if (errorMessage.includes('Responses API is not enabled')) {
skipResponsesApi = true
onSkipResponsesApi?.()
}
useCompletionsApi = true
}
if (useCompletionsApi) {
if (webSearch) {
console.warn(
'Web search is only supported via the OpenAI Responses API; ignoring it for the Completions API fallback.'
)
}
const completion = await getCompletion(messageParams, abortController, toolDefs, {
forceCompletions: true,
forceModelProvider: modelProvider,
openaiClient: clients.openai,
reasoningEffort
})
const continueCompletion = await parseOpenAICompletion(
completion,
callbacks,
messages,
addedMessages,
tools,
helpers,
undefined,
parseOptions
)
trackUsage(continueCompletion.tokenUsage)
if (!continueCompletion.shouldContinue) {
break
}
}
} else if (isAnthropic) {
const runAnthropic = async (useWebSearch: boolean): Promise<boolean> => {
const completion = await getAnthropicCompletion(messageParams, abortController, toolDefs, {
forceModelProvider: modelProvider,
anthropicClient: clients.anthropic,
webSearch: useWebSearch,
reasoningEffort
})
if (!completion) {
return true
}
const continueCompletion = await parseAnthropicCompletion(
completion,
callbacks,
messages,
addedMessages,
tools,
helpers,
abortController,
parseOptions
)
trackUsage(continueCompletion.tokenUsage)
return continueCompletion.shouldContinue
}
try {
if (!(await runAnthropic(webSearch))) {
break
}
} catch (err) {
if (webSearch && shouldRetryWithoutWebSearch(err)) {
markWebSearchUnsupported(webSearchCacheKey, err, config.onWebSearchUnavailable)
if (!(await runAnthropic(false))) {
break
}
} else {
throw err
}
}
} else {
const completion = await getCompletion(messageParams, abortController, toolDefs, {
forceModelProvider: modelProvider,
openaiClient: clients.openai,
reasoningEffort
})
if (completion) {
const continueCompletion = await parseOpenAICompletion(
completion,
callbacks,
messages,
addedMessages,
tools,
helpers,
undefined,
parseOptions
)
trackUsage(continueCompletion.tokenUsage)
if (!continueCompletion.shouldContinue) {
break
}
}
}
}
return { addedMessages, tokenUsage, lastIterationUsage, hitMaxIterations }
}