logancyang_obsidian-copilot/src/chainUtils.ts
Logan Yang 09ce24115b
fix: defense-in-depth overrides to prevent tiktoken CDN timeout in Plus mode (#2283)
* fix: defense-in-depth overrides to prevent tiktoken CDN timeout in Plus mode (#2282)

Plus mode's planToolCalls uses invoke() on a streaming model, which
causes LangChain's _generate() to call _getEstimatedTokenCountFromPrompt
after streaming. This triggers getNumTokens -> encodingForModel -> CDN
fetch with 6 retries, hanging for minutes when tiktoken.pages.dev is
unreachable.

The existing getNumTokens instance override (PR #2160) is bypassed in
some production bundle configurations. Add overrides at two additional
levels: getNumTokensFromMessages and _getEstimatedTokenCountFromPrompt.
The latter returns 0 since actual token usage comes from API response
metadata.

Agent mode is unaffected because it uses stream() which bypasses
_generate() entirely.

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

* fix: use stream() instead of invoke() in planToolCalls to avoid tiktoken CDN fetch

Instance property overrides on getNumTokens are bypassed in esbuild
production bundles (prototype methods called directly). Instead of
trying to override methods, eliminate the problematic code path: replace
invoke() with stream() + chunk collection in planToolCalls.

invoke() goes through _generate() which, with streaming: true, calls
_getEstimatedTokenCountFromPrompt -> getNumTokensFromMessages ->
getNumTokens -> encodingForModel -> tiktoken CDN fetch (6 retries).

stream() goes through _streamResponseChunks() directly, bypassing
_generate() and the entire token estimation path. This is the same
approach Agent mode uses, which is why it was never affected.

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

* fix: patch getNumTokens on prototype instead of instance for esbuild compat

Instance property overrides are bypassed in esbuild production bundles
because minified code resolves this.method() calls directly to the
prototype. Patch the prototype with a guard flag to ensure the tiktoken
CDN fetch is prevented in all code paths including production builds.

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

* fix: patch tiktoken on BaseLanguageModel prototype and convert invoke to stream

Two fixes for the tiktoken CDN timeout that blocks web search:

1. Walk up the prototype chain to patch getNumTokens on
   BaseLanguageModel.prototype (where it's defined) instead of the
   direct prototype. One patch now covers ALL model classes
   (ChatOpenAI, ChatOpenRouter, etc.) instead of only the first
   class instantiated.

2. Convert getStandaloneQuestion() from invoke() to stream() to
   avoid _generate() -> _getEstimatedTokenCountFromPrompt() ->
   getNumTokens() -> tiktoken CDN fetch path entirely. This is
   the invoke() call triggered during @websearch tool execution.

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

* refactor: simplify tiktoken patch to one-time module-level override

Replace the per-instance prototype walking with a single module-level
patch on BaseLanguageModel.prototype.getNumTokens. Runs at import time
before any model is created, covering all model classes and all code
paths (createModelInstance, ping, etc.) with zero per-call overhead.

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

* fix: handle array content in getStandaloneQuestion stream loop

Use extractTextFromChunk() instead of a bare string check so that
providers sending content-part arrays (e.g. Anthropic) are handled
correctly during streaming. Previously array content was silently
dropped, which could produce an empty condensed question.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:48:13 -07:00

55 lines
2 KiB
TypeScript

import ProjectManager from "@/LLMProviders/projectManager";
import {
ChatHistoryEntry,
extractTextFromChunk,
removeErrorTags,
removeThinkTags,
withSuppressedTokenWarnings,
} from "@/utils";
export async function getStandaloneQuestion(
question: string,
chatHistory: ChatHistoryEntry[]
): Promise<string> {
const condenseQuestionTemplate = `Given the following conversation and a follow up question,
summarize the conversation as context and keep the follow up question unchanged, in its original language.
If the follow up question is unrelated to its preceding messages, return this follow up question directly.
If it is related, then combine the summary and the follow up question to construct a standalone question.
Make sure to keep any [[]] wrapped note titles in the question unchanged.
If there's nothing in the chat history, just return the follow up question.
Chat History:
{chat_history}
Follow Up Input: {question}
Standalone question:`;
const formattedChatHistory = chatHistory
.map(({ role, content }) => `${role}: ${content}`)
.join("\n");
// Wrap the model call with token warning suppression
return await withSuppressedTokenWarnings(async () => {
// Use temperature=0 for deterministic question condensation
const chatModel = await ProjectManager.instance
.getCurrentChainManager()
.chatModelManager.getChatModelWithTemperature(0);
// Use stream() instead of invoke() to avoid LangChain's _generate() path,
// which triggers tiktoken CDN fetch via _getEstimatedTokenCountFromPrompt.
let text = "";
const stream = await chatModel.stream([
{
role: "user",
content: condenseQuestionTemplate
.replace("{chat_history}", formattedChatHistory)
.replace("{question}", question),
},
]);
for await (const chunk of stream) {
text += extractTextFromChunk(chunk.content);
}
const cleanedResponse = removeThinkTags(text);
return removeErrorTags(cleanedResponse);
});
}