This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

niri / src / runner / loop-completion.ts
27 kB 768 lines
1import OpenAI from "openai" 2import { logMessage } from "../db" 3import { buildCompletionMessages, rememberRecalledMemoryChunks } from "../memory" 4import { recordMetric } from "../metrics" 5import { emit } from "../stream" 6import type { LoopState } from "./types" 7import { 8 API_BASE, 9 ENABLE_THINKING, 10 FALLBACK_BASE, 11 FALLBACK_MODEL, 12 FALLBACK_TOOL_CHOICE, 13 MODEL, 14 PRIMARY_TOOL_CHOICE, 15 SUMMARY_MODEL, 16 TOOLS, 17 USE_FALLBACK, 18 apiErrorDetails, 19 client, 20 errorSummary, 21 estimatePromptTokens, 22 fallbackClient, 23 fallbackContextWindow, 24 findSummaryMessageIndex, 25 isContentFilterError, 26 isImageParseError, 27 isPromptTooLargeError, 28 retryDelayMs, 29 sanitizeMessages, 30 scrubImagesFromConversation, 31 shouldFallback, 32 summaryClient, 33 summarizeConversationViaLLM, 34} from "./util" 35import { assistantContentText } from "./loop-content" 36import type { CompletionRequest, CompletionTurnResult, ToolCallAssembly } from "./loop-shared" 37 38/** 39 * Resolves the configured summary client/model pair. 40 * 41 * @returns Active summary provider config. 42 */ 43export function configuredSummaryProvider(): { client: OpenAI | null; model: string } { 44 if (summaryClient && SUMMARY_MODEL) return { client: summaryClient, model: SUMMARY_MODEL } 45 return { 46 client: USE_FALLBACK ? fallbackClient : client, 47 model: USE_FALLBACK ? FALLBACK_MODEL : MODEL, 48 } 49} 50 51function logApiError(err: unknown, context: string): void { 52 if (!(err instanceof OpenAI.APIError)) return 53 console.error(`[api] ${err.status} ${err.message} - ${context}`) 54 for (const line of apiErrorDetails(err)) console.error(line) 55} 56 57/** 58 * Appends an assistant message to state and persists it to conversation logs. 59 * 60 * @param convId - Active conversation id. 61 * @param state - Mutable loop state. 62 * @param msg - Assistant message to append. 63 */ 64export function addAssistantMessage(convId: number, state: LoopState, msg: OpenAI.Chat.ChatCompletionMessage): void { 65 state.conversation.push(msg) 66 logMessage(convId, msg.role, msg.content ?? "", msg.tool_calls ?? undefined) 67} 68 69function recordPromptResponse(request: CompletionRequest, result: CompletionTurnResult, promptMetricId: number | null): void { 70 recordMetric({ 71 type: "prompt_response", 72 promptMetricId: promptMetricId ?? undefined, 73 model: request.model, 74 toolChoice: request.tool_choice, 75 messages: request.messages, 76 response: result.message, 77 usage: result.usage, 78 }) 79} 80 81/** 82 * Applies token usage from a completion response to loop state counters. 83 * 84 * @param state - Mutable loop state. 85 * @param usage - Completion usage payload (if provided by the API). 86 */ 87export function applyUsage(state: LoopState, usage: OpenAI.Completions.CompletionUsage | undefined): void { 88 if (!usage) return 89 state.tokenCount += usage.total_tokens 90 if (usage.prompt_tokens) state.contextSize = usage.prompt_tokens 91 console.log(`[tokens] +${usage.total_tokens} total=${state.tokenCount}`) 92 recordMetric({ type: "usage", usage }) 93} 94 95/** 96 * Emits model reasoning text when exposed by the provider. 97 * 98 * Supports both `reasoning_content` and `<think>...</think>` wrappers. 99 * 100 * @param msg - Assistant message to inspect for reasoning traces. 101 */ 102export function emitThinking(msg: OpenAI.Chat.ChatCompletionMessage): void { 103 const rawMsg = msg as unknown as Record<string, unknown> 104 let thinkingText: string | null = null 105 106 if (typeof rawMsg.reasoning_content === "string" && rawMsg.reasoning_content.trim()) { 107 thinkingText = rawMsg.reasoning_content.trim() 108 } else if (typeof msg.content === "string") { 109 const match = msg.content.match(/^<think>([\s\S]*?)<\/think>\s*/i) 110 if (match) { 111 thinkingText = match[1]!.trim() 112 ;(msg as unknown as Record<string, unknown>).content = msg.content.slice(match[0].length) 113 } 114 } 115 116 if (thinkingText) emit({ type: "thinking", text: thinkingText }) 117} 118 119function sleep(ms: number): Promise<void> { 120 return new Promise((resolve) => setTimeout(resolve, ms)) 121} 122 123function formatRetryAt(retryAfterMs: number): string { 124 const retryAt = new Date(Date.now() + retryAfterMs) 125 const local = retryAt.toLocaleString(undefined, { 126 hour12: false, 127 timeZoneName: "short", 128 }) 129 return `${local} (${retryAt.toISOString()})` 130} 131 132function apiErrorSearchText(err: { message: string; error?: unknown }): string { 133 const parts = [err.message] 134 if (err.error !== undefined) { 135 try { 136 parts.push(JSON.stringify(err.error)) 137 } catch { 138 parts.push(String(err.error)) 139 } 140 } 141 return parts.join("\n") 142} 143 144function shouldRetryWithAutoToolChoice(err: unknown): boolean { 145 if (!(err instanceof OpenAI.APIError)) return false 146 const text = apiErrorSearchText(err) 147 return /no endpoints found that support the provided 'tool_choice' value|does not support this tool_choice/i.test(text) 148} 149 150function shouldRetryWithoutReasoningForTools(err: unknown): boolean { 151 if (!(err instanceof OpenAI.APIError)) return false 152 return /function call should not be used with prefix/i.test(apiErrorSearchText(err)) 153} 154 155function shouldRetryWithReasoningEnabled(err: unknown): boolean { 156 if (!(err instanceof OpenAI.APIError)) return false 157 return /reasoning is mandatory|reasoning cannot be disabled|reasoning is required/i.test(apiErrorSearchText(err)) 158} 159 160function enableReasoningForRequest(request: CompletionRequest): CompletionRequest { 161 const next: CompletionRequest = { 162 ...request, 163 include_reasoning: true, 164 reasoning: { enabled: true, effort: "low" }, 165 } 166 delete next.enable_thinking 167 if (next.chat_template_kwargs) { 168 const { enable_thinking: _ignored, ...rest } = next.chat_template_kwargs 169 next.chat_template_kwargs = Object.keys(rest).length > 0 ? rest : undefined 170 if (!next.chat_template_kwargs) delete next.chat_template_kwargs 171 } 172 return next 173} 174 175function toolCompatibleReasoningExtras( 176 request?: Pick<CompletionRequest, "provider" | "chat_template_kwargs">, 177): Partial<CompletionRequest> { 178 return { 179 include_reasoning: false, 180 reasoning: { enabled: false, exclude: true, effort: "none" }, 181 provider: { ...request?.provider, require_parameters: true }, 182 } 183} 184 185function prefixModeToolCallExtras(request?: Pick<CompletionRequest, "provider" | "chat_template_kwargs">): Partial<CompletionRequest> { 186 return { 187 ...toolCompatibleReasoningExtras(request), 188 enable_thinking: false, 189 chat_template_kwargs: { 190 ...request?.chat_template_kwargs, 191 enable_thinking: false, 192 }, 193 } 194} 195 196function disableReasoningForToolCalls(request: CompletionRequest): CompletionRequest { 197 return { 198 ...request, 199 ...prefixModeToolCallExtras(request), 200 } 201} 202 203function configuredThinkingRequestExtras( 204 request?: Pick<CompletionRequest, "chat_template_kwargs">, 205): Partial<CompletionRequest> { 206 if (ENABLE_THINKING) return {} 207 return { 208 include_reasoning: false, 209 reasoning: { enabled: false, exclude: true, effort: "none" }, 210 enable_thinking: false, 211 chat_template_kwargs: { 212 ...request?.chat_template_kwargs, 213 enable_thinking: false, 214 }, 215 } 216} 217 218function openRouterToolRequestExtras(baseUrl: string): Partial<CompletionRequest> { 219 if (!baseUrl.includes("openrouter.ai")) return {} 220 return toolCompatibleReasoningExtras() 221} 222 223function shouldRetryWithoutStreamUsage(err: unknown): boolean { 224 if (!(err instanceof OpenAI.APIError)) return false 225 if (err.status !== 400) return false 226 return /stream_options|include_usage/i.test(err.message) 227} 228 229function coerceReasoningToolArgument(rawValue: string): unknown { 230 const value = rawValue.trim() 231 if (!value) return "" 232 if (/^true$/i.test(value)) return true 233 if (/^false$/i.test(value)) return false 234 if (/^null$/i.test(value)) return null 235 236 if (/^-?\d+(?:\.\d+)?$/.test(value)) { 237 const parsed = Number(value) 238 if (Number.isFinite(parsed)) return parsed 239 } 240 241 if ( 242 (value.startsWith("{") && value.endsWith("}")) || 243 (value.startsWith("[") && value.endsWith("]")) || 244 (value.startsWith('"') && value.endsWith('"')) 245 ) { 246 try { 247 return JSON.parse(value) 248 } catch { 249 // keep raw string fallback 250 } 251 } 252 253 return value 254} 255 256function parseReasoningToolCallBlock(rawBlock: string): ToolCallAssembly | null { 257 const functionMatch = rawBlock.match(/<function(?:=|\s+name\s*=\s*["']?)([^>"'\s/]+)["']?\s*>/i) 258 if (!functionMatch || functionMatch.index === undefined) return null 259 260 const functionName = functionMatch[1]?.trim() 261 if (!functionName) return null 262 263 const functionBodyStart = functionMatch.index + functionMatch[0].length 264 const functionBodyEnd = rawBlock.indexOf("</function>", functionBodyStart) 265 if (functionBodyEnd < 0) return null 266 267 const functionBody = rawBlock.slice(functionBodyStart, functionBodyEnd) 268 const args: Record<string, unknown> = {} 269 270 const parameterRegex = /<parameter(?:=|\s+name\s*=\s*["']?)([^>"'\s/]+)["']?\s*>([\s\S]*?)<\/parameter>/gi 271 for (const match of functionBody.matchAll(parameterRegex)) { 272 const key = match[1]?.trim() 273 if (!key) continue 274 args[key] = coerceReasoningToolArgument(match[2] ?? "") 275 } 276 277 return { 278 id: "", 279 type: "function", 280 function: { 281 name: functionName, 282 arguments: JSON.stringify(args), 283 }, 284 } 285} 286 287function drainReasoningToolCallBlocks(buffer: string): { blocks: string[]; remainder: string } { 288 const blocks: string[] = [] 289 let remaining = buffer 290 291 while (true) { 292 const openMatch = remaining.match(/<tool_call(?:\s[^>]*)?>/i) 293 if (!openMatch || openMatch.index === undefined) { 294 const partialStart = remaining.lastIndexOf("<tool_call") 295 return { 296 blocks, 297 remainder: partialStart >= 0 ? remaining.slice(partialStart) : "", 298 } 299 } 300 301 const openStart = openMatch.index 302 const openEnd = openStart + openMatch[0].length 303 const closeStart = remaining.indexOf("</tool_call>", openEnd) 304 if (closeStart < 0) { 305 return { 306 blocks, 307 remainder: remaining.slice(openStart), 308 } 309 } 310 311 blocks.push(remaining.slice(openEnd, closeStart)) 312 remaining = remaining.slice(closeStart + "</tool_call>".length) 313 } 314} 315 316async function consumeCompletionStream( 317 stream: AsyncIterable<OpenAI.Chat.ChatCompletionChunk>, 318): Promise<CompletionTurnResult> { 319 const contentParts: string[] = [] 320 const streamedToolCalls = new Map<number, ToolCallAssembly>() 321 const reasoningToolCalls: ToolCallAssembly[] = [] 322 let reasoningToolBuffer = "" 323 324 let usage: OpenAI.Completions.CompletionUsage | undefined 325 let emittedText = false 326 let emittedThinking = false 327 const reasoningParts: string[] = [] 328 329 for await (const chunk of stream) { 330 if (chunk.usage) usage = chunk.usage 331 332 const choice = chunk.choices[0] 333 if (!choice) continue 334 335 const delta = choice.delta as OpenAI.Chat.ChatCompletionChunk.Choice.Delta & { 336 reasoning_content?: string 337 } 338 339 if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) { 340 if (ENABLE_THINKING) reasoningParts.push(delta.reasoning_content) 341 342 reasoningToolBuffer += delta.reasoning_content 343 const { blocks, remainder } = drainReasoningToolCallBlocks(reasoningToolBuffer) 344 reasoningToolBuffer = remainder 345 346 for (const block of blocks) { 347 const parsedCall = parseReasoningToolCallBlock(block) 348 if (!parsedCall) continue 349 parsedCall.id = `call_reasoning_${reasoningToolCalls.length}` 350 reasoningToolCalls.push(parsedCall) 351 } 352 } 353 354 if (typeof delta.content === "string" && delta.content.length > 0) { 355 if (ENABLE_THINKING && !emittedThinking && reasoningParts.length > 0) { 356 emit({ type: "thinking", text: reasoningParts.join("") }) 357 emittedThinking = true 358 } 359 contentParts.push(delta.content) 360 emit({ type: "text", text: delta.content }) 361 emittedText = true 362 } 363 364 if (!Array.isArray(delta.tool_calls)) continue 365 366 for (const partial of delta.tool_calls) { 367 const index = partial.index ?? 0 368 const existing = streamedToolCalls.get(index) ?? { 369 id: partial.id ?? `call_${index}`, 370 type: "function" as const, 371 function: { name: "", arguments: "" }, 372 } 373 374 if (partial.id) existing.id = partial.id 375 if (partial.type === "function") existing.type = "function" 376 if (partial.function?.name) existing.function.name += partial.function.name 377 if (partial.function?.arguments) existing.function.arguments += partial.function.arguments 378 streamedToolCalls.set(index, existing) 379 } 380 } 381 382 if (streamedToolCalls.size === 0) { 383 const trailingReasoningCall = parseReasoningToolCallBlock(reasoningToolBuffer) 384 if (trailingReasoningCall) { 385 trailingReasoningCall.id = `call_reasoning_${reasoningToolCalls.length}` 386 reasoningToolCalls.push(trailingReasoningCall) 387 } 388 } 389 390 const finalToolCalls = 391 streamedToolCalls.size > 0 392 ? [...streamedToolCalls.entries()] 393 .sort((a, b) => a[0] - b[0]) 394 .map(([, toolCall]) => toolCall) 395 : reasoningToolCalls 396 397 const message: OpenAI.Chat.ChatCompletionMessage = { 398 role: "assistant", 399 content: contentParts.length > 0 ? contentParts.join("") : null, 400 refusal: null, 401 ...(finalToolCalls.length > 0 402 ? { 403 tool_calls: finalToolCalls, 404 } 405 : {}), 406 } 407 408 if (reasoningParts.length > 0) { 409 ;(message as OpenAI.Chat.ChatCompletionMessage & { reasoning_content?: string }).reasoning_content = 410 reasoningParts.join("") 411 } 412 413 return { 414 message, 415 usage, 416 emittedText, 417 emittedThinking, 418 bufferedThinking: reasoningParts.join(""), 419 } 420} 421 422async function createStreamedCompletion( 423 apiClient: OpenAI, 424 request: CompletionRequest, 425): Promise<CompletionTurnResult> { 426 const streamedRequest = { 427 ...request, 428 stream: true, 429 stream_options: { include_usage: true }, 430 } as const 431 432 const promptMetricId = recordMetric({ type: "prompt", messages: request.messages }) 433 434 try { 435 const stream = await apiClient.chat.completions.create(streamedRequest) 436 const result = await consumeCompletionStream(stream as AsyncIterable<OpenAI.Chat.ChatCompletionChunk>) 437 recordPromptResponse(request, result, promptMetricId) 438 return result 439 } catch (err) { 440 if (shouldRetryWithoutStreamUsage(err)) { 441 const stream = await apiClient.chat.completions.create({ 442 ...request, 443 stream: true, 444 } as const) 445 const result = await consumeCompletionStream(stream as AsyncIterable<OpenAI.Chat.ChatCompletionChunk>) 446 recordPromptResponse(request, result, promptMetricId) 447 return result 448 } 449 throw err 450 } 451} 452 453async function createFallbackCompletion(messages: OpenAI.Chat.ChatCompletionMessageParam[]): Promise<CompletionTurnResult> { 454 const request: CompletionRequest = { 455 model: FALLBACK_MODEL, 456 messages, 457 tools: TOOLS, 458 tool_choice: FALLBACK_TOOL_CHOICE, 459 ...openRouterToolRequestExtras(FALLBACK_BASE), 460 ...configuredThinkingRequestExtras(), 461 } 462 463 let currentRequest = request 464 let retriedAutoToolChoice = false 465 let retriedWithoutReasoning = false 466 let retriedWithReasoning = false 467 while (true) { 468 try { 469 return await createStreamedCompletion(fallbackClient, currentRequest) 470 } catch (err) { 471 if (currentRequest.tool_choice !== "auto" && !retriedAutoToolChoice && shouldRetryWithAutoToolChoice(err)) { 472 retriedAutoToolChoice = true 473 console.warn( 474 `[fallback] provider rejected tool_choice=${currentRequest.tool_choice}; retrying with tool_choice=auto`, 475 ) 476 currentRequest = { 477 ...currentRequest, 478 tool_choice: "auto", 479 } 480 continue 481 } 482 if (!retriedWithoutReasoning && shouldRetryWithoutReasoningForTools(err)) { 483 retriedWithoutReasoning = true 484 console.warn("[fallback] provider rejected function calling in reasoning/prefix mode; retrying fallback with tool-compatible reasoning disabled") 485 currentRequest = disableReasoningForToolCalls(currentRequest) 486 continue 487 } 488 if (!retriedWithReasoning && shouldRetryWithReasoningEnabled(err)) { 489 retriedWithReasoning = true 490 console.warn(`[fallback] model ${currentRequest.model} requires reasoning; retrying fallback with reasoning enabled`) 491 currentRequest = enableReasoningForRequest(currentRequest) 492 continue 493 } 494 throw err 495 } 496 } 497} 498 499async function createPrimaryCompletion(messages: OpenAI.Chat.ChatCompletionMessageParam[]): Promise<CompletionTurnResult> { 500 const request: CompletionRequest = { 501 model: MODEL, 502 messages, 503 tools: TOOLS, 504 tool_choice: PRIMARY_TOOL_CHOICE, 505 ...openRouterToolRequestExtras(API_BASE), 506 ...configuredThinkingRequestExtras(), 507 } 508 509 let currentRequest = request 510 let retriedAutoToolChoice = false 511 let retriedWithoutReasoning = false 512 let retriedWithReasoning = false 513 while (true) { 514 try { 515 return await createStreamedCompletion(client!, currentRequest) 516 } catch (err) { 517 if (currentRequest.tool_choice !== "auto" && !retriedAutoToolChoice && shouldRetryWithAutoToolChoice(err)) { 518 retriedAutoToolChoice = true 519 console.warn(`[api] provider rejected tool_choice=${currentRequest.tool_choice}; retrying primary with tool_choice=auto`) 520 currentRequest = { 521 ...currentRequest, 522 tool_choice: "auto", 523 } 524 continue 525 } 526 if (!retriedWithoutReasoning && shouldRetryWithoutReasoningForTools(err)) { 527 retriedWithoutReasoning = true 528 console.warn("[api] provider rejected function calling in reasoning/prefix mode; retrying primary with tool-compatible reasoning disabled") 529 currentRequest = disableReasoningForToolCalls(currentRequest) 530 continue 531 } 532 if (!retriedWithReasoning && shouldRetryWithReasoningEnabled(err)) { 533 retriedWithReasoning = true 534 console.warn(`[api] model ${currentRequest.model} requires reasoning; retrying primary with reasoning enabled`) 535 currentRequest = enableReasoningForRequest(currentRequest) 536 continue 537 } 538 throw err 539 } 540 } 541} 542 543function logPromptSizeDebug(state: LoopState, err: unknown, label: string): void { 544 const messageCount = state.conversation.length 545 const roleCounts = state.conversation.reduce<Record<string, number>>((acc, m) => { 546 const role = (m as { role?: string }).role ?? "unknown" 547 acc[role] = (acc[role] ?? 0) + 1 548 return acc 549 }, {}) 550 const estimate = estimatePromptTokens(state.conversation) 551 const charLength = JSON.stringify(state.conversation).length 552 const summary = err instanceof OpenAI.APIError ? `${err.status} ${err.message}` : errorSummary(err) 553 console.warn( 554 `[api] ${label}: ${summary} - messages=${messageCount} est_tokens=${estimate} chars=${charLength} roles=${JSON.stringify(roleCounts)} observedPromptTokens=${state.contextSize}`, 555 ) 556} 557 558async function recoverFromPromptTooLarge(state: LoopState, attempt: number): Promise<boolean> { 559 const beforeCount = state.conversation.length 560 const beforeEstimate = estimatePromptTokens(state.conversation) 561 562 const summaryProvider = configuredSummaryProvider() 563 if (!summaryProvider.client || !summaryProvider.model) { 564 console.warn(`[context] recovery: no summary client available; cannot llm-summarize`) 565 return false 566 } 567 568 console.warn(`[context] recovery: attempting llm summarization via ${summaryProvider.model} (attempt=${attempt + 1})`) 569 const summarized = await summarizeConversationViaLLM(state.conversation, summaryProvider.client, summaryProvider.model) 570 if (!summarized) { 571 console.warn(`[context] recovery: llm summarization returned no changes`) 572 return false 573 } 574 575 const afterEstimate = estimatePromptTokens(summarized) 576 if (afterEstimate >= beforeEstimate) { 577 console.warn(`[context] recovery: llm summary not smaller (${beforeEstimate} -> ${afterEstimate}); keeping original`) 578 return false 579 } 580 581 state.conversation = summarized 582 state.contextSize = afterEstimate 583 584 const summaryIdx = findSummaryMessageIndex(state.conversation) 585 const summary = summaryIdx >= 0 ? (state.conversation[summaryIdx]?.content as string) : undefined 586 587 console.warn( 588 `[context] recovery: llm-summarized conversation (${beforeCount} -> ${summarized.length} msgs, ${beforeEstimate} -> ${afterEstimate} tokens)`, 589 ) 590 591 recordMetric({ 592 type: "compaction", 593 before: beforeEstimate, 594 after: afterEstimate, 595 method: "force-llm", 596 summary, 597 }) 598 return true 599} 600 601/** 602 * Fetches the next assistant completion, including fallback and backoff behavior. 603 * 604 * @param state - Mutable loop state containing current conversation/context. 605 * @param baseConversation - Optional alternate base conversation for retries. 606 * @returns The next chat completion response. 607 * @throws If the primary request fails with a non-fallback error condition. 608 */ 609export async function fetchCompletion( 610 state: LoopState, 611 baseConversation: OpenAI.Chat.ChatCompletionMessageParam[] = state.conversation, 612): Promise<CompletionTurnResult> { 613 let promptTooLargeAttempts = 0 614 let imagesScrubbed = false 615 616 // Both content-filter rejections and image parse/format rejections (e.g. 617 // z.ai/GLM code 1210) stick across turns when the offending image lives in 618 // the persisted conversation, which crash-loops the runner on restart. 619 // Scrub the image parts (replacing them with a placeholder the model sees) 620 // and retry so the loop survives instead of aborting. 621 const recoverByScrubbingImages = (err: unknown, label: string): boolean => { 622 if (imagesScrubbed) return false 623 if (!isContentFilterError(err) && !isImageParseError(err)) return false 624 const reason = isContentFilterError(err) ? "content-filter rejection" : "image parse/format rejection" 625 const scrubbed = scrubImagesFromConversation(state.conversation) 626 imagesScrubbed = true 627 if (scrubbed > 0) { 628 console.warn( 629 `[api] ${label} ${reason}; scrubbed ${scrubbed} image attachment(s) from conversation and retrying`, 630 ) 631 return true 632 } 633 console.warn(`[api] ${label} ${reason} but no images found to scrub`) 634 return false 635 } 636 637 while (true) { 638 if (baseConversation === state.conversation) { 639 state.conversation = sanitizeMessages(state.conversation) 640 baseConversation = state.conversation 641 } else { 642 baseConversation = sanitizeMessages(baseConversation) 643 } 644 645 const requestContext = await buildCompletionMessages( 646 baseConversation, 647 state.memoryRecallCooldowns, 648 state.memoryRecallTurn, 649 ) 650 const requestMessages = requestContext.messages 651 652 if (USE_FALLBACK) { 653 const fallbackWindow = fallbackContextWindow(requestMessages) 654 if (fallbackWindow.nearLimit) { 655 console.warn( 656 `[fallback] prompt estimate ${fallbackWindow.estimate} nearing fallback limit ${fallbackWindow.softLimit} (${FALLBACK_MODEL})`, 657 ) 658 } 659 660 try { 661 const completion = await createFallbackCompletion(requestMessages) 662 state.memoryRecallCooldowns = rememberRecalledMemoryChunks( 663 state.memoryRecallCooldowns, 664 requestContext.recalledChunkIds, 665 state.memoryRecallTurn, 666 ) 667 return completion 668 } catch (fallbackErr) { 669 if (isPromptTooLargeError(fallbackErr) && promptTooLargeAttempts < 2) { 670 logPromptSizeDebug(state, fallbackErr, `fallback rejected prompt (attempt ${promptTooLargeAttempts + 1}/2)`) 671 const recovered = await recoverFromPromptTooLarge(state, promptTooLargeAttempts) 672 promptTooLargeAttempts++ 673 if (recovered) continue 674 } 675 if (recoverByScrubbingImages(fallbackErr, "fallback")) continue 676 if (shouldFallback(fallbackErr)) { 677 const retryAfter = retryDelayMs(fallbackErr) 678 console.warn( 679 `[fallback] transient failure (${errorSummary(fallbackErr)}); retrying after ${Math.ceil(retryAfter / 1000)}s`, 680 ) 681 console.log( 682 `[runner] backing off ${Math.ceil(retryAfter / 1000)}s (until ${formatRetryAt(retryAfter)}) before retrying fallback...`, 683 ) 684 await sleep(retryAfter) 685 continue 686 } 687 logApiError(fallbackErr, `model=${FALLBACK_MODEL} api=${FALLBACK_BASE}`) 688 throw fallbackErr 689 } 690 } 691 692 try { 693 const completion = await createPrimaryCompletion(requestMessages) 694 state.memoryRecallCooldowns = rememberRecalledMemoryChunks( 695 state.memoryRecallCooldowns, 696 requestContext.recalledChunkIds, 697 state.memoryRecallTurn, 698 ) 699 return completion 700 } catch (primaryErr) { 701 if (isPromptTooLargeError(primaryErr) && promptTooLargeAttempts < 2) { 702 logPromptSizeDebug(state, primaryErr, `primary rejected prompt (attempt ${promptTooLargeAttempts + 1}/2)`) 703 const recovered = await recoverFromPromptTooLarge(state, promptTooLargeAttempts) 704 promptTooLargeAttempts++ 705 if (recovered) continue 706 logApiError(primaryErr, `model=${MODEL} api=${API_BASE}`) 707 throw primaryErr 708 } 709 710 if (recoverByScrubbingImages(primaryErr, "primary")) continue 711 712 if (!shouldFallback(primaryErr)) { 713 logApiError(primaryErr, `model=${MODEL} api=${API_BASE}`) 714 throw primaryErr 715 } 716 717 const fallbackWindow = fallbackContextWindow(requestMessages) 718 if (fallbackWindow.skip) { 719 console.warn( 720 `[api] primary down (${errorSummary(primaryErr)}) and fallback context estimate ${fallbackWindow.estimate} exceeds hard limit ${fallbackWindow.hardLimit}; retrying primary after backoff`, 721 ) 722 const retryAfter = retryDelayMs(primaryErr) 723 console.log( 724 `[runner] backing off ${Math.ceil(retryAfter / 1000)}s (until ${formatRetryAt(retryAfter)}) before retrying primary...`, 725 ) 726 await sleep(retryAfter) 727 continue 728 } 729 730 if (fallbackWindow.nearLimit) { 731 console.warn( 732 `[fallback] prompt estimate ${fallbackWindow.estimate} nearing fallback limit ${fallbackWindow.softLimit} (${FALLBACK_MODEL})`, 733 ) 734 } 735 736 console.warn(`[api] primary down (${errorSummary(primaryErr)}) - switching to fallback`) 737 try { 738 const completion = await createFallbackCompletion(requestMessages) 739 state.memoryRecallCooldowns = rememberRecalledMemoryChunks( 740 state.memoryRecallCooldowns, 741 requestContext.recalledChunkIds, 742 state.memoryRecallTurn, 743 ) 744 return completion 745 } catch (fallbackErr) { 746 if (isPromptTooLargeError(fallbackErr) && promptTooLargeAttempts < 2) { 747 logPromptSizeDebug(state, fallbackErr, `fallback rejected prompt during failover (attempt ${promptTooLargeAttempts + 1}/2)`) 748 const recovered = await recoverFromPromptTooLarge(state, promptTooLargeAttempts) 749 promptTooLargeAttempts++ 750 if (recovered) continue 751 } 752 if (recoverByScrubbingImages(fallbackErr, "fallback-failover")) continue 753 console.warn( 754 `[api] fallback failed (${errorSummary(fallbackErr)}) after primary failure (${errorSummary(primaryErr)}); retrying primary after backoff`, 755 ) 756 const retryAfter = retryDelayMs(primaryErr) 757 console.log( 758 `[runner] backing off ${Math.ceil(retryAfter / 1000)}s (until ${formatRetryAt(retryAfter)}) before retrying primary...`, 759 ) 760 await sleep(retryAfter) 761 } 762 } 763 } 764} 765 766export const __completionTest = { 767 consumeCompletionStream, 768}