Show information about anthropic calls when clicking usage (#34)

This commit is contained in:
Brian Hackett
2025-02-19 16:19:42 -08:00
committed by GitHub
parent dbaf92b3e8
commit 3c3a97aa50
3 changed files with 116 additions and 52 deletions

View File

@@ -1,6 +1,9 @@
import { memo } from 'react';
import { Markdown } from './Markdown';
import type { JSONValue } from 'ai';
import type { ChatAnthropicInfo, AnthropicCall } from '~/lib/.server/llm/chat-anthropic';
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages/messages.mjs';
import { toast } from 'react-toastify';
interface AssistantMessageProps {
content: string;
@@ -13,6 +16,7 @@ export function getAnnotationsTokensUsage(annotations: JSONValue[] | undefined)
) || []) as { type: string; value: any }[];
const usage: {
chatInfo: ChatAnthropicInfo;
completionTokens: number;
promptTokens: number;
totalTokens: number;
@@ -21,13 +25,83 @@ export function getAnnotationsTokensUsage(annotations: JSONValue[] | undefined)
return usage;
}
function flatMessageContent(content: string | ContentBlockParam[]): string {
if (typeof content === "string") {
return content;
}
if (Array.isArray(content)) {
let result = "";
for (const elem of content) {
if (elem.type === "text") {
result += elem.text;
}
}
return result;
}
console.log("AnthropicUnknownContent", JSON.stringify(content, null, 2));
return "AnthropicUnknownContent";
}
function describeChatInfo(chatInfo: ChatAnthropicInfo) {
let text = "";
function appendCall(call: AnthropicCall) {
text += "************************************************\n";
text += "AnthropicMessageSend\n";
text += "Message system:\n";
text += call.systemPrompt;
for (const message of call.messages) {
text += `Message ${message.role}:\n`;
text += flatMessageContent(message.content);
}
text += "Response:\n";
text += call.responseText;
text += "\n";
text += `Tokens ${call.completionTokens + call.promptTokens}\n`;
text += "************************************************\n";
}
appendCall(chatInfo.mainCall);
for (const call of chatInfo.restoreCalls) {
appendCall(call);
}
for (const info of chatInfo.infos) {
text += info;
}
return text;
}
export const AssistantMessage = memo(({ content, annotations }: AssistantMessageProps) => {
const usage = getAnnotationsTokensUsage(annotations);
const onUsageClicked = () => {
if (!usage.chatInfo) {
toast.error("No chat info found");
return;
}
const text = describeChatInfo(usage.chatInfo);
// Create a blob with the text content
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
// Open the blob URL in a new window
window.open(url);
// Clean up the blob URL after a short delay
setTimeout(() => URL.revokeObjectURL(url), 100);
};
return (
<div className="overflow-hidden w-full">
{usage && (
<div className="text-sm text-bolt-elements-textSecondary mb-2">
<div
className="text-sm text-bolt-elements-textSecondary mb-2 cursor-pointer hover:underline"
onClick={onUsageClicked}
title="View call information"
>
Tokens: {usage.totalTokens} (prompt: {usage.promptTokens}, completion: {usage.completionTokens})
</div>
)}

View File

@@ -24,23 +24,6 @@ function convertContentToAnthropic(content: any): ContentBlockParam[] {
return [];
}
function flatMessageContent(content: string | ContentBlockParam[]): string {
if (typeof content === "string") {
return content;
}
if (Array.isArray(content)) {
let result = "";
for (const elem of content) {
if (elem.type === "text") {
result += elem.text;
}
}
return result;
}
console.log("AnthropicUnknownContent", JSON.stringify(content, null, 2));
return "AnthropicUnknownContent";
}
export interface AnthropicApiKey {
key: string;
isUser: boolean;
@@ -73,15 +56,7 @@ const callAnthropic = wrapWithSpan(
const anthropic = new Anthropic({ apiKey: apiKey.key });
console.log("************************************************");
console.log("AnthropicMessageSend");
console.log("Message system:");
console.log(systemPrompt);
for (const message of messages) {
console.log(`Message ${message.role}:`);
console.log(flatMessageContent(message.content));
}
console.log("************************************************");
const response = await anthropic.messages.create({
model: Model,
@@ -111,11 +86,7 @@ const callAnthropic = wrapWithSpan(
});
console.log("************************************************");
console.log("AnthropicMessageResponse:");
console.log(responseText);
console.log("AnthropicTokens", completionTokens + promptTokens);
console.log("************************************************");
console.log("AnthropicMessageResponse");
return {
systemPrompt,
@@ -142,7 +113,13 @@ function shouldRestorePartialFile(existingContent: string, newContent: string):
return existingContent.length > newContent.length;
}
interface ChatState {
// Info about how the chat was processed which will be conveyed back to the client.
infos: string[];
}
async function restorePartialFile(
state: ChatState,
existingContent: string,
newContent: string,
apiKey: AnthropicApiKey,
@@ -203,7 +180,7 @@ ${responseDescription}
const closeTag = restoreCall.responseText.indexOf(CloseTag);
if (openTag === -1 || closeTag === -1) {
console.error("Invalid restored content", restoreCall.responseText);
state.infos.push(`Error: Invalid restored content: ${restoreCall.responseText}`);
return { restoreCall, restoredContent: newContent };
}
@@ -212,7 +189,7 @@ ${responseDescription}
// Sometimes the model ignores its instructions and doesn't return the content if it hasn't
// made any modifications. In this case we use the unmodified new content.
if (restoredContent.length < existingContent.length && restoredContent.length < newContent.length) {
console.error("Restored content too short", restoredContent);
state.infos.push(`Error: Restored content too short: ${restoreCall.responseText}`);
return { restoreCall, restoredContent: newContent };
}
@@ -242,7 +219,7 @@ function getMessageDescription(responseText: string): string {
return responseText;
}
async function getLatestPackageVersion(packageName: string) {
async function getLatestPackageVersion(state: ChatState, packageName: string) {
try {
const response = await fetch(`https://registry.npmjs.org/${packageName}/latest`);
const data = await response.json() as any;
@@ -250,7 +227,7 @@ async function getLatestPackageVersion(packageName: string) {
return data.version;
}
} catch (e) {
console.error("Error getting latest package version", packageName, e);
state.infos.push(`Error getting latest package version: ${packageName}`);
}
return undefined;
}
@@ -262,12 +239,12 @@ function ignorePackageUpgrade(packageName: string) {
// Upgrade dependencies in package.json to the latest version, instead of the random
// and sometimes ancient versions that the AI picks.
async function upgradePackageJSON(content: string) {
async function upgradePackageJSON(state: ChatState, content: string) {
try {
const packageJSON = JSON.parse(content);
for (const key of Object.keys(packageJSON.dependencies)) {
if (!ignorePackageUpgrade(key)) {
const version = await getLatestPackageVersion(key);
const version = await getLatestPackageVersion(state, key);
if (version) {
packageJSON.dependencies[key] = version;
}
@@ -275,12 +252,12 @@ async function upgradePackageJSON(content: string) {
}
return JSON.stringify(packageJSON, null, 2);
} catch (e) {
console.error("Error upgrading package.json", e);
state.infos.push(`Error upgrading package.json: ${e}`);
return content;
}
}
function replaceFileContents(responseText: string, oldContent: string, newContent: string) {
function replaceFileContents(state: ChatState, responseText: string, oldContent: string, newContent: string) {
let contentIndex = responseText.indexOf(oldContent);
if (contentIndex === -1) {
@@ -288,8 +265,10 @@ function replaceFileContents(responseText: string, oldContent: string, newConten
oldContent = oldContent.trim();
contentIndex = responseText.indexOf(oldContent);
console.error("Old content not found in response", JSON.stringify({ responseText, oldContent }));
return responseText;
if (contentIndex == -1) {
state.infos.push(`Error: Old content not found in response: ${JSON.stringify({ responseText, oldContent })}`);
return responseText;
}
}
return responseText.substring(0, contentIndex) +
@@ -302,7 +281,7 @@ interface FileContents {
content: string;
}
async function fixupResponseFiles(files: FileMap, apiKey: AnthropicApiKey, responseText: string) {
async function fixupResponseFiles(state: ChatState, files: FileMap, apiKey: AnthropicApiKey, responseText: string) {
const fileContents: FileContents[] = [];
const messageParser = new StreamingMessageParser({
@@ -328,24 +307,31 @@ async function fixupResponseFiles(files: FileMap, apiKey: AnthropicApiKey, respo
if (shouldRestorePartialFile(existingContent, newContent)) {
const { restoreCall, restoredContent } = await restorePartialFile(
state,
existingContent,
newContent,
apiKey,
responseDescription
);
restoreCalls.push(restoreCall);
responseText = replaceFileContents(responseText, newContent, restoredContent);
responseText = replaceFileContents(state, responseText, newContent, restoredContent);
}
if (filePath.includes("package.json")) {
const newPackageJSON = await upgradePackageJSON(newContent);
responseText = replaceFileContents(responseText, newContent, newPackageJSON);
const newPackageJSON = await upgradePackageJSON(state, newContent);
responseText = replaceFileContents(state, responseText, newContent, newPackageJSON);
}
}
return { responseText, restoreCalls };
}
export type ChatAnthropicInfo = {
mainCall: AnthropicCall;
restoreCalls: AnthropicCall[];
infos: string[];
}
export async function chatAnthropic(chatController: ChatStreamController, files: FileMap, apiKey: AnthropicApiKey, systemPrompt: string, messages: CoreMessage[]) {
const messageParams: MessageParam[] = [];
@@ -360,18 +346,22 @@ export async function chatAnthropic(chatController: ChatStreamController, files:
const mainCall = await callAnthropic(apiKey, systemPrompt, messageParams);
const { responseText, restoreCalls } = await fixupResponseFiles(files, apiKey, mainCall.responseText);
const state: ChatState = {
infos: [],
};
const { responseText, restoreCalls } = await fixupResponseFiles(state, files, apiKey, mainCall.responseText);
chatController.writeText(responseText);
const callInfos = [mainCall, ...restoreCalls];
const chatInfo: ChatAnthropicInfo = { mainCall, restoreCalls, infos: state.infos };
let completionTokens = 0;
let promptTokens = 0;
for (const callInfo of callInfos) {
for (const callInfo of [mainCall, ...restoreCalls]) {
completionTokens += callInfo.completionTokens;
promptTokens += callInfo.promptTokens;
}
chatController.writeUsage({ callInfos, completionTokens, promptTokens });
chatController.writeUsage({ chatInfo, completionTokens, promptTokens });
}

View File

@@ -3,7 +3,7 @@
// to be functionality exported from the associated packages to do this so
// for now we do it manually after reverse engineering the protocol.
import type { AnthropicCall } from "~/lib/.server/llm/chat-anthropic";
import type { ChatAnthropicInfo } from "~/lib/.server/llm/chat-anthropic";
export interface ChatFileChange {
filePath: string;
@@ -38,7 +38,7 @@ export class ChatStreamController {
this.controller.enqueue(data);
}
writeUsage({ callInfos, completionTokens, promptTokens }: { callInfos: AnthropicCall[], completionTokens: number, promptTokens: number }) {
this.writeAnnotation("usage", { callInfos, completionTokens, promptTokens, totalTokens: completionTokens + promptTokens });
writeUsage({ chatInfo, completionTokens, promptTokens }: { chatInfo: ChatAnthropicInfo, completionTokens: number, promptTokens: number }) {
this.writeAnnotation("usage", { chatInfo, completionTokens, promptTokens, totalTokens: completionTokens + promptTokens });
}
}