diff --git a/examples/camel.py b/examples/camel.py index ec3e7362..bffa0507 100644 --- a/examples/camel.py +++ b/examples/camel.py @@ -1,4 +1,3 @@ -import asyncio from typing import List from pydantic import BaseModel, Field diff --git a/examples/litellm_recipe_prompting.py b/examples/litellm_recipe_prompting.py new file mode 100644 index 00000000..87446e01 --- /dev/null +++ b/examples/litellm_recipe_prompting.py @@ -0,0 +1,52 @@ +from typing import List +from pydantic import BaseModel, Field +from bespokelabs import curator +from datasets import Dataset + + +def main(): + # List of cuisines to generate recipes for + cuisines = [ + {"cuisine": cuisine} + for cuisine in [ + "Chinese", + "Italian", + "Mexican", + "French", + "Japanese", + "Indian", + "Thai", + "Korean", + "Vietnamese", + "Brazilian", + ] + ] + cuisines = Dataset.from_list(cuisines) + + # Create prompter using LiteLLM backend + ############################################# + # To use Gemini models: + # 1. Go to https://aistudio.google.com/app/apikey + # 2. Generate an API key + # 3. Set environment variable: GEMINI_API_KEY + ############################################# + + recipe_prompter = curator.Prompter( + model_name="gemini/gemini-1.5-flash", + prompt_func=lambda row: f"Generate a random {row['cuisine']} recipe. Be creative but keep it realistic.", + parse_func=lambda row, response: { + "recipe": response, + "cuisine": row["cuisine"], + }, + backend="litellm", + ) + + # Generate recipes for all cuisines + recipes = recipe_prompter(cuisines) + + # Print results + print(recipes.to_pandas()) + + +if __name__ == "__main__": + main() diff --git a/examples/litellm_recipe_structured_output.py b/examples/litellm_recipe_structured_output.py new file mode 100644 index 00000000..747411e9 --- /dev/null +++ b/examples/litellm_recipe_structured_output.py @@ -0,0 +1,71 @@ +from typing import List +from pydantic import BaseModel, Field +from bespokelabs import curator +import logging + +logger = logging.getLogger(__name__) + + +# Define response format using Pydantic +class Recipe(BaseModel): + title: str = Field(description="Title of the recipe") + ingredients: List[str] = Field(description="List of ingredients needed") + instructions: List[str] = Field(description="Step by step cooking instructions") + prep_time: int = Field(description="Preparation time in minutes") + cook_time: int = Field(description="Cooking time in minutes") + servings: int = Field(description="Number of servings") + + +class Cuisines(BaseModel): + cuisines_list: List[str] = Field(description="A list of cuisines.") + + +def main(): + # We define a prompter that generates cuisines + ############################################# + # To use Claude models: + # 1. Go to https://console.anthropic.com/settings/keys + # 2. Generate an API key or use an existing API key + # 3. Set environment variable: ANTHROPIC_API_KEY + ############################################# + cuisines_generator = curator.Prompter( + prompt_func=lambda: f"Generate 10 diverse cuisines.", + model_name="claude-3-5-haiku-20241022", + response_format=Cuisines, + parse_func=lambda _, cuisines: [{"cuisine": t} for t in cuisines.cuisines_list], + backend="litellm", + ) + cuisines = cuisines_generator() + print(cuisines.to_pandas()) + + ############################################# + # To use Gemini models: + # 1. Go to https://aistudio.google.com/app/apikey + # 2. Generate an API key or use an existing API key + # 3. Set environment variable: GEMINI_API_KEY + ############################################# + recipe_prompter = curator.Prompter( + model_name="gemini/gemini-1.5-flash", + prompt_func=lambda row: f"Generate a random {row['cuisine']} recipe. Be creative but keep it realistic.", + parse_func=lambda row, response: { + "title": response.title, + "ingredients": response.ingredients, + "instructions": response.instructions, + "prep_time": response.prep_time, + "cook_time": response.cook_time, + "servings": response.servings, + "cuisine": row["cuisine"], + }, + response_format=Recipe, + backend="litellm", + ) + + # Generate recipes for all cuisines + recipes = recipe_prompter(cuisines) + + # Print results + print(recipes.to_pandas()) + + +if __name__ == "__main__": + main() diff --git a/examples/persona-hub/prompt_templates.py b/examples/persona-hub/prompt_templates.py new file mode 100644 index 00000000..e11b00e6 --- /dev/null +++ b/examples/persona-hub/prompt_templates.py @@ -0,0 +1,233 @@ +# From https://github.com/tencent-ailab/persona-hub/blob/main/code/prompt_templates.py +math_template = """Create a math problem related to the following persona: + +{persona} + +Note: + +1. The math problem should be challenging and involve advanced mathematical skills and knowledge. Only top talents can solve it correctly. +2. You should make full use of the persona description to create the math problem to ensure that the math problem is unique and specific to the persona. +3. Your response should always start with "Math problem:". Your response should not include a solution to the created math problem. +4. Your created math problem should include no more than 2 sub-problems. +""" + + +instruction_template = '''Guess a prompt that the following persona may ask you to do: + +{persona} + +Note: + +1. The prompt should be informative and specific. +2. Your output should start with "User prompt:"''' + +knowledge_template = '''{persona} + +Assume you are the persona described above and you are writing a Quora article using your knowledge, skills, experience, or insights to help others learn and benefit from it. + +Note: + +1. The article should be specific, informative and knowledge-rich. +2. Your response should start with "Title:"''' + +npc_template = """World of Warcraft (WoW) is a massively multiplayer online role-playing game (MMORPG) developed by Blizzard Entertainment. It is set in the high-fantasy world of Azeroth, a land filled with rich lore, diverse races, and epic conflicts. The game has evolved significantly since its release in 2004, with numerous expansions adding new continents, races, classes, and storylines. Below is a detailed overview of the game's worldview, story background, and some key characters and NPCs. + +### Worldview and Story Background + +**Azeroth** is a world steeped in ancient history, powerful magic, and epic conflicts. The planet is divided into several continents, each with its own unique environments, cultures, and histories. The main continents include: + +- Eastern Kingdoms: Home to the human kingdoms, dwarves, gnomes, and the undead Forsaken. +- Kalimdor: Inhabited by orcs, night elves, tauren, trolls, and other races. +- Northrend: A frozen continent, home to the Lich King and the undead Scourge. +- Pandaria: A mystical land shrouded in mists, home to the Pandaren. +- Broken Isles: The remnants of the ancient Night Elf civilization and the site of the Tomb of Sargeras. +- Zandalar and Kul Tiras: Introduced in the Battle for Azeroth expansion, these are the homelands of the Zandalari trolls and the human kingdom of Kul Tiras, respectively. +- Shadowlands: The realm of the afterlife, introduced in the Shadowlands expansion. + +The story of Azeroth is vast and complex, spanning millennia and involving numerous races, factions, and cosmic forces. Here are some key aspects of the world's background: + +#### **The Titans and the Old Gods** + +- **The Titans**: Azeroth was shaped by the Titans, colossal beings who are part of the Pantheon, a group of god-like entities dedicated to bringing order to the universe. The Titans discovered Azeroth and found it infested with chaotic entities known as the Old Gods. To combat this, they created the Titan-forged, including the Keepers, to help shape and protect the world. + +- **The Old Gods**: These malevolent, ancient beings sought to corrupt Azeroth. The Titans imprisoned the Old Gods beneath the surface of the world, but their influence persisted, causing chaos and corruption throughout history. Notable Old Gods include C'Thun, Yogg-Saron, N'Zoth, and Y'Shaarj. + +#### **The Sundering** + +- **The Well of Eternity**: At the center of ancient Kalimdor was the Well of Eternity, a source of immense arcane power. The Highborne, a group of night elves led by Queen Azshara, recklessly tapped into its power, attracting the attention of the Burning Legion, a demonic army led by the dark titan Sargeras. + +- **The War of the Ancients**: This conflict saw the night elves, dragons, and other races unite to repel the Burning Legion's invasion. The war culminated in the Sundering, a catastrophic event that shattered the supercontinent of Kalimdor into several smaller continents and created the Maelstrom, a massive, swirling vortex of energy. + +#### **The Rise and Fall of Empires** + +- **The Troll Empires**: Before the Sundering, the trolls established powerful empires, such as the Gurubashi and Amani. These empires declined over time but left a lasting impact on Azeroth's history. + +- **The Night Elf Empire**: After the Sundering, the night elves established a new empire, centered around the World Tree, Nordrassil. They became the guardians of nature and the Emerald Dream, a parallel realm of primal life. + +- **The Human Kingdoms**: Humans emerged as a dominant race in the Eastern Kingdoms, founding powerful kingdoms such as Stormwind, Lordaeron, and Dalaran. These kingdoms played crucial roles in the defense of Azeroth against various threats. + +#### **The First and Second Wars** + +- **The First War**: The orcs, originally from the world of Draenor, were corrupted by the Burning Legion and transported to Azeroth through the Dark Portal. They waged war against the human kingdom of Stormwind, ultimately destroying it. + +- **The Second War**: The orcs, now united under the Horde, continued their conquest, clashing with the Alliance of Lordaeron, a coalition of human, dwarf, and high elf forces. The Alliance eventually triumphed, and the orcs were interned in camps. + +#### **The Scourge and the Lich King** + +- **The Lich King**: Created by the demon lord Kil'jaeden, the Lich King was originally the orc shaman Ner'zhul. He was transformed into a powerful undead entity and imprisoned in the Frozen Throne in Northrend. The Lich King created the Scourge, an army of undead, to pave the way for a new invasion by the Burning Legion. + +- **The Third War**: The Scourge ravaged the human kingdoms, leading to the fall of Lordaeron and the rise of the undead Forsaken. The war culminated in the Battle of Mount Hyjal, where the combined forces of the night elves, Horde, and Alliance defeated the Burning Legion. + +#### **The Burning Crusade and Beyond** + +- **The Burning Crusade**: The first expansion of WoW saw players journey to Outland, the shattered remnants of Draenor, to combat the Burning Legion and its allies. + +- **Wrath of the Lich King**: This expansion focused on the conflict with the Lich King in Northrend, culminating in his defeat at Icecrown Citadel. + +- **Cataclysm**: The return of the corrupted Dragon Aspect Deathwing caused massive upheaval across Azeroth, reshaping the world and leading to new conflicts. + +- **Mists of Pandaria**: This expansion introduced the mysterious continent of Pandaria and its inhabitants, the Pandaren, as well as new threats from the Sha and the mogu. + +- **Warlords of Draenor**: Players traveled to an alternate-timeline Draenor to confront the Iron Horde, a new orcish threat. + +- **Legion**: The Burning Legion launched a full-scale invasion of Azeroth, leading to epic battles and the eventual defeat of the dark titan Sargeras. + +- **Battle for Azeroth**: This expansion reignited the conflict between the Alliance and Horde, with new zones, races, and storylines. + +- **Shadowlands**: The latest expansion takes players to the realm of the afterlife, where they must confront new threats and uncover the mysteries of death. + +### Overarching Themes + +**1. Conflict and Unity** +- The world of Azeroth is defined by its conflicts, both internal and external. The ongoing struggle between the Alliance and Horde is a central theme, but there are also numerous other conflicts involving ancient evils, demonic invasions, and cosmic forces. Despite these conflicts, there are moments of unity where disparate factions come together to face common threats. + +**2. Corruption and Redemption** +- Many of Azeroth's greatest heroes and villains have faced corruption, often by dark forces such as the Old Gods or the Burning Legion. Redemption is a recurring theme, with characters seeking to atone for their past actions and reclaim their honor. + +**3. Legacy and Heritage** +- The history of Azeroth is rich with ancient civilizations, legendary heroes, and powerful artifacts. The legacy of these past events shapes the present, with characters and factions often drawing on their heritage to guide their actions. + +**4. Magic and Technology** +- Azeroth is a world where magic and technology coexist. Arcane magic, divine power, and druidic nature magic are all integral to the world's functioning, while technological advancements by races like the gnomes and goblins add another layer of complexity. + +**5. Exploration and Discovery** +- The world of Azeroth is vast and filled with hidden secrets, ancient ruins, and uncharted territories. Exploration and discovery are key aspects of the game's appeal, with players constantly uncovering new lore and adventures. + +### Key Characters and NPCs + +**1. Thrall (Go'el)** +- **Race**: Orc +- **Class**: Shaman +- **Background**: Thrall is one of the most iconic characters in WoW. He was the Warchief of the Horde and played a crucial role in uniting the orc clans and leading them to a new home in Kalimdor. Thrall is known for his wisdom, strength, and deep connection to the elements. + +**2. Jaina Proudmoore** +- **Race**: Human +- **Class**: Mage +- **Background**: Jaina is the daughter of Admiral Daelin Proudmoore and one of the most powerful mages in Azeroth. She has been a key figure in many of the game's major events, including the founding of Theramore and the defense of Azeroth against various threats. + +**3. Sylvanas Windrunner** +- **Race**: Undead (formerly High Elf) +- **Class**: Hunter +- **Background**: Sylvanas was the Ranger-General of Silvermoon before being turned into a banshee by Arthas Menethil. She later became the leader of the Forsaken and, for a time, the Warchief of the Horde. Her actions have often been controversial and have had significant impacts on the game's storyline. + +**4. Anduin Wrynn** +- **Race**: Human +- **Class**: Priest +- **Background**: Anduin is the King of Stormwind and the son of the legendary King Varian Wrynn. Known for his compassion and desire for peace, Anduin has grown into a strong leader, guiding the Alliance through numerous conflicts. + +**5. Arthas Menethil (The Lich King)** +- **Race**: Undead (formerly Human) +- **Class**: Death Knight +- **Background**: Arthas was the Crown Prince of Lordaeron who fell from grace and became the Lich King, one of the most feared beings in Azeroth. His story is central to the Wrath of the Lich King expansion. + +**6. Illidan Stormrage** +- **Race**: Night Elf (Demon Hunter) +- **Class**: Demon Hunter +- **Background**: Illidan is a complex character who has walked the line between hero and villain. He was imprisoned for ten thousand years for his use of forbidden magic but later became a key figure in the fight against the Burning Legion. + +**7. Bolvar Fordragon** +- **Race**: Human (later Undead) +- **Class**: Paladin (later Death Knight) +- **Background**: Bolvar was a noble paladin who sacrificed himself to become the new Lich King, containing the Scourge. His story takes a dramatic turn in the Shadowlands expansion. + +**8. Tyrande Whisperwind** +- **Race**: Night Elf +- **Class**: Priestess of Elune +- **Background**: Tyrande is the High Priestess of Elune and the leader of the Night Elves. She is a fierce warrior and a devoted leader, often seen alongside her husband, Malfurion Stormrage. + +**9. Malfurion Stormrage** +- **Race**: Night Elf +- **Class**: Druid +- **Background**: Malfurion is the first Night Elf druid and one of the most powerful druids in Azeroth. He has played a crucial role in many of the world's major events, including the War of the Ancients and the defense of Azeroth against numerous threats. + +**10. Vol'jin** +- **Race**: Troll +- **Class**: Shadow Hunter +- **Background**: Vol'jin was the leader of the Darkspear Trolls and later became the Warchief of the Horde. He is known for his wisdom, bravery, and deep connection to the spirits. + +### Notable NPCs + +**1. Khadgar** +- **Race**: Human +- **Class**: Mage +- **Background**: Khadgar is one of the most powerful mages in Azeroth and a key figure in the fight against the Burning Legion. He played a significant role in the events of the Warlords of Draenor and Legion expansions. + +**2. Varok Saurfang** +- **Race**: Orc +- **Class**: Warrior +- **Background**: Saurfang is a legendary orc warrior known for his honor and strength. He played a pivotal role in the events of the Battle for Azeroth expansion. + +**3. Lor'themar Theron** +- **Race**: Blood Elf +- **Class**: Ranger +- **Background**: Lor'themar is the Regent Lord of Quel'Thalas and the leader of the Blood Elves. He has guided his people through many challenges, including their alliance with the Horde. + +**4. Genn Greymane** +- **Race**: Worgen (formerly Human) +- **Class**: Warrior +- **Background**: Genn is the King of Gilneas and a fierce leader of the Worgen. He has a deep-seated hatred for Sylvanas Windrunner and has been a key figure in the Alliance's efforts against the Horde. + +**5. Baine Bloodhoof** +- **Race**: Tauren +- **Class**: Warrior +- **Background**: Baine is the High Chieftain of the Tauren and the son of the legendary Cairne Bloodhoof. He is known for his wisdom, strength, and dedication to his people. + +**6. Alexstrasza the Life-Binder** +- **Race**: Dragon (Red Dragonflight) +- **Class**: Aspect of Life +- **Background**: Alexstrasza is the Aspect of Life and the leader of the Red Dragonflight. She has played a crucial role in many of Azeroth's major events, including the fight against Deathwing and the Cataclysm. + +**7. Magni Bronzebeard** +- **Race**: Dwarf +- **Class**: Warrior (later Speaker of Azeroth) +- **Background**: Magni is the former King of Ironforge who was transformed into a diamond form to become the Speaker of Azeroth, communicating with the world-soul of the planet. + +**8. Turalyon** +- **Race**: Human +- **Class**: Paladin +- **Background**: Turalyon is a legendary paladin and one of the original Knights of the Silver Hand. He spent many years fighting the Burning Legion in the Twisting Nether and returned to Azeroth during the Legion expansion. + +**9. Alleria Windrunner** +- **Race**: High Elf (later Void Elf) +- **Class**: Ranger +- **Background**: Alleria is the eldest of the Windrunner sisters and a skilled ranger. She embraced the powers of the Void and became a key figure in the fight against the Burning Legion. + +**10. Nathanos Blightcaller** +- **Race**: Undead +- **Class**: Hunter +- **Background**: Nathanos is a loyal champion of Sylvanas Windrunner and one of the most skilled hunters in Azeroth. He played a significant role in the events of the Battle for Azeroth expansion. + +--- + +Above is the introduction and backgroud story of the game "World of Warcraft (WoW)". + +Your task is to consider what NPC the following persona will become after they come to the world of WoW: + +{persona} + +Note: + +1. Your response should start with "Name:". +2. Your NPC description should be specific and consistent with the game. +3. You also need to specify how the NPC interacts with players in the game. +""" diff --git a/examples/persona-hub/synthesize.py b/examples/persona-hub/synthesize.py new file mode 100644 index 00000000..232d6e1f --- /dev/null +++ b/examples/persona-hub/synthesize.py @@ -0,0 +1,81 @@ +""" Reimplementation of persona-hub openai_synthesize.py using curator. +Source: https://github.com/tencent-ailab/persona-hub/blob/main/code/openai_synthesize.py +How to run: +`python synthesize.py --template "math" --output_path "math.jsonl"` +Use `curator-viewer` to view the output. +""" + +import argparse +from bespokelabs import curator +from datasets import load_dataset +import prompt_templates + + +def get_template(template_name): + # Load the appropriate template + if template_name == "instruction": + return prompt_templates.instruction_template + elif template_name == "knowledge": + return prompt_templates.knowledge_template + elif template_name == "npc": + return prompt_templates.npc_template + elif template_name == "math": + return prompt_templates.math_template + else: + raise ValueError( + "Invalid template type. Choose from 'instruction', 'knowledge', 'npc', or 'math'." + ) + + +def get_generator(template): + def prompt_func(row): + return template.format(persona=row["persona"]) + + generator = curator.Prompter( + prompt_func=prompt_func, + model_name="gpt-4o", + temperature=0.7, + ) + return generator + + +def main(args): + template = get_template(args.template) + generator = get_generator(template) + # Load the persona dataset + persona_dataset = load_dataset( + "proj-persona/PersonaHub", data_files="persona.jsonl", split="train" + ) + if args.sample_size > 0: + persona_dataset = persona_dataset.take(args.sample_size) + print(f"Total number of input personas: {len(persona_dataset['persona'])}") + output = generator(persona_dataset) + # You can now view this via the curator-viewer (use `curator-viewer` command) or store directly to hf hub. + # Store the hf dataset to jsonl file. + output.to_json(args.output_path) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Synthesize text using a specified model and template." + ) + parser.add_argument( + "--sample_size", + type=int, + default=10, + help="Number of samples to process from the dataset; Set it to 0 if you want to use the full set of 200k personas.", + ) + parser.add_argument( + "--template", + type=str, + required=True, + choices=["instruction", "knowledge", "npc", "math"], + help=( + "Prompt templates. Choose from 'instruction', 'knowledge', 'math' or 'npc'. " + "You can also add more customized templates in prompt_templates.py" + ), + ) + parser.add_argument("--output_path", type=str, required=True, help="Path to the output file.") + + args = parser.parse_args() + main(args) diff --git a/examples/poem.py b/examples/poem.py index ffb8c5a5..e8e50d07 100644 --- a/examples/poem.py +++ b/examples/poem.py @@ -18,7 +18,7 @@ class Topics(BaseModel): # We define a prompter that generates topics. topic_generator = curator.Prompter( - prompt_func=lambda: f"Generate 10 diverse topics that are suitable for writing poems about.", + prompt_func=lambda: "Generate 10 diverse topics that are suitable for writing poems about.", model_name="gpt-4o-mini", response_format=Topics, parse_func=lambda _, topics: [{"topic": t} for t in topics.topics_list], diff --git a/poetry.lock b/poetry.lock index 0af8eb19..0abbbcff 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,14 +1,14 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "aiofiles" -version = "24.1.0" +version = "23.2.1" description = "File support for asyncio." optional = false -python-versions = ">=3.8" +python-versions = ">=3.7" files = [ - {file = "aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5"}, - {file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"}, + {file = "aiofiles-23.2.1-py3-none-any.whl", hash = "sha256:19297512c647d4b27a2cf7c34caa7e405c0d60b5560618a29a9fe027b18b0107"}, + {file = "aiofiles-23.2.1.tar.gz", hash = "sha256:84ec2218d8419404abcb9f0c02df3f34c6e0a68ed41072acfb1cef5cbc29051a"}, ] [[package]] @@ -24,87 +24,87 @@ files = [ [[package]] name = "aiohttp" -version = "3.11.2" +version = "3.11.6" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.9" files = [ - {file = "aiohttp-3.11.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:783741f534c14957fbe657d62a34b947ec06db23d45a2fd4a8aeb73d9c84d7e6"}, - {file = "aiohttp-3.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:435f7a08d8aa42371a94e7c141205a9cb092ba551084b5e0c57492e6673601a3"}, - {file = "aiohttp-3.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c681f34e2814bc6e1eef49752b338061b94a42c92734d0be9513447d3f83718c"}, - {file = "aiohttp-3.11.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73a664478ae1ea011b5a710fb100b115ca8b2146864fa0ce4143ff944df714b8"}, - {file = "aiohttp-3.11.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1d06c8fd8b453c3e553c956bd3b8395100401060430572174bb7876dd95ad49"}, - {file = "aiohttp-3.11.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b1f4844909321ef2c1cee50ddeccbd6018cd8c8d1ddddda3f553e94a5859497"}, - {file = "aiohttp-3.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdc6f8dce09281ae534eaf08a54f0d38612398375f28dad733a8885f3bf9b978"}, - {file = "aiohttp-3.11.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d2d942421cf3a1d1eceae8fa192f1fbfb74eb9d3e207d35ad2696bd2ce2c987c"}, - {file = "aiohttp-3.11.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:08ebe7a1d6c1e5ca766d68407280d69658f5f98821c2ba6c41c63cabfed159af"}, - {file = "aiohttp-3.11.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2793d3297f3e49015140e6d3ea26142c967e07998e2fb00b6ee8d041138fbc4e"}, - {file = "aiohttp-3.11.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4a23475d8d5c56e447b7752a1e2ac267c1f723f765e406c81feddcd16cdc97bc"}, - {file = "aiohttp-3.11.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:556564d89e2f4a6e8fe000894c03e4e84cf0b6cfa5674e425db122633ee244d1"}, - {file = "aiohttp-3.11.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:57993f406ce3f114b2a6756d7809be3ffd0cc40f33e8f8b9a4aa1b027fd4e3eb"}, - {file = "aiohttp-3.11.2-cp310-cp310-win32.whl", hash = "sha256:177b000efaf8d2f7012c649e8aee5b0bf488677b1162be5e7511aa4f9d567607"}, - {file = "aiohttp-3.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:ff5d22eece44528023254b595c670dfcf9733ac6af74c4b6cb4f6a784dc3870c"}, - {file = "aiohttp-3.11.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:50e0aee4adc9abcd2109c618a8d1b2c93b85ac277b24a003ab147d91e068b06d"}, - {file = "aiohttp-3.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9aa4e68f1e4f303971ec42976fb170204fb5092de199034b57199a1747e78a2d"}, - {file = "aiohttp-3.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d84930b4145991214602372edd7305fc76b700220db79ac0dd57d3afd0f0a1ca"}, - {file = "aiohttp-3.11.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ec8afd362356b8798c8caa806e91deb3f0602d8ffae8e91d2d3ced2a90c35e"}, - {file = "aiohttp-3.11.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb0544a0e8294a5a5e20d3cacdaaa9a911d7c0a9150f5264aef36e7d8fdfa07e"}, - {file = "aiohttp-3.11.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7b0a1618060e3f5aa73d3526ca2108a16a1b6bf86612cd0bb2ddcbef9879d06"}, - {file = "aiohttp-3.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d878a0186023ac391861958035174d0486f3259cabf8fd94e591985468da3ea"}, - {file = "aiohttp-3.11.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e33a7eddcd07545ccf5c3ab230f60314a17dc33e285475e8405e26e21f02660"}, - {file = "aiohttp-3.11.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4d7fad8c456d180a6d2f44c41cfab4b80e2e81451815825097db48b8293f59d5"}, - {file = "aiohttp-3.11.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d954ba0eae7f33884d27dc00629ca4389d249eb8d26ca07c30911257cae8c96"}, - {file = "aiohttp-3.11.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:afa55e863224e664a782effa62245df73fdfc55aee539bed6efacf35f6d4e4b7"}, - {file = "aiohttp-3.11.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:10a5f91c319d9d4afba812f72984816b5fcd20742232ff7ecc1610ffbf3fc64d"}, - {file = "aiohttp-3.11.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6e8e19a80ba194db5c06915a9df23c0c06e0e9ca9a4db9386a6056cca555a027"}, - {file = "aiohttp-3.11.2-cp311-cp311-win32.whl", hash = "sha256:9c8d1db4f65bbc9d75b7b271d68fb996f1c8c81a525263862477d93611856c2d"}, - {file = "aiohttp-3.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:2adb967454e10e69478ba4a8d8afbba48a7c7a8619216b7c807f8481cc66ddfb"}, - {file = "aiohttp-3.11.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f833a80d9de9307d736b6af58c235b17ef7f90ebea7b9c49cd274dec7a66a2f1"}, - {file = "aiohttp-3.11.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:382f853516664d2ebfc75dc01da4a10fdef5edcb335fe7b45cf471ce758ecb18"}, - {file = "aiohttp-3.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3a2bcf6c81639a165da93469e1e0aff67c956721f3fa9c0560f07dd1e505116"}, - {file = "aiohttp-3.11.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de3b4d5fb5d69749104b880a157f38baeea7765c93d9cd3837cedd5b84729e10"}, - {file = "aiohttp-3.11.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a90a0dc4b054b5af299a900bf950fe8f9e3e54322bc405005f30aa5cacc5c98"}, - {file = "aiohttp-3.11.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32334f35824811dd20a12cc90825d000e6b50faaeaa71408d42269151a66140d"}, - {file = "aiohttp-3.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cba0b8d25aa2d450762f3dd6df85498f5e7c3ad0ddeb516ef2b03510f0eea32"}, - {file = "aiohttp-3.11.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bbb2dbc2701ab7e9307ca3a8fa4999c5b28246968e0a0202a5afabf48a42e22"}, - {file = "aiohttp-3.11.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97fba98fc5d9ccd3d33909e898d00f2494d6a9eec7cbda3d030632e2c8bb4d00"}, - {file = "aiohttp-3.11.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0ebdf5087e2ce903d8220cc45dcece90c2199ae4395fd83ca616fcc81010db2c"}, - {file = "aiohttp-3.11.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:122768e3ae9ce74f981b46edefea9c6e5a40aea38aba3ac50168e6370459bf20"}, - {file = "aiohttp-3.11.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5587da333b7d280a312715b843d43e734652aa382cba824a84a67c81f75b338b"}, - {file = "aiohttp-3.11.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85de9904bc360fd29a98885d2bfcbd4e02ab33c53353cb70607f2bea2cb92468"}, - {file = "aiohttp-3.11.2-cp312-cp312-win32.whl", hash = "sha256:b470de64d17156c37e91effc109d3b032b39867000e2c126732fe01d034441f9"}, - {file = "aiohttp-3.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:3f617a48b70f4843d54f52440ea1e58da6bdab07b391a3a6aed8d3b311a4cc04"}, - {file = "aiohttp-3.11.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5d90b5a3b0f32a5fecf5dd83d828713986c019585f5cddf40d288ff77f366615"}, - {file = "aiohttp-3.11.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d23854e5867650d40cba54d49956aad8081452aa80b2cf0d8c310633f4f48510"}, - {file = "aiohttp-3.11.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:486273d3b5af75a80c31c311988931bdd2a4b96a74d5c7f422bad948f99988ef"}, - {file = "aiohttp-3.11.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9075313f8e41b481e4cb10af405054564b0247dc335db5398ed05f8ec38787e2"}, - {file = "aiohttp-3.11.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:44b69c69c194ffacbc50165911cf023a4b1b06422d1e1199d3aea82eac17004e"}, - {file = "aiohttp-3.11.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b339d91ac9060bd6ecdc595a82dc151045e5d74f566e0864ef3f2ba0887fec42"}, - {file = "aiohttp-3.11.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64e8f5178958a9954043bc8cd10a5ae97352c3f2fc99aa01f2aebb0026010910"}, - {file = "aiohttp-3.11.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3129151378f858cdc4a0a4df355c9a0d060ab49e2eea7e62e9f085bac100551b"}, - {file = "aiohttp-3.11.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:14eb6c628432720e41b4fab1ada879d56cfe7034159849e083eb536b4c2afa99"}, - {file = "aiohttp-3.11.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e57a10aacedcf24666f4c90d03e599f71d172d1c5e00dcf48205c445806745b0"}, - {file = "aiohttp-3.11.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:66e58a2e8c7609a3545c4b38fb8b01a6b8346c4862e529534f7674c5265a97b8"}, - {file = "aiohttp-3.11.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9b6d15adc9768ff167614ca853f7eeb6ee5f1d55d5660e3af85ce6744fed2b82"}, - {file = "aiohttp-3.11.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2914061f5ca573f990ec14191e6998752fa8fe50d518e3405410353c3f44aa5d"}, - {file = "aiohttp-3.11.2-cp313-cp313-win32.whl", hash = "sha256:1c2496182e577042e0e07a328d91c949da9e77a2047c7291071e734cd7a6e780"}, - {file = "aiohttp-3.11.2-cp313-cp313-win_amd64.whl", hash = "sha256:cccb2937bece1310c5c0163d0406aba170a2e5fb1f0444d7b0e7fdc9bd6bb713"}, - {file = "aiohttp-3.11.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:994cb893936dd2e1803655ae8667a45066bfd53360b148e22b4e3325cc5ea7a3"}, - {file = "aiohttp-3.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3666c750b73ce463a413692e3a57c60f7089e2d9116a2aa5a0f0eaf2ae325148"}, - {file = "aiohttp-3.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6ad9a7d2a3a0f235184426425f80bd3b26c66b24fd5fddecde66be30c01ebe6e"}, - {file = "aiohttp-3.11.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c979fc92aba66730b66099cd5becb42d869a26c0011119bc1c2478408a8bf7a"}, - {file = "aiohttp-3.11.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:766d0ebf8703d28f854f945982aa09224d5a27a29594c70d921c43c3930fe7ac"}, - {file = "aiohttp-3.11.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:79efd1ee3827b2f16797e14b1e45021206c3271249b4d0025014466d416d7413"}, - {file = "aiohttp-3.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d6e069b882c1fdcbe5577dc4be372eda705180197140577a4cddb648c29d22e"}, - {file = "aiohttp-3.11.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e9a766c346b2ed7e88937919d84ed64b4ef489dad1d8939f806ee52901dc142"}, - {file = "aiohttp-3.11.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2b02a68b9445c70d7f5c8b578c5f5e5866b1d67ca23eb9e8bc8658ae9e3e2c74"}, - {file = "aiohttp-3.11.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:374baefcb1b6275f350da605951f5f02487a9bc84a574a7d5b696439fabd49a3"}, - {file = "aiohttp-3.11.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d2f991c18132f3e505c108147925372ffe4549173b7c258cf227df1c5977a635"}, - {file = "aiohttp-3.11.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:34f37c59b12bc3afc52bab6fcd9cd3be82ff01c4598a84cbea934ccb3a9c54a0"}, - {file = "aiohttp-3.11.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:33af11eca7bb0f5c6ffaf5e7d9d2336c2448f9c6279b93abdd6f3c35f9ee321f"}, - {file = "aiohttp-3.11.2-cp39-cp39-win32.whl", hash = "sha256:83a70e22e0f6222effe7f29fdeba6c6023f9595e59a0479edacfbd7de4b77bb7"}, - {file = "aiohttp-3.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:c28c1677ea33ccb8b14330560094cc44d3ff4fad617a544fd18beb90403fe0f1"}, - {file = "aiohttp-3.11.2.tar.gz", hash = "sha256:68d1f46f9387db3785508f5225d3acbc5825ca13d9c29f2b5cce203d5863eb79"}, + {file = "aiohttp-3.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7510b3ca2275691875ddf072a5b6cd129278d11fe09301add7d292fc8d3432de"}, + {file = "aiohttp-3.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bfab0d2c3380c588fc925168533edb21d3448ad76c3eadc360ff963019161724"}, + {file = "aiohttp-3.11.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf02dba0f342f3a8228f43fae256aafc21c4bc85bffcf537ce4582e2b1565188"}, + {file = "aiohttp-3.11.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92daedf7221392e7a7984915ca1b0481a94c71457c2f82548414a41d65555e70"}, + {file = "aiohttp-3.11.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2274a7876e03429e3218589a6d3611a194bdce08c3f1e19962e23370b47c0313"}, + {file = "aiohttp-3.11.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8a2e1eae2d2f62f3660a1591e16e543b2498358593a73b193006fb89ee37abc6"}, + {file = "aiohttp-3.11.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:978ec3fb0a42efcd98aae608f58c6cfcececaf0a50b4e86ee3ea0d0a574ab73b"}, + {file = "aiohttp-3.11.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a51f87b27d9219ed4e202ed8d6f1bb96f829e5eeff18db0d52f592af6de6bdbf"}, + {file = "aiohttp-3.11.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:04d1a02a669d26e833c8099992c17f557e3b2fdb7960a0c455d7b1cbcb05121d"}, + {file = "aiohttp-3.11.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3679d5fcbc7f1ab518ab4993f12f80afb63933f6afb21b9b272793d398303b98"}, + {file = "aiohttp-3.11.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a4b24e03d04893b5c8ec9cd5f2f11dc9c8695c4e2416d2ac2ce6c782e4e5ffa5"}, + {file = "aiohttp-3.11.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d9abdfd35ecff1c95f270b7606819a0e2de9e06fa86b15d9080de26594cf4c23"}, + {file = "aiohttp-3.11.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8b5c3e7928a0ad80887a5eba1c1da1830512ddfe7394d805badda45c03db3109"}, + {file = "aiohttp-3.11.6-cp310-cp310-win32.whl", hash = "sha256:913dd9e9378f3c38aeb5c4fb2b8383d6490bc43f3b427ae79f2870651ae08f22"}, + {file = "aiohttp-3.11.6-cp310-cp310-win_amd64.whl", hash = "sha256:4ac26d482c2000c3a59bf757a77adc972828c9d4177b4bd432a46ba682ca7271"}, + {file = "aiohttp-3.11.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:26ac4c960ea8debf557357a172b3ef201f2236a462aefa1bc17683a75483e518"}, + {file = "aiohttp-3.11.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8b1f13ebc99fb98c7c13057b748f05224ccc36d17dee18136c695ef23faaf4ff"}, + {file = "aiohttp-3.11.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4679f1a47516189fab1774f7e45a6c7cac916224c91f5f94676f18d0b64ab134"}, + {file = "aiohttp-3.11.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74491fdb3d140ff561ea2128cb7af9ba0a360067ee91074af899c9614f88a18f"}, + {file = "aiohttp-3.11.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f51e1a90412d387e62aa2d243998c5eddb71373b199d811e6ed862a9f34f9758"}, + {file = "aiohttp-3.11.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:72ab89510511c3bb703d0bb5504787b11e0ed8be928ed2a7cf1cda9280628430"}, + {file = "aiohttp-3.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6681c9e046d99646e8059266688374a063da85b2e4c0ebfa078cda414905d080"}, + {file = "aiohttp-3.11.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a17f8a6d3ab72cbbd137e494d1a23fbd3ea973db39587941f32901bb3c5c350"}, + {file = "aiohttp-3.11.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:867affc7612a314b95f74d93aac550ce0909bc6f0b6c658cc856890f4d326542"}, + {file = "aiohttp-3.11.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:00d894ebd609d5a423acef885bd61e7f6a972153f99c5b3ea45fc01fe909196c"}, + {file = "aiohttp-3.11.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:614c87be9d0d64477d1e4b663bdc5d1534fc0a7ebd23fb08347ab9fd5fe20fd7"}, + {file = "aiohttp-3.11.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:533ed46cf772f28f3bffae81c0573d916a64dee590b5dfaa3f3d11491da05b95"}, + {file = "aiohttp-3.11.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:589884cfbc09813afb1454816b45677e983442e146183143f988f7f5a040791a"}, + {file = "aiohttp-3.11.6-cp311-cp311-win32.whl", hash = "sha256:1da63633ba921669eec3d7e080459d4ceb663752b3dafb2f31f18edd248d2170"}, + {file = "aiohttp-3.11.6-cp311-cp311-win_amd64.whl", hash = "sha256:d778ddda09622e7d83095cc8051698a0084c155a1474bfee9bac27d8613dbc31"}, + {file = "aiohttp-3.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:943a952df105a5305257984e7a1f5c2d0fd8564ff33647693c4d07eb2315446d"}, + {file = "aiohttp-3.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d24ec28b7658970a1f1d98608d67f88376c7e503d9d45ff2ba1949c09f2b358c"}, + {file = "aiohttp-3.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6720e809a660fdb9bec7c168c582e11cfedce339af0a5ca847a5d5b588dce826"}, + {file = "aiohttp-3.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4252d30da0ada6e6841b325869c7ef5104b488e8dd57ec439892abbb8d7b3615"}, + {file = "aiohttp-3.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f65f43ff01b238aa0b5c47962c83830a49577efe31bd37c1400c3d11d8a32835"}, + {file = "aiohttp-3.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4dc5933f6c9b26404444d36babb650664f984b8e5fa0694540e7b7315d11a4ff"}, + {file = "aiohttp-3.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bf546ba0c029dfffc718c4b67748687fd4f341b07b7c8f1719d6a3a46164798"}, + {file = "aiohttp-3.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c351d05bbeae30c088009c0bb3b17dda04fd854f91cc6196c448349cc98f71c3"}, + {file = "aiohttp-3.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:10499079b063576fad1597898de3f9c0a2ce617c19cc7cd6b62fdcff6b408bf7"}, + {file = "aiohttp-3.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:442ee82eda47dd59798d6866ce020fb8d02ea31ac9ac82b3d719ed349e6a9d52"}, + {file = "aiohttp-3.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:86fce9127bc317119b34786d9e9ae8af4508a103158828a535f56d201da6ab19"}, + {file = "aiohttp-3.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:973d26a5537ce5d050302eb3cd876457451745b1da0624cbb483217970e12567"}, + {file = "aiohttp-3.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:532b8f038a4e001137d3600cea5d3439d1881df41bdf44d0f9651264d562fdf0"}, + {file = "aiohttp-3.11.6-cp312-cp312-win32.whl", hash = "sha256:4863c59f748dbe147da82b389931f2a676aebc9d3419813ed5ca32d057c9cb32"}, + {file = "aiohttp-3.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:5d7f481f82c18ac1f7986e31ba6eea9be8b2e2c86f1ef035b6866179b6c5dd68"}, + {file = "aiohttp-3.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:40f502350496ba4c6820816d3164f8a0297b9aa4e95d910da31beb189866a9df"}, + {file = "aiohttp-3.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9072669b0bffb40f1f6977d0b5e8a296edc964f9cefca3a18e68649c214d0ce3"}, + {file = "aiohttp-3.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:518160ecf4e6ffd61715bc9173da0925fcce44ae6c7ca3d3f098fe42585370fb"}, + {file = "aiohttp-3.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f69cc1b45115ac44795b63529aa5caa9674be057f11271f65474127b24fc1ce6"}, + {file = "aiohttp-3.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6be90a6beced41653bda34afc891617c6d9e8276eef9c183f029f851f0a3c3d"}, + {file = "aiohttp-3.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:00c22fe2486308770d22ef86242101d7b0f1e1093ce178f2358f860e5149a551"}, + {file = "aiohttp-3.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2607ebb783e3aeefa017ec8f34b506a727e6b6ab2c4b037d65f0bc7151f4430a"}, + {file = "aiohttp-3.11.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f761d6819870c2a8537f75f3e2fc610b163150cefa01f9f623945840f601b2c"}, + {file = "aiohttp-3.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e44d1bc6c88f5234115011842219ba27698a5f2deee245c963b180080572aaa2"}, + {file = "aiohttp-3.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7e0cb6a1b1f499cb2aa0bab1c9f2169ad6913c735b7447e058e0c29c9e51c0b5"}, + {file = "aiohttp-3.11.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a76b4d4ca34254dca066acff2120811e2a8183997c135fcafa558280f2cc53f3"}, + {file = "aiohttp-3.11.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69051c1e45fb18c0ae4d39a075532ff0b015982e7997f19eb5932eb4a3e05c17"}, + {file = "aiohttp-3.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:aff2ed18274c0bfe0c1d772781c87d5ca97ae50f439729007cec9644ee9b15fe"}, + {file = "aiohttp-3.11.6-cp313-cp313-win32.whl", hash = "sha256:2fbea25f2d44df809a46414a8baafa5f179d9dda7e60717f07bded56300589b3"}, + {file = "aiohttp-3.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:f77bc29a465c0f9f6573d1abe656d385fa673e34efe615bd4acc50899280ee47"}, + {file = "aiohttp-3.11.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:de6123b298d17bca9e53581f50a275b36e10d98e8137eb743ce69ee766dbdfe9"}, + {file = "aiohttp-3.11.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a10200f705f4fff00e148b7f41e5d1d929c7cd4ac523c659171a0ea8284cd6fb"}, + {file = "aiohttp-3.11.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7776ef6901b54dd557128d96c71e412eec0c39ebc07567e405ac98737995aad"}, + {file = "aiohttp-3.11.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e5c2a55583cd91936baf73d223807bb93ace6eb1fe54424782690f2707162ab"}, + {file = "aiohttp-3.11.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b032bd6cf7422583bf44f233f4a1489fee53c6d35920123a208adc54e2aba41e"}, + {file = "aiohttp-3.11.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fe2d99acbc5cf606f75d7347bf3a027c24c27bc052d470fb156f4cfcea5739"}, + {file = "aiohttp-3.11.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84a79c366375c2250934d1238abe5d5ea7754c823a1c7df0c52bf0a2bfded6a9"}, + {file = "aiohttp-3.11.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c33cbbe97dc94a34d1295a7bb68f82727bcbff2b284f73ae7e58ecc05903da97"}, + {file = "aiohttp-3.11.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:19e4fb9ac727834b003338dcdd27dcfe0de4fb44082b01b34ed0ab67c3469fc9"}, + {file = "aiohttp-3.11.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:a97f6b2afbe1d27220c0c14ea978e09fb4868f462ef3d56d810d206bd2e057a2"}, + {file = "aiohttp-3.11.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:c3f7afeea03a9bc49be6053dfd30809cd442cc12627d6ca08babd1c1f9e04ccf"}, + {file = "aiohttp-3.11.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:0d10967600ce5bb69ddcb3e18d84b278efb5199d8b24c3c71a4959c2f08acfd0"}, + {file = "aiohttp-3.11.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:60f2f631b9fe7aa321fa0f0ff3f5d8b9f7f9b72afd4eecef61c33cf1cfea5d58"}, + {file = "aiohttp-3.11.6-cp39-cp39-win32.whl", hash = "sha256:4d2b75333deb5c5f61bac5a48bba3dbc142eebbd3947d98788b6ef9cc48628ae"}, + {file = "aiohttp-3.11.6-cp39-cp39-win_amd64.whl", hash = "sha256:8908c235421972a2e02abcef87d16084aabfe825d14cc9a1debd609b3cfffbea"}, + {file = "aiohttp-3.11.6.tar.gz", hash = "sha256:fd9f55c1b51ae1c20a1afe7216a64a88d38afee063baa23c7fce03757023c999"}, ] [package.dependencies] @@ -1956,13 +1956,13 @@ files = [ [[package]] name = "openai" -version = "1.54.4" +version = "1.55.0" description = "The official Python library for the openai API" optional = false python-versions = ">=3.8" files = [ - {file = "openai-1.54.4-py3-none-any.whl", hash = "sha256:0d95cef99346bf9b6d7fbf57faf61a673924c3e34fa8af84c9ffe04660673a7e"}, - {file = "openai-1.54.4.tar.gz", hash = "sha256:50f3656e45401c54e973fa05dc29f3f0b0d19348d685b2f7ddb4d92bf7b1b6bf"}, + {file = "openai-1.55.0-py3-none-any.whl", hash = "sha256:446e08918f8dd70d8723274be860404c8c7cc46b91b93bbc0ef051f57eb503c1"}, + {file = "openai-1.55.0.tar.gz", hash = "sha256:6c0975ac8540fe639d12b4ff5a8e0bf1424c844c4a4251148f59f06c4b2bd5db"}, ] [package.dependencies] @@ -2384,22 +2384,19 @@ files = [ [[package]] name = "pydantic" -version = "2.9.2" +version = "2.10.0" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"}, - {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"}, + {file = "pydantic-2.10.0-py3-none-any.whl", hash = "sha256:5e7807ba9201bdf61b1b58aa6eb690916c40a47acfb114b1b4fef3e7fd5b30fc"}, + {file = "pydantic-2.10.0.tar.gz", hash = "sha256:0aca0f045ff6e2f097f1fe89521115335f15049eeb8a7bef3dafe4b19a74e289"}, ] [package.dependencies] annotated-types = ">=0.6.0" -pydantic-core = "2.23.4" -typing-extensions = [ - {version = ">=4.6.1", markers = "python_version < \"3.13\""}, - {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, -] +pydantic-core = "2.27.0" +typing-extensions = ">=4.12.2" [package.extras] email = ["email-validator (>=2.0.0)"] @@ -2407,100 +2404,111 @@ timezone = ["tzdata"] [[package]] name = "pydantic-core" -version = "2.23.4" +version = "2.27.0" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"}, - {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f"}, - {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3"}, - {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071"}, - {file = "pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119"}, - {file = "pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f"}, - {file = "pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8"}, - {file = "pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b"}, - {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0"}, - {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64"}, - {file = "pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f"}, - {file = "pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3"}, - {file = "pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231"}, - {file = "pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126"}, - {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e"}, - {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24"}, - {file = "pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84"}, - {file = "pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9"}, - {file = "pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc"}, - {file = "pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327"}, - {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6"}, - {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f"}, - {file = "pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769"}, - {file = "pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5"}, - {file = "pydantic_core-2.23.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555"}, - {file = "pydantic_core-2.23.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12"}, - {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2"}, - {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb"}, - {file = "pydantic_core-2.23.4-cp38-none-win32.whl", hash = "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6"}, - {file = "pydantic_core-2.23.4-cp38-none-win_amd64.whl", hash = "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556"}, - {file = "pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a"}, - {file = "pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55"}, - {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040"}, - {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605"}, - {file = "pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6"}, - {file = "pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e"}, - {file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"}, + {file = "pydantic_core-2.27.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:cd2ac6b919f7fed71b17fe0b4603c092a4c9b5bae414817c9c81d3c22d1e1bcc"}, + {file = "pydantic_core-2.27.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e015833384ca3e1a0565a79f5d953b0629d9138021c27ad37c92a9fa1af7623c"}, + {file = "pydantic_core-2.27.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db72e40628967f6dc572020d04b5f800d71264e0531c6da35097e73bdf38b003"}, + {file = "pydantic_core-2.27.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df45c4073bed486ea2f18757057953afed8dd77add7276ff01bccb79982cf46c"}, + {file = "pydantic_core-2.27.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:836a4bfe0cc6d36dc9a9cc1a7b391265bf6ce9d1eb1eac62ac5139f5d8d9a6fa"}, + {file = "pydantic_core-2.27.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4bf1340ae507f6da6360b24179c2083857c8ca7644aab65807023cf35404ea8d"}, + {file = "pydantic_core-2.27.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ab325fc86fbc077284c8d7f996d904d30e97904a87d6fb303dce6b3de7ebba9"}, + {file = "pydantic_core-2.27.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1da0c98a85a6c6ed702d5556db3b09c91f9b0b78de37b7593e2de8d03238807a"}, + {file = "pydantic_core-2.27.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7b0202ebf2268954090209a84f9897345719e46a57c5f2c9b7b250ca0a9d3e63"}, + {file = "pydantic_core-2.27.0-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:35380671c3c921fe8adf31ad349dc6f7588b7e928dbe44e1093789734f607399"}, + {file = "pydantic_core-2.27.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b4c19525c3538fbc0bbda6229f9682fb8199ce9ac37395880e6952798e00373"}, + {file = "pydantic_core-2.27.0-cp310-none-win32.whl", hash = "sha256:333c840a1303d1474f491e7be0b718226c730a39ead0f7dab2c7e6a2f3855555"}, + {file = "pydantic_core-2.27.0-cp310-none-win_amd64.whl", hash = "sha256:99b2863c1365f43f74199c980a3d40f18a218fbe683dd64e470199db426c4d6a"}, + {file = "pydantic_core-2.27.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4523c4009c3f39d948e01962223c9f5538602e7087a628479b723c939fab262d"}, + {file = "pydantic_core-2.27.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:84af1cf7bfdcbc6fcf5a5f70cc9896205e0350306e4dd73d54b6a18894f79386"}, + {file = "pydantic_core-2.27.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e65466b31be1070b4a5b7dbfbd14b247884cb8e8b79c64fb0f36b472912dbaea"}, + {file = "pydantic_core-2.27.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a5c022bb0d453192426221605efc865373dde43b17822a264671c53b068ac20c"}, + {file = "pydantic_core-2.27.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6bb69bf3b6500f195c3deb69c1205ba8fc3cb21d1915f1f158a10d6b1ef29b6a"}, + {file = "pydantic_core-2.27.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0aa4d1b2eba9a325897308b3124014a142cdccb9f3e016f31d3ebee6b5ea5e75"}, + {file = "pydantic_core-2.27.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e96ca781e0c01e32115912ebdf7b3fb0780ce748b80d7d28a0802fa9fbaf44e"}, + {file = "pydantic_core-2.27.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b872c86d8d71827235c7077461c502feb2db3f87d9d6d5a9daa64287d75e4fa0"}, + {file = "pydantic_core-2.27.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:82e1ad4ca170e8af4c928b67cff731b6296e6a0a0981b97b2eb7c275cc4e15bd"}, + {file = "pydantic_core-2.27.0-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:eb40f828bc2f73f777d1eb8fee2e86cd9692a4518b63b6b5aa8af915dfd3207b"}, + {file = "pydantic_core-2.27.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9a8fbf506fde1529a1e3698198fe64bfbe2e0c09557bc6a7dcf872e7c01fec40"}, + {file = "pydantic_core-2.27.0-cp311-none-win32.whl", hash = "sha256:24f984fc7762ed5f806d9e8c4c77ea69fdb2afd987b4fd319ef06c87595a8c55"}, + {file = "pydantic_core-2.27.0-cp311-none-win_amd64.whl", hash = "sha256:68950bc08f9735306322bfc16a18391fcaac99ded2509e1cc41d03ccb6013cfe"}, + {file = "pydantic_core-2.27.0-cp311-none-win_arm64.whl", hash = "sha256:3eb8849445c26b41c5a474061032c53e14fe92a11a5db969f722a2716cd12206"}, + {file = "pydantic_core-2.27.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8117839a9bdbba86e7f9df57018fe3b96cec934c3940b591b0fd3fbfb485864a"}, + {file = "pydantic_core-2.27.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a291d0b4243a259c8ea7e2b84eb9ccb76370e569298875a7c5e3e71baf49057a"}, + {file = "pydantic_core-2.27.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84e35afd9e10b2698e6f2f32256678cb23ca6c1568d02628033a837638b3ed12"}, + {file = "pydantic_core-2.27.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:58ab0d979c969983cdb97374698d847a4acffb217d543e172838864636ef10d9"}, + {file = "pydantic_core-2.27.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d06b667e53320332be2bf6f9461f4a9b78092a079b8ce8634c9afaa7e10cd9f"}, + {file = "pydantic_core-2.27.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78f841523729e43e3928a364ec46e2e3f80e6625a4f62aca5c345f3f626c6e8a"}, + {file = "pydantic_core-2.27.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:400bf470e4327e920883b51e255617dfe4496d4e80c3fea0b5a5d0bf2c404dd4"}, + {file = "pydantic_core-2.27.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:951e71da6c89d354572098bada5ba5b5dc3a9390c933af8a614e37755d3d1840"}, + {file = "pydantic_core-2.27.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2a51ce96224eadd1845150b204389623c8e129fde5a67a84b972bd83a85c6c40"}, + {file = "pydantic_core-2.27.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:483c2213a609e7db2c592bbc015da58b6c75af7360ca3c981f178110d9787bcf"}, + {file = "pydantic_core-2.27.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:359e7951f04ad35111b5ddce184db3391442345d0ab073aa63a95eb8af25a5ef"}, + {file = "pydantic_core-2.27.0-cp312-none-win32.whl", hash = "sha256:ee7d9d5537daf6d5c74a83b38a638cc001b648096c1cae8ef695b0c919d9d379"}, + {file = "pydantic_core-2.27.0-cp312-none-win_amd64.whl", hash = "sha256:2be0ad541bb9f059954ccf8877a49ed73877f862529575ff3d54bf4223e4dd61"}, + {file = "pydantic_core-2.27.0-cp312-none-win_arm64.whl", hash = "sha256:6e19401742ed7b69e51d8e4df3c03ad5ec65a83b36244479fd70edde2828a5d9"}, + {file = "pydantic_core-2.27.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5f2b19b8d6fca432cb3acf48cf5243a7bf512988029b6e6fd27e9e8c0a204d85"}, + {file = "pydantic_core-2.27.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c86679f443e7085ea55a7376462553996c688395d18ef3f0d3dbad7838f857a2"}, + {file = "pydantic_core-2.27.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:510b11e9c3b1a852876d1ccd8d5903684336d635214148637ceb27366c75a467"}, + {file = "pydantic_core-2.27.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb704155e73b833801c247f39d562229c0303f54770ca14fb1c053acb376cf10"}, + {file = "pydantic_core-2.27.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ce048deb1e033e7a865ca384770bccc11d44179cf09e5193a535c4c2f497bdc"}, + {file = "pydantic_core-2.27.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58560828ee0951bb125c6f2862fbc37f039996d19ceb6d8ff1905abf7da0bf3d"}, + {file = "pydantic_core-2.27.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abb4785894936d7682635726613c44578c420a096729f1978cd061a7e72d5275"}, + {file = "pydantic_core-2.27.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2883b260f7a93235488699d39cbbd94fa7b175d3a8063fbfddd3e81ad9988cb2"}, + {file = "pydantic_core-2.27.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c6fcb3fa3855d583aa57b94cf146f7781d5d5bc06cb95cb3afece33d31aac39b"}, + {file = "pydantic_core-2.27.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e851a051f7260e6d688267eb039c81f05f23a19431bd7dfa4bf5e3cb34c108cd"}, + {file = "pydantic_core-2.27.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:edb1bfd45227dec8d50bc7c7d86463cd8728bcc574f9b07de7369880de4626a3"}, + {file = "pydantic_core-2.27.0-cp313-none-win32.whl", hash = "sha256:678f66462058dd978702db17eb6a3633d634f7aa0deaea61e0a674152766d3fc"}, + {file = "pydantic_core-2.27.0-cp313-none-win_amd64.whl", hash = "sha256:d28ca7066d6cdd347a50d8b725dc10d9a1d6a1cce09836cf071ea6a2d4908be0"}, + {file = "pydantic_core-2.27.0-cp313-none-win_arm64.whl", hash = "sha256:6f4a53af9e81d757756508b57cae1cf28293f0f31b9fa2bfcb416cc7fb230f9d"}, + {file = "pydantic_core-2.27.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:e9f9feee7f334b72ceae46313333d002b56f325b5f04271b4ae2aadd9e993ae4"}, + {file = "pydantic_core-2.27.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:225bfff5d425c34e1fd562cef52d673579d59b967d9de06178850c4802af9039"}, + {file = "pydantic_core-2.27.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c921ad596ff1a82f9c692b0758c944355abc9f0de97a4c13ca60ffc6d8dc15d4"}, + {file = "pydantic_core-2.27.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6354e18a9be37bfa124d6b288a87fb30c673745806c92956f1a25e3ae6e76b96"}, + {file = "pydantic_core-2.27.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ee4c2a75af9fe21269a4a0898c5425afb01af1f5d276063f57e2ae1bc64e191"}, + {file = "pydantic_core-2.27.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c91e3c04f5191fd3fb68764bddeaf02025492d5d9f23343b283870f6ace69708"}, + {file = "pydantic_core-2.27.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a6ebfac28fd51890a61df36ef202adbd77d00ee5aca4a3dadb3d9ed49cfb929"}, + {file = "pydantic_core-2.27.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:36aa167f69d8807ba7e341d67ea93e50fcaaf6bc433bb04939430fa3dab06f31"}, + {file = "pydantic_core-2.27.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3e8d89c276234579cd3d095d5fa2a44eb10db9a218664a17b56363cddf226ff3"}, + {file = "pydantic_core-2.27.0-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:5cc822ab90a70ea3a91e6aed3afac570b276b1278c6909b1d384f745bd09c714"}, + {file = "pydantic_core-2.27.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e15315691fe2253eb447503153acef4d7223dfe7e7702f9ed66539fcd0c43801"}, + {file = "pydantic_core-2.27.0-cp38-none-win32.whl", hash = "sha256:dfa5f5c0a4c8fced1422dc2ca7eefd872d5d13eb33cf324361dbf1dbfba0a9fe"}, + {file = "pydantic_core-2.27.0-cp38-none-win_amd64.whl", hash = "sha256:513cb14c0cc31a4dfd849a4674b20c46d87b364f997bbcb02282306f5e187abf"}, + {file = "pydantic_core-2.27.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:4148dc9184ab79e356dc00a4199dc0ee8647973332cb385fc29a7cced49b9f9c"}, + {file = "pydantic_core-2.27.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5fc72fbfebbf42c0856a824b8b0dc2b5cd2e4a896050281a21cfa6fed8879cb1"}, + {file = "pydantic_core-2.27.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:185ef205256cd8b38431205698531026979db89a79587725c1e55c59101d64e9"}, + {file = "pydantic_core-2.27.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:395e3e1148fa7809016231f8065f30bb0dc285a97b4dc4360cd86e17bab58af7"}, + {file = "pydantic_core-2.27.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:33d14369739c5d07e2e7102cdb0081a1fa46ed03215e07f097b34e020b83b1ae"}, + {file = "pydantic_core-2.27.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e7820bb0d65e3ce1e3e70b6708c2f66143f55912fa02f4b618d0f08b61575f12"}, + {file = "pydantic_core-2.27.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43b61989068de9ce62296cde02beffabcadb65672207fc51e7af76dca75e6636"}, + {file = "pydantic_core-2.27.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:15e350efb67b855cd014c218716feea4986a149ed1f42a539edd271ee074a196"}, + {file = "pydantic_core-2.27.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:433689845288f9a1ee5714444e65957be26d30915f7745091ede4a83cfb2d7bb"}, + {file = "pydantic_core-2.27.0-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:3fd8bc2690e7c39eecdf9071b6a889ce7b22b72073863940edc2a0a23750ca90"}, + {file = "pydantic_core-2.27.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:884f1806609c2c66564082540cffc96868c5571c7c3cf3a783f63f2fb49bd3cd"}, + {file = "pydantic_core-2.27.0-cp39-none-win32.whl", hash = "sha256:bf37b72834e7239cf84d4a0b2c050e7f9e48bced97bad9bdf98d26b8eb72e846"}, + {file = "pydantic_core-2.27.0-cp39-none-win_amd64.whl", hash = "sha256:31a2cae5f059329f9cfe3d8d266d3da1543b60b60130d186d9b6a3c20a346361"}, + {file = "pydantic_core-2.27.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4fb49cfdb53af5041aba909be00cccfb2c0d0a2e09281bf542371c5fd36ad04c"}, + {file = "pydantic_core-2.27.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:49633583eb7dc5cba61aaf7cdb2e9e662323ad394e543ee77af265736bcd3eaa"}, + {file = "pydantic_core-2.27.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:153017e3d6cd3ce979de06d84343ca424bb6092727375eba1968c8b4693c6ecb"}, + {file = "pydantic_core-2.27.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff63a92f6e249514ef35bc795de10745be0226eaea06eb48b4bbeaa0c8850a4a"}, + {file = "pydantic_core-2.27.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5982048129f40b082c2654de10c0f37c67a14f5ff9d37cf35be028ae982f26df"}, + {file = "pydantic_core-2.27.0-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:91bc66f878557313c2a6bcf396e7befcffe5ab4354cfe4427318968af31143c3"}, + {file = "pydantic_core-2.27.0-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:68ef5377eb582fa4343c9d0b57a5b094046d447b4c73dd9fbd9ffb216f829e7d"}, + {file = "pydantic_core-2.27.0-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:c5726eec789ee38f2c53b10b1821457b82274f81f4f746bb1e666d8741fcfadb"}, + {file = "pydantic_core-2.27.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c0c431e4be5c1a0c6654e0c31c661cd89e0ca956ef65305c3c3fd96f4e72ca39"}, + {file = "pydantic_core-2.27.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:8e21d927469d04b39386255bf00d0feedead16f6253dcc85e9e10ddebc334084"}, + {file = "pydantic_core-2.27.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:4b51f964fcbb02949fc546022e56cdb16cda457af485e9a3e8b78ac2ecf5d77e"}, + {file = "pydantic_core-2.27.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a7fd4de38f7ff99a37e18fa0098c3140286451bc823d1746ba80cec5b433a1"}, + {file = "pydantic_core-2.27.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fda87808429c520a002a85d6e7cdadbf58231d60e96260976c5b8f9a12a8e13"}, + {file = "pydantic_core-2.27.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a150392102c402c538190730fda06f3bce654fc498865579a9f2c1d2b425833"}, + {file = "pydantic_core-2.27.0-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c9ed88b398ba7e3bad7bd64d66cc01dcde9cfcb7ec629a6fd78a82fa0b559d78"}, + {file = "pydantic_core-2.27.0-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:9fe94d9d2a2b4edd7a4b22adcd45814b1b59b03feb00e56deb2e89747aec7bfe"}, + {file = "pydantic_core-2.27.0-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:d8b5ee4ae9170e2775d495b81f414cc20268041c42571530513496ba61e94ba3"}, + {file = "pydantic_core-2.27.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d29e235ce13c91902ef3efc3d883a677655b3908b1cbc73dee816e5e1f8f7739"}, + {file = "pydantic_core-2.27.0.tar.gz", hash = "sha256:f57783fbaf648205ac50ae7d646f27582fc706be3977e87c3c124e7a92407b10"}, ] [package.dependencies] @@ -3055,42 +3063,47 @@ test = ["pytest", "tornado (>=4.5)", "typeguard"] [[package]] name = "tiktoken" -version = "0.8.0" +version = "0.7.0" description = "tiktoken is a fast BPE tokeniser for use with OpenAI's models" optional = false -python-versions = ">=3.9" +python-versions = ">=3.8" files = [ - {file = "tiktoken-0.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b07e33283463089c81ef1467180e3e00ab00d46c2c4bbcef0acab5f771d6695e"}, - {file = "tiktoken-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9269348cb650726f44dd3bbb3f9110ac19a8dcc8f54949ad3ef652ca22a38e21"}, - {file = "tiktoken-0.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e13f37bc4ef2d012731e93e0fef21dc3b7aea5bb9009618de9a4026844e560"}, - {file = "tiktoken-0.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f13d13c981511331eac0d01a59b5df7c0d4060a8be1e378672822213da51e0a2"}, - {file = "tiktoken-0.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6b2ddbc79a22621ce8b1166afa9f9a888a664a579350dc7c09346a3b5de837d9"}, - {file = "tiktoken-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d8c2d0e5ba6453a290b86cd65fc51fedf247e1ba170191715b049dac1f628005"}, - {file = "tiktoken-0.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d622d8011e6d6f239297efa42a2657043aaed06c4f68833550cac9e9bc723ef1"}, - {file = "tiktoken-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2efaf6199717b4485031b4d6edb94075e4d79177a172f38dd934d911b588d54a"}, - {file = "tiktoken-0.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5637e425ce1fc49cf716d88df3092048359a4b3bbb7da762840426e937ada06d"}, - {file = "tiktoken-0.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fb0e352d1dbe15aba082883058b3cce9e48d33101bdaac1eccf66424feb5b47"}, - {file = "tiktoken-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:56edfefe896c8f10aba372ab5706b9e3558e78db39dd497c940b47bf228bc419"}, - {file = "tiktoken-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:326624128590def898775b722ccc327e90b073714227175ea8febbc920ac0a99"}, - {file = "tiktoken-0.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:881839cfeae051b3628d9823b2e56b5cc93a9e2efb435f4cf15f17dc45f21586"}, - {file = "tiktoken-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fe9399bdc3f29d428f16a2f86c3c8ec20be3eac5f53693ce4980371c3245729b"}, - {file = "tiktoken-0.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9a58deb7075d5b69237a3ff4bb51a726670419db6ea62bdcd8bd80c78497d7ab"}, - {file = "tiktoken-0.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2908c0d043a7d03ebd80347266b0e58440bdef5564f84f4d29fb235b5df3b04"}, - {file = "tiktoken-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:294440d21a2a51e12d4238e68a5972095534fe9878be57d905c476017bff99fc"}, - {file = "tiktoken-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:d8f3192733ac4d77977432947d563d7e1b310b96497acd3c196c9bddb36ed9db"}, - {file = "tiktoken-0.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:02be1666096aff7da6cbd7cdaa8e7917bfed3467cd64b38b1f112e96d3b06a24"}, - {file = "tiktoken-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94ff53c5c74b535b2cbf431d907fc13c678bbd009ee633a2aca269a04389f9a"}, - {file = "tiktoken-0.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b231f5e8982c245ee3065cd84a4712d64692348bc609d84467c57b4b72dcbc5"}, - {file = "tiktoken-0.8.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4177faa809bd55f699e88c96d9bb4635d22e3f59d635ba6fd9ffedf7150b9953"}, - {file = "tiktoken-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5376b6f8dc4753cd81ead935c5f518fa0fbe7e133d9e25f648d8c4dabdd4bad7"}, - {file = "tiktoken-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:18228d624807d66c87acd8f25fc135665617cab220671eb65b50f5d70fa51f69"}, - {file = "tiktoken-0.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7e17807445f0cf1f25771c9d86496bd8b5c376f7419912519699f3cc4dc5c12e"}, - {file = "tiktoken-0.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:886f80bd339578bbdba6ed6d0567a0d5c6cfe198d9e587ba6c447654c65b8edc"}, - {file = "tiktoken-0.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6adc8323016d7758d6de7313527f755b0fc6c72985b7d9291be5d96d73ecd1e1"}, - {file = "tiktoken-0.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b591fb2b30d6a72121a80be24ec7a0e9eb51c5500ddc7e4c2496516dd5e3816b"}, - {file = "tiktoken-0.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:845287b9798e476b4d762c3ebda5102be87ca26e5d2c9854002825d60cdb815d"}, - {file = "tiktoken-0.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:1473cfe584252dc3fa62adceb5b1c763c1874e04511b197da4e6de51d6ce5a02"}, - {file = "tiktoken-0.8.0.tar.gz", hash = "sha256:9ccbb2740f24542534369c5635cfd9b2b3c2490754a78ac8831d99f89f94eeb2"}, + {file = "tiktoken-0.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:485f3cc6aba7c6b6ce388ba634fbba656d9ee27f766216f45146beb4ac18b25f"}, + {file = "tiktoken-0.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e54be9a2cd2f6d6ffa3517b064983fb695c9a9d8aa7d574d1ef3c3f931a99225"}, + {file = "tiktoken-0.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79383a6e2c654c6040e5f8506f3750db9ddd71b550c724e673203b4f6b4b4590"}, + {file = "tiktoken-0.7.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d4511c52caacf3c4981d1ae2df85908bd31853f33d30b345c8b6830763f769c"}, + {file = "tiktoken-0.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13c94efacdd3de9aff824a788353aa5749c0faee1fbe3816df365ea450b82311"}, + {file = "tiktoken-0.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8e58c7eb29d2ab35a7a8929cbeea60216a4ccdf42efa8974d8e176d50c9a3df5"}, + {file = "tiktoken-0.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:21a20c3bd1dd3e55b91c1331bf25f4af522c525e771691adbc9a69336fa7f702"}, + {file = "tiktoken-0.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:10c7674f81e6e350fcbed7c09a65bca9356eaab27fb2dac65a1e440f2bcfe30f"}, + {file = "tiktoken-0.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:084cec29713bc9d4189a937f8a35dbdfa785bd1235a34c1124fe2323821ee93f"}, + {file = "tiktoken-0.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:811229fde1652fedcca7c6dfe76724d0908775b353556d8a71ed74d866f73f7b"}, + {file = "tiktoken-0.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86b6e7dc2e7ad1b3757e8a24597415bafcfb454cebf9a33a01f2e6ba2e663992"}, + {file = "tiktoken-0.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1063c5748be36344c7e18c7913c53e2cca116764c2080177e57d62c7ad4576d1"}, + {file = "tiktoken-0.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20295d21419bfcca092644f7e2f2138ff947a6eb8cfc732c09cc7d76988d4a89"}, + {file = "tiktoken-0.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:959d993749b083acc57a317cbc643fb85c014d055b2119b739487288f4e5d1cb"}, + {file = "tiktoken-0.7.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:71c55d066388c55a9c00f61d2c456a6086673ab7dec22dd739c23f77195b1908"}, + {file = "tiktoken-0.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:09ed925bccaa8043e34c519fbb2f99110bd07c6fd67714793c21ac298e449410"}, + {file = "tiktoken-0.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03c6c40ff1db0f48a7b4d2dafeae73a5607aacb472fa11f125e7baf9dce73704"}, + {file = "tiktoken-0.7.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d20b5c6af30e621b4aca094ee61777a44118f52d886dbe4f02b70dfe05c15350"}, + {file = "tiktoken-0.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d427614c3e074004efa2f2411e16c826f9df427d3c70a54725cae860f09e4bf4"}, + {file = "tiktoken-0.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8c46d7af7b8c6987fac9b9f61041b452afe92eb087d29c9ce54951280f899a97"}, + {file = "tiktoken-0.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:0bc603c30b9e371e7c4c7935aba02af5994a909fc3c0fe66e7004070858d3f8f"}, + {file = "tiktoken-0.7.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2398fecd38c921bcd68418675a6d155fad5f5e14c2e92fcf5fe566fa5485a858"}, + {file = "tiktoken-0.7.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8f5f6afb52fb8a7ea1c811e435e4188f2bef81b5e0f7a8635cc79b0eef0193d6"}, + {file = "tiktoken-0.7.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:861f9ee616766d736be4147abac500732b505bf7013cfaf019b85892637f235e"}, + {file = "tiktoken-0.7.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54031f95c6939f6b78122c0aa03a93273a96365103793a22e1793ee86da31685"}, + {file = "tiktoken-0.7.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:fffdcb319b614cf14f04d02a52e26b1d1ae14a570f90e9b55461a72672f7b13d"}, + {file = "tiktoken-0.7.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c72baaeaefa03ff9ba9688624143c858d1f6b755bb85d456d59e529e17234769"}, + {file = "tiktoken-0.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:131b8aeb043a8f112aad9f46011dced25d62629091e51d9dc1adbf4a1cc6aa98"}, + {file = "tiktoken-0.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cabc6dc77460df44ec5b879e68692c63551ae4fae7460dd4ff17181df75f1db7"}, + {file = "tiktoken-0.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8d57f29171255f74c0aeacd0651e29aa47dff6f070cb9f35ebc14c82278f3b25"}, + {file = "tiktoken-0.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ee92776fdbb3efa02a83f968c19d4997a55c8e9ce7be821ceee04a1d1ee149c"}, + {file = "tiktoken-0.7.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e215292e99cb41fbc96988ef62ea63bb0ce1e15f2c147a61acc319f8b4cbe5bf"}, + {file = "tiktoken-0.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8a81bac94769cab437dd3ab0b8a4bc4e0f9cf6835bcaa88de71f39af1791727a"}, + {file = "tiktoken-0.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d6d73ea93e91d5ca771256dfc9d1d29f5a554b83821a1dc0891987636e0ae226"}, + {file = "tiktoken-0.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:2bcb28ddf79ffa424f171dfeef9a4daff61a94c631ca6813f43967cb263b83b9"}, + {file = "tiktoken-0.7.0.tar.gz", hash = "sha256:1077266e949c24e0291f6c350433c6f0971365ece2b173a23bc3b9f9defef6b6"}, ] [package.dependencies] @@ -3285,13 +3298,13 @@ urllib3 = ">=1.26.0" [[package]] name = "typer" -version = "0.13.0" +version = "0.13.1" description = "Typer, build great CLIs. Easy to code. Based on Python type hints." optional = false python-versions = ">=3.7" files = [ - {file = "typer-0.13.0-py3-none-any.whl", hash = "sha256:d85fe0b777b2517cc99c8055ed735452f2659cd45e451507c76f48ce5c1d00e2"}, - {file = "typer-0.13.0.tar.gz", hash = "sha256:f1c7198347939361eec90139ffa0fd8b3df3a2259d5852a0f7400e476d95985c"}, + {file = "typer-0.13.1-py3-none-any.whl", hash = "sha256:5b59580fd925e89463a29d363e0a43245ec02765bde9fb77d39e5d0f29dd7157"}, + {file = "typer-0.13.1.tar.gz", hash = "sha256:9d444cb96cc268ce6f8b94e13b4335084cef4c079998a9f4851a90229a3bd25c"}, ] [package.dependencies] @@ -3473,93 +3486,93 @@ files = [ [[package]] name = "yarl" -version = "1.17.1" +version = "1.17.2" description = "Yet another URL library" optional = false python-versions = ">=3.9" files = [ - {file = "yarl-1.17.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b1794853124e2f663f0ea54efb0340b457f08d40a1cef78edfa086576179c91"}, - {file = "yarl-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fbea1751729afe607d84acfd01efd95e3b31db148a181a441984ce9b3d3469da"}, - {file = "yarl-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ee427208c675f1b6e344a1f89376a9613fc30b52646a04ac0c1f6587c7e46ec"}, - {file = "yarl-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b74ff4767d3ef47ffe0cd1d89379dc4d828d4873e5528976ced3b44fe5b0a21"}, - {file = "yarl-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:62a91aefff3d11bf60e5956d340eb507a983a7ec802b19072bb989ce120cd948"}, - {file = "yarl-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:846dd2e1243407133d3195d2d7e4ceefcaa5f5bf7278f0a9bda00967e6326b04"}, - {file = "yarl-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e844be8d536afa129366d9af76ed7cb8dfefec99f5f1c9e4f8ae542279a6dc3"}, - {file = "yarl-1.17.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc7c92c1baa629cb03ecb0c3d12564f172218fb1739f54bf5f3881844daadc6d"}, - {file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ae3476e934b9d714aa8000d2e4c01eb2590eee10b9d8cd03e7983ad65dfbfcba"}, - {file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c7e177c619342e407415d4f35dec63d2d134d951e24b5166afcdfd1362828e17"}, - {file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64cc6e97f14cf8a275d79c5002281f3040c12e2e4220623b5759ea7f9868d6a5"}, - {file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:84c063af19ef5130084db70ada40ce63a84f6c1ef4d3dbc34e5e8c4febb20822"}, - {file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:482c122b72e3c5ec98f11457aeb436ae4aecca75de19b3d1de7cf88bc40db82f"}, - {file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:380e6c38ef692b8fd5a0f6d1fa8774d81ebc08cfbd624b1bca62a4d4af2f9931"}, - {file = "yarl-1.17.1-cp310-cp310-win32.whl", hash = "sha256:16bca6678a83657dd48df84b51bd56a6c6bd401853aef6d09dc2506a78484c7b"}, - {file = "yarl-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:561c87fea99545ef7d692403c110b2f99dced6dff93056d6e04384ad3bc46243"}, - {file = "yarl-1.17.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cbad927ea8ed814622305d842c93412cb47bd39a496ed0f96bfd42b922b4a217"}, - {file = "yarl-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fca4b4307ebe9c3ec77a084da3a9d1999d164693d16492ca2b64594340999988"}, - {file = "yarl-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ff5c6771c7e3511a06555afa317879b7db8d640137ba55d6ab0d0c50425cab75"}, - {file = "yarl-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b29beab10211a746f9846baa39275e80034e065460d99eb51e45c9a9495bcca"}, - {file = "yarl-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a52a1ffdd824fb1835272e125385c32fd8b17fbdefeedcb4d543cc23b332d74"}, - {file = "yarl-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58c8e9620eb82a189c6c40cb6b59b4e35b2ee68b1f2afa6597732a2b467d7e8f"}, - {file = "yarl-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d216e5d9b8749563c7f2c6f7a0831057ec844c68b4c11cb10fc62d4fd373c26d"}, - {file = "yarl-1.17.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:881764d610e3269964fc4bb3c19bb6fce55422828e152b885609ec176b41cf11"}, - {file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8c79e9d7e3d8a32d4824250a9c6401194fb4c2ad9a0cec8f6a96e09a582c2cc0"}, - {file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:299f11b44d8d3a588234adbe01112126010bd96d9139c3ba7b3badd9829261c3"}, - {file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:cc7d768260f4ba4ea01741c1b5fe3d3a6c70eb91c87f4c8761bbcce5181beafe"}, - {file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:de599af166970d6a61accde358ec9ded821234cbbc8c6413acfec06056b8e860"}, - {file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2b24ec55fad43e476905eceaf14f41f6478780b870eda5d08b4d6de9a60b65b4"}, - {file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9fb815155aac6bfa8d86184079652c9715c812d506b22cfa369196ef4e99d1b4"}, - {file = "yarl-1.17.1-cp311-cp311-win32.whl", hash = "sha256:7615058aabad54416ddac99ade09a5510cf77039a3b903e94e8922f25ed203d7"}, - {file = "yarl-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:14bc88baa44e1f84164a392827b5defb4fa8e56b93fecac3d15315e7c8e5d8b3"}, - {file = "yarl-1.17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:327828786da2006085a4d1feb2594de6f6d26f8af48b81eb1ae950c788d97f61"}, - {file = "yarl-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cc353841428d56b683a123a813e6a686e07026d6b1c5757970a877195f880c2d"}, - {file = "yarl-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c73df5b6e8fabe2ddb74876fb82d9dd44cbace0ca12e8861ce9155ad3c886139"}, - {file = "yarl-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bdff5e0995522706c53078f531fb586f56de9c4c81c243865dd5c66c132c3b5"}, - {file = "yarl-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:06157fb3c58f2736a5e47c8fcbe1afc8b5de6fb28b14d25574af9e62150fcaac"}, - {file = "yarl-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1654ec814b18be1af2c857aa9000de7a601400bd4c9ca24629b18486c2e35463"}, - {file = "yarl-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f6595c852ca544aaeeb32d357e62c9c780eac69dcd34e40cae7b55bc4fb1147"}, - {file = "yarl-1.17.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:459e81c2fb920b5f5df744262d1498ec2c8081acdcfe18181da44c50f51312f7"}, - {file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e48cdb8226644e2fbd0bdb0a0f87906a3db07087f4de77a1b1b1ccfd9e93685"}, - {file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d9b6b28a57feb51605d6ae5e61a9044a31742db557a3b851a74c13bc61de5172"}, - {file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e594b22688d5747b06e957f1ef822060cb5cb35b493066e33ceac0cf882188b7"}, - {file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5f236cb5999ccd23a0ab1bd219cfe0ee3e1c1b65aaf6dd3320e972f7ec3a39da"}, - {file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a2a64e62c7a0edd07c1c917b0586655f3362d2c2d37d474db1a509efb96fea1c"}, - {file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d0eea830b591dbc68e030c86a9569826145df485b2b4554874b07fea1275a199"}, - {file = "yarl-1.17.1-cp312-cp312-win32.whl", hash = "sha256:46ddf6e0b975cd680eb83318aa1d321cb2bf8d288d50f1754526230fcf59ba96"}, - {file = "yarl-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:117ed8b3732528a1e41af3aa6d4e08483c2f0f2e3d3d7dca7cf538b3516d93df"}, - {file = "yarl-1.17.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5d1d42556b063d579cae59e37a38c61f4402b47d70c29f0ef15cee1acaa64488"}, - {file = "yarl-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0167540094838ee9093ef6cc2c69d0074bbf84a432b4995835e8e5a0d984374"}, - {file = "yarl-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2f0a6423295a0d282d00e8701fe763eeefba8037e984ad5de44aa349002562ac"}, - {file = "yarl-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5b078134f48552c4d9527db2f7da0b5359abd49393cdf9794017baec7506170"}, - {file = "yarl-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d401f07261dc5aa36c2e4efc308548f6ae943bfff20fcadb0a07517a26b196d8"}, - {file = "yarl-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5f1ac7359e17efe0b6e5fec21de34145caef22b260e978336f325d5c84e6938"}, - {file = "yarl-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f63d176a81555984e91f2c84c2a574a61cab7111cc907e176f0f01538e9ff6e"}, - {file = "yarl-1.17.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e275792097c9f7e80741c36de3b61917aebecc08a67ae62899b074566ff8556"}, - {file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:81713b70bea5c1386dc2f32a8f0dab4148a2928c7495c808c541ee0aae614d67"}, - {file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:aa46dce75078fceaf7cecac5817422febb4355fbdda440db55206e3bd288cfb8"}, - {file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1ce36ded585f45b1e9bb36d0ae94765c6608b43bd2e7f5f88079f7a85c61a4d3"}, - {file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2d374d70fdc36f5863b84e54775452f68639bc862918602d028f89310a034ab0"}, - {file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2d9f0606baaec5dd54cb99667fcf85183a7477f3766fbddbe3f385e7fc253299"}, - {file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b0341e6d9a0c0e3cdc65857ef518bb05b410dbd70d749a0d33ac0f39e81a4258"}, - {file = "yarl-1.17.1-cp313-cp313-win32.whl", hash = "sha256:2e7ba4c9377e48fb7b20dedbd473cbcbc13e72e1826917c185157a137dac9df2"}, - {file = "yarl-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:949681f68e0e3c25377462be4b658500e85ca24323d9619fdc41f68d46a1ffda"}, - {file = "yarl-1.17.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8994b29c462de9a8fce2d591028b986dbbe1b32f3ad600b2d3e1c482c93abad6"}, - {file = "yarl-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f9cbfbc5faca235fbdf531b93aa0f9f005ec7d267d9d738761a4d42b744ea159"}, - {file = "yarl-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b40d1bf6e6f74f7c0a567a9e5e778bbd4699d1d3d2c0fe46f4b717eef9e96b95"}, - {file = "yarl-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5efe0661b9fcd6246f27957f6ae1c0eb29bc60552820f01e970b4996e016004"}, - {file = "yarl-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b5c4804e4039f487e942c13381e6c27b4b4e66066d94ef1fae3f6ba8b953f383"}, - {file = "yarl-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5d6a6c9602fd4598fa07e0389e19fe199ae96449008d8304bf5d47cb745462e"}, - {file = "yarl-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f4c9156c4d1eb490fe374fb294deeb7bc7eaccda50e23775b2354b6a6739934"}, - {file = "yarl-1.17.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6324274b4e0e2fa1b3eccb25997b1c9ed134ff61d296448ab8269f5ac068c4c"}, - {file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d8a8b74d843c2638f3864a17d97a4acda58e40d3e44b6303b8cc3d3c44ae2d29"}, - {file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:7fac95714b09da9278a0b52e492466f773cfe37651cf467a83a1b659be24bf71"}, - {file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c180ac742a083e109c1a18151f4dd8675f32679985a1c750d2ff806796165b55"}, - {file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:578d00c9b7fccfa1745a44f4eddfdc99d723d157dad26764538fbdda37209857"}, - {file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:1a3b91c44efa29e6c8ef8a9a2b583347998e2ba52c5d8280dbd5919c02dfc3b5"}, - {file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a7ac5b4984c468ce4f4a553df281450df0a34aefae02e58d77a0847be8d1e11f"}, - {file = "yarl-1.17.1-cp39-cp39-win32.whl", hash = "sha256:7294e38f9aa2e9f05f765b28ffdc5d81378508ce6dadbe93f6d464a8c9594473"}, - {file = "yarl-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:eb6dce402734575e1a8cc0bb1509afca508a400a57ce13d306ea2c663bad1138"}, - {file = "yarl-1.17.1-py3-none-any.whl", hash = "sha256:f1790a4b1e8e8e028c391175433b9c8122c39b46e1663228158e61e6f915bf06"}, - {file = "yarl-1.17.1.tar.gz", hash = "sha256:067a63fcfda82da6b198fa73079b1ca40b7c9b7994995b6ee38acda728b64d47"}, + {file = "yarl-1.17.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:93771146ef048b34201bfa382c2bf74c524980870bb278e6df515efaf93699ff"}, + {file = "yarl-1.17.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8281db240a1616af2f9c5f71d355057e73a1409c4648c8949901396dc0a3c151"}, + {file = "yarl-1.17.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:170ed4971bf9058582b01a8338605f4d8c849bd88834061e60e83b52d0c76870"}, + {file = "yarl-1.17.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc61b005f6521fcc00ca0d1243559a5850b9dd1e1fe07b891410ee8fe192d0c0"}, + {file = "yarl-1.17.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:871e1b47eec7b6df76b23c642a81db5dd6536cbef26b7e80e7c56c2fd371382e"}, + {file = "yarl-1.17.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a58a2f2ca7aaf22b265388d40232f453f67a6def7355a840b98c2d547bd037f"}, + {file = "yarl-1.17.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:736bb076f7299c5c55dfef3eb9e96071a795cb08052822c2bb349b06f4cb2e0a"}, + {file = "yarl-1.17.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8fd51299e21da709eabcd5b2dd60e39090804431292daacbee8d3dabe39a6bc0"}, + {file = "yarl-1.17.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:358dc7ddf25e79e1cc8ee16d970c23faee84d532b873519c5036dbb858965795"}, + {file = "yarl-1.17.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:50d866f7b1a3f16f98603e095f24c0eeba25eb508c85a2c5939c8b3870ba2df8"}, + {file = "yarl-1.17.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:8b9c4643e7d843a0dca9cd9d610a0876e90a1b2cbc4c5ba7930a0d90baf6903f"}, + {file = "yarl-1.17.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d63123bfd0dce5f91101e77c8a5427c3872501acece8c90df457b486bc1acd47"}, + {file = "yarl-1.17.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:4e76381be3d8ff96a4e6c77815653063e87555981329cf8f85e5be5abf449021"}, + {file = "yarl-1.17.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:734144cd2bd633a1516948e477ff6c835041c0536cef1d5b9a823ae29899665b"}, + {file = "yarl-1.17.2-cp310-cp310-win32.whl", hash = "sha256:26bfb6226e0c157af5da16d2d62258f1ac578d2899130a50433ffee4a5dfa673"}, + {file = "yarl-1.17.2-cp310-cp310-win_amd64.whl", hash = "sha256:76499469dcc24759399accd85ec27f237d52dec300daaca46a5352fcbebb1071"}, + {file = "yarl-1.17.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:792155279dc093839e43f85ff7b9b6493a8eaa0af1f94f1f9c6e8f4de8c63500"}, + {file = "yarl-1.17.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:38bc4ed5cae853409cb193c87c86cd0bc8d3a70fd2268a9807217b9176093ac6"}, + {file = "yarl-1.17.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4a8c83f6fcdc327783bdc737e8e45b2e909b7bd108c4da1892d3bc59c04a6d84"}, + {file = "yarl-1.17.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c6d5fed96f0646bfdf698b0a1cebf32b8aae6892d1bec0c5d2d6e2df44e1e2d"}, + {file = "yarl-1.17.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:782ca9c58f5c491c7afa55518542b2b005caedaf4685ec814fadfcee51f02493"}, + {file = "yarl-1.17.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ff6af03cac0d1a4c3c19e5dcc4c05252411bf44ccaa2485e20d0a7c77892ab6e"}, + {file = "yarl-1.17.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a3f47930fbbed0f6377639503848134c4aa25426b08778d641491131351c2c8"}, + {file = "yarl-1.17.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1fa68a3c921365c5745b4bd3af6221ae1f0ea1bf04b69e94eda60e57958907f"}, + {file = "yarl-1.17.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:187df91395c11e9f9dc69b38d12406df85aa5865f1766a47907b1cc9855b6303"}, + {file = "yarl-1.17.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:93d1c8cc5bf5df401015c5e2a3ce75a5254a9839e5039c881365d2a9dcfc6dc2"}, + {file = "yarl-1.17.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:11d86c6145ac5c706c53d484784cf504d7d10fa407cb73b9d20f09ff986059ef"}, + {file = "yarl-1.17.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c42774d1d1508ec48c3ed29e7b110e33f5e74a20957ea16197dbcce8be6b52ba"}, + {file = "yarl-1.17.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8e589379ef0407b10bed16cc26e7392ef8f86961a706ade0a22309a45414d7"}, + {file = "yarl-1.17.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1056cadd5e850a1c026f28e0704ab0a94daaa8f887ece8dfed30f88befb87bb0"}, + {file = "yarl-1.17.2-cp311-cp311-win32.whl", hash = "sha256:be4c7b1c49d9917c6e95258d3d07f43cfba2c69a6929816e77daf322aaba6628"}, + {file = "yarl-1.17.2-cp311-cp311-win_amd64.whl", hash = "sha256:ac8eda86cc75859093e9ce390d423aba968f50cf0e481e6c7d7d63f90bae5c9c"}, + {file = "yarl-1.17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dd90238d3a77a0e07d4d6ffdebc0c21a9787c5953a508a2231b5f191455f31e9"}, + {file = "yarl-1.17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c74f0b0472ac40b04e6d28532f55cac8090e34c3e81f118d12843e6df14d0909"}, + {file = "yarl-1.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d486ddcaca8c68455aa01cf53d28d413fb41a35afc9f6594a730c9779545876"}, + {file = "yarl-1.17.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f25b7e93f5414b9a983e1a6c1820142c13e1782cc9ed354c25e933aebe97fcf2"}, + {file = "yarl-1.17.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a0baff7827a632204060f48dca9e63fbd6a5a0b8790c1a2adfb25dc2c9c0d50"}, + {file = "yarl-1.17.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:460024cacfc3246cc4d9f47a7fc860e4fcea7d1dc651e1256510d8c3c9c7cde0"}, + {file = "yarl-1.17.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5870d620b23b956f72bafed6a0ba9a62edb5f2ef78a8849b7615bd9433384171"}, + {file = "yarl-1.17.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2941756754a10e799e5b87e2319bbec481ed0957421fba0e7b9fb1c11e40509f"}, + {file = "yarl-1.17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9611b83810a74a46be88847e0ea616794c406dbcb4e25405e52bff8f4bee2d0a"}, + {file = "yarl-1.17.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cd7e35818d2328b679a13268d9ea505c85cd773572ebb7a0da7ccbca77b6a52e"}, + {file = "yarl-1.17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6b981316fcd940f085f646b822c2ff2b8b813cbd61281acad229ea3cbaabeb6b"}, + {file = "yarl-1.17.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:688058e89f512fb7541cb85c2f149c292d3fa22f981d5a5453b40c5da49eb9e8"}, + {file = "yarl-1.17.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56afb44a12b0864d17b597210d63a5b88915d680f6484d8d202ed68ade38673d"}, + {file = "yarl-1.17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:17931dfbb84ae18b287279c1f92b76a3abcd9a49cd69b92e946035cff06bcd20"}, + {file = "yarl-1.17.2-cp312-cp312-win32.whl", hash = "sha256:ff8d95e06546c3a8c188f68040e9d0360feb67ba8498baf018918f669f7bc39b"}, + {file = "yarl-1.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:4c840cc11163d3c01a9d8aad227683c48cd3e5be5a785921bcc2a8b4b758c4f3"}, + {file = "yarl-1.17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3294f787a437cb5d81846de3a6697f0c35ecff37a932d73b1fe62490bef69211"}, + {file = "yarl-1.17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f1e7fedb09c059efee2533119666ca7e1a2610072076926fa028c2ba5dfeb78c"}, + {file = "yarl-1.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:da9d3061e61e5ae3f753654813bc1cd1c70e02fb72cf871bd6daf78443e9e2b1"}, + {file = "yarl-1.17.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91c012dceadc695ccf69301bfdccd1fc4472ad714fe2dd3c5ab4d2046afddf29"}, + {file = "yarl-1.17.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f11fd61d72d93ac23718d393d2a64469af40be2116b24da0a4ca6922df26807e"}, + {file = "yarl-1.17.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46c465ad06971abcf46dd532f77560181387b4eea59084434bdff97524444032"}, + {file = "yarl-1.17.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef6eee1a61638d29cd7c85f7fd3ac7b22b4c0fabc8fd00a712b727a3e73b0685"}, + {file = "yarl-1.17.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4434b739a8a101a837caeaa0137e0e38cb4ea561f39cb8960f3b1e7f4967a3fc"}, + {file = "yarl-1.17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:752485cbbb50c1e20908450ff4f94217acba9358ebdce0d8106510859d6eb19a"}, + {file = "yarl-1.17.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:17791acaa0c0f89323c57da7b9a79f2174e26d5debbc8c02d84ebd80c2b7bff8"}, + {file = "yarl-1.17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5c6ea72fe619fee5e6b5d4040a451d45d8175f560b11b3d3e044cd24b2720526"}, + {file = "yarl-1.17.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db5ac3871ed76340210fe028f535392f097fb31b875354bcb69162bba2632ef4"}, + {file = "yarl-1.17.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7a1606ba68e311576bcb1672b2a1543417e7e0aa4c85e9e718ba6466952476c0"}, + {file = "yarl-1.17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9bc27dd5cfdbe3dc7f381b05e6260ca6da41931a6e582267d5ca540270afeeb2"}, + {file = "yarl-1.17.2-cp313-cp313-win32.whl", hash = "sha256:52492b87d5877ec405542f43cd3da80bdcb2d0c2fbc73236526e5f2c28e6db28"}, + {file = "yarl-1.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:8e1bf59e035534ba4077f5361d8d5d9194149f9ed4f823d1ee29ef3e8964ace3"}, + {file = "yarl-1.17.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c556fbc6820b6e2cda1ca675c5fa5589cf188f8da6b33e9fc05b002e603e44fa"}, + {file = "yarl-1.17.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f2f44a4247461965fed18b2573f3a9eb5e2c3cad225201ee858726cde610daca"}, + {file = "yarl-1.17.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3a3ede8c248f36b60227eb777eac1dbc2f1022dc4d741b177c4379ca8e75571a"}, + {file = "yarl-1.17.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2654caaf5584449d49c94a6b382b3cb4a246c090e72453493ea168b931206a4d"}, + {file = "yarl-1.17.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d41c684f286ce41fa05ab6af70f32d6da1b6f0457459a56cf9e393c1c0b2217"}, + {file = "yarl-1.17.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2270d590997445a0dc29afa92e5534bfea76ba3aea026289e811bf9ed4b65a7f"}, + {file = "yarl-1.17.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18662443c6c3707e2fc7fad184b4dc32dd428710bbe72e1bce7fe1988d4aa654"}, + {file = "yarl-1.17.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:75ac158560dec3ed72f6d604c81090ec44529cfb8169b05ae6fcb3e986b325d9"}, + {file = "yarl-1.17.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1fee66b32e79264f428dc8da18396ad59cc48eef3c9c13844adec890cd339db5"}, + {file = "yarl-1.17.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:585ce7cd97be8f538345de47b279b879e091c8b86d9dbc6d98a96a7ad78876a3"}, + {file = "yarl-1.17.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c019abc2eca67dfa4d8fb72ba924871d764ec3c92b86d5b53b405ad3d6aa56b0"}, + {file = "yarl-1.17.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:c6e659b9a24d145e271c2faf3fa6dd1fcb3e5d3f4e17273d9e0350b6ab0fe6e2"}, + {file = "yarl-1.17.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:d17832ba39374134c10e82d137e372b5f7478c4cceeb19d02ae3e3d1daed8721"}, + {file = "yarl-1.17.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:bc3003710e335e3f842ae3fd78efa55f11a863a89a72e9a07da214db3bf7e1f8"}, + {file = "yarl-1.17.2-cp39-cp39-win32.whl", hash = "sha256:f5ffc6b7ace5b22d9e73b2a4c7305740a339fbd55301d52735f73e21d9eb3130"}, + {file = "yarl-1.17.2-cp39-cp39-win_amd64.whl", hash = "sha256:48e424347a45568413deec6f6ee2d720de2cc0385019bedf44cd93e8638aa0ed"}, + {file = "yarl-1.17.2-py3-none-any.whl", hash = "sha256:dd7abf4f717e33b7487121faf23560b3a50924f80e4bef62b22dab441ded8f3b"}, + {file = "yarl-1.17.2.tar.gz", hash = "sha256:753eaaa0c7195244c84b5cc159dc8204b7fd99f716f11198f999f2332a86b178"}, ] [package.dependencies] @@ -3589,4 +3602,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "3604f19ac9d9dd28454528f2623f2b638bbd985d12810f4d99934d2bd11a3294" +content-hash = "12084fbca319156982a26115e12c65410ac08b2e18678a27c0b807d5f375866c" diff --git a/pyproject.toml b/pyproject.toml index 0e622361..54a6b10d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bespokelabs-curator" -version = "0.1.9post1" +version = "0.1.11" description = "Bespoke Labs Curator" authors = ["Bespoke Labs "] readme = "README.md" @@ -29,12 +29,12 @@ pandas = "2.2.2" xxhash = "^3.5.0" tqdm = "^4.67.0" matplotlib = "^3.9.2" -aiofiles = "^24.1.0" -tiktoken = "^0.8.0" nest-asyncio = "^1.6.0" rich = "^13.7.0" litellm = "^1.52.11" isort = "^5.13.2" +tiktoken = ">=0.7.0,<0.8.0" +aiofiles = ">=22.0,<24.0" [tool.poetry.group.dev.dependencies] black = "^24.2.0" @@ -49,3 +49,6 @@ curator-viewer = "bespokelabs.curator.viewer.__main__:main" [tool.black] line-length = 100 + +[tool.pytest.ini_options] +asyncio_default_fixture_loop_scope = "function" diff --git a/src/bespokelabs/curator/prompter/prompter.py b/src/bespokelabs/curator/prompter/prompter.py index f0b5e9c9..61e9e99e 100644 --- a/src/bespokelabs/curator/prompter/prompter.py +++ b/src/bespokelabs/curator/prompter/prompter.py @@ -21,16 +21,53 @@ from bespokelabs.curator.request_processor.openai_online_request_processor import ( OpenAIOnlineRequestProcessor, ) +from bespokelabs.curator.request_processor.litellm_online_request_processor import ( + LiteLLMOnlineRequestProcessor, +) _CURATOR_DEFAULT_CACHE_DIR = "~/.cache/curator" T = TypeVar("T") -logger = logging.getLogger(__name__) +logger = logger = logging.getLogger(__name__) class Prompter: """Interface for prompting LLMs.""" + @staticmethod + def _determine_backend( + model_name: str, response_format: Optional[Type[BaseModel]] = None + ) -> str: + """Determine which backend to use based on model name and response format. + + Args: + model_name (str): Name of the model + response_format (Optional[Type[BaseModel]]): Response format if specified + + Returns: + str: Backend to use ("openai" or "litellm") + """ + model_name = model_name.lower() + + # GPT-4o models with response format should use OpenAI + if ( + response_format + and OpenAIOnlineRequestProcessor(model_name).check_structured_output_support() + ): + logger.info(f"Requesting structured output from {model_name}, using OpenAI backend") + return "openai" + + # GPT models and O1 models without response format should use OpenAI + if not response_format and any(x in model_name for x in ["gpt-", "o1-preview", "o1-mini"]): + logger.info(f"Requesting text output from {model_name}, using OpenAI backend") + return "openai" + + # Default to LiteLLM for all other cases + logger.info( + f"Requesting {f'structured' if response_format else 'text'} output from {model_name}, using LiteLLM backend" + ) + return "litellm" + def __init__( self, model_name: str, @@ -45,8 +82,12 @@ def __init__( ] ] = None, response_format: Optional[Type[BaseModel]] = None, + backend: Optional[str] = None, batch: bool = False, batch_size: Optional[int] = None, + batch_check_interval: Optional[int] = 60, + delete_successful_batch_files: bool = True, + delete_failed_batch_files: bool = False, # To allow users to debug failed batches temperature: Optional[float] = None, top_p: Optional[float] = None, presence_penalty: Optional[float] = None, @@ -62,6 +103,7 @@ def __init__( response object and returns the parsed output response_format (Optional[Type[BaseModel]]): A Pydantic model specifying the response format from the LLM. + backend (Optional[str]): The backend to use ("openai" or "litellm"). If None, will be auto-determined batch (bool): Whether to use batch processing batch_size (Optional[int]): The size of the batch to use, only used if batch is True temperature (Optional[float]): The temperature to use for the LLM, only used if batch is False @@ -86,48 +128,82 @@ def __init__( model_name, prompt_func, parse_func, response_format ) self.batch_mode = batch - if batch: - if batch_size is None: - batch_size = 1_000 - logger.info( - f"batch=True but no batch_size provided, using default batch_size of {batch_size:,}" - ) - self._request_processor = OpenAIBatchRequestProcessor( - model=model_name, - batch_size=batch_size, - temperature=temperature, - top_p=top_p, - presence_penalty=presence_penalty, - frequency_penalty=frequency_penalty, - ) + + # Auto-determine backend if not specified + # Use provided backend or auto-determine based on model and format + if backend is not None: + self.backend = backend else: - if batch_size is not None: + self.backend = self._determine_backend(model_name, response_format) + + # Select request processor based on backend + if self.backend == "openai": + if batch: + if batch_size is None: + batch_size = 1_000 + logger.info( + f"batch=True but no batch_size provided, using default batch_size of {batch_size:,}" + ) + self._request_processor = OpenAIBatchRequestProcessor( + model=model_name, + batch_size=batch_size, + temperature=temperature, + top_p=top_p, + batch_check_interval=batch_check_interval, + presence_penalty=presence_penalty, + frequency_penalty=frequency_penalty, + delete_successful_batch_files=delete_successful_batch_files, + delete_failed_batch_files=delete_failed_batch_files, + ) + else: + if batch_size is not None: + logger.warning( + f"Prompter argument `batch_size` {batch_size} is ignored because `batch` is False" + ) + self._request_processor = OpenAIOnlineRequestProcessor( + model=model_name, + temperature=temperature, + top_p=top_p, + presence_penalty=presence_penalty, + frequency_penalty=frequency_penalty, + ) + elif self.backend == "litellm": + if batch: logger.warning( - f"Prompter argument `batch_size` {batch_size} is ignored because `batch` is False" + "Batch mode is not supported with LiteLLM backend, ignoring batch=True" ) - self._request_processor = OpenAIOnlineRequestProcessor( + self._request_processor = LiteLLMOnlineRequestProcessor( model=model_name, temperature=temperature, top_p=top_p, presence_penalty=presence_penalty, frequency_penalty=frequency_penalty, ) + else: + raise ValueError(f"Unknown backend: {self.backend}") - def __call__(self, dataset: Optional[Iterable] = None, working_dir: str = None) -> Dataset: + def __call__( + self, + dataset: Optional[Iterable] = None, + working_dir: str = None, + batch_cancel: bool = False, + ) -> Dataset: """ Run completions on a dataset. Args: dataset (Iterable): A dataset consisting of a list of items to apply completions working_dir (str): The working directory to save the requests.jsonl, responses.jsonl, and dataset.arrow files. + batch_cancel (bool): Whether to cancel batches """ - return self._completions(self._request_processor, dataset, working_dir) + return self._completions(self._request_processor, dataset, working_dir, batch_cancel) def _completions( self, request_processor: BaseRequestProcessor, dataset: Optional[Iterable] = None, working_dir: str = None, + batch_cancel: bool = False, ) -> Dataset: """ Apply structured completions in parallel to a dataset using specified model and @@ -176,10 +252,13 @@ def _completions( else "text" ), str(self.batch_mode), + str(self.backend), ] ) - fingerprint = xxh64(fingerprint_str.encode("utf-8")).hexdigest() + logger.debug(f"Curator Cache Fingerprint String: {fingerprint_str}") + logger.debug(f"Curator Cache Fingerprint: {fingerprint}") + metadata_db_path = os.path.join(curator_cache_dir, "metadata.db") metadata_db = MetadataDB(metadata_db_path) @@ -206,12 +285,22 @@ def _completions( } metadata_db.store_metadata(metadata_dict) - dataset = request_processor.run( - dataset=dataset, - working_dir=os.path.join(curator_cache_dir, fingerprint), - parse_func_hash=parse_func_hash, - prompt_formatter=self.prompt_formatter, - ) + run_cache_dir = os.path.join(curator_cache_dir, fingerprint) + + if batch_cancel: + if type(request_processor) != OpenAIBatchRequestProcessor: + raise ValueError("batch_cancel can only be used with batch mode") + + dataset = request_processor.cancel_batches( + working_dir=run_cache_dir, + ) + else: + dataset = request_processor.run( + dataset=dataset, + working_dir=run_cache_dir, + parse_func_hash=parse_func_hash, + prompt_formatter=self.prompt_formatter, + ) return dataset diff --git a/src/bespokelabs/curator/request_processor/base_online_request_processor.py b/src/bespokelabs/curator/request_processor/base_online_request_processor.py new file mode 100644 index 00000000..7e95cbc0 --- /dev/null +++ b/src/bespokelabs/curator/request_processor/base_online_request_processor.py @@ -0,0 +1,475 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +import datetime +import time +from typing import Optional +from tqdm import tqdm +import logging +import asyncio +import aiohttp +import os +import json +import resource + +from bespokelabs.curator.dataset import Dataset +from bespokelabs.curator.request_processor.base_request_processor import BaseRequestProcessor +from bespokelabs.curator.prompter.prompter import PromptFormatter +from bespokelabs.curator.request_processor.generic_request import GenericRequest +from bespokelabs.curator.request_processor.event_loop import run_in_event_loop +from bespokelabs.curator.request_processor.generic_response import GenericResponse +import aiofiles + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +@dataclass +class StatusTracker: + """Tracks the status of all requests.""" + + num_tasks_started: int = 0 + num_tasks_in_progress: int = 0 + num_tasks_succeeded: int = 0 + num_tasks_failed: int = 0 + num_tasks_already_completed: int = 0 + num_api_errors: int = 0 + num_other_errors: int = 0 + num_rate_limit_errors: int = 0 + available_request_capacity: float = 0 + available_token_capacity: float = 0 + last_update_time: float = field(default_factory=time.time) + max_requests_per_minute: int = 0 + max_tokens_per_minute: int = 0 + pbar: tqdm = field(default=None) + response_cost: float = 0 + time_of_last_rate_limit_error: float = field(default=None) + + def __str__(self): + return ( + f"Tasks - Started: {self.num_tasks_started}, " + f"In Progress: {self.num_tasks_in_progress}, " + f"Succeeded: {self.num_tasks_succeeded}, " + f"Failed: {self.num_tasks_failed}, " + f"Already Completed: {self.num_tasks_already_completed}\n" + f"Errors - API: {self.num_api_errors}, " + f"Rate Limit: {self.num_rate_limit_errors}, " + f"Other: {self.num_other_errors}, " + f"Total: {self.num_other_errors + self.num_api_errors + self.num_rate_limit_errors}" + ) + + def update_capacity(self): + """Update available capacity based on time elapsed""" + current_time = time.time() + seconds_since_update = current_time - self.last_update_time + + self.available_request_capacity = min( + self.available_request_capacity + + self.max_requests_per_minute * seconds_since_update / 60.0, + self.max_requests_per_minute, + ) + + self.available_token_capacity = min( + self.available_token_capacity + + self.max_tokens_per_minute * seconds_since_update / 60.0, + self.max_tokens_per_minute, + ) + + self.last_update_time = current_time + + def has_capacity(self, token_estimate: int) -> bool: + """Check if there's enough capacity for a request""" + self.update_capacity() + has_capacity = ( + self.available_request_capacity >= 1 and self.available_token_capacity >= token_estimate + ) + if not has_capacity: + logger.debug( + f"No capacity for request with {token_estimate} tokens. " + f"Available capacity: {self.available_token_capacity} tokens, " + f"{self.available_request_capacity} requests." + ) + return has_capacity + + def consume_capacity(self, token_estimate: int): + """Consume capacity for a request""" + self.available_request_capacity -= 1 + self.available_token_capacity -= token_estimate + + +@dataclass +class APIRequest: + """Stores an API request's inputs, outputs, and other metadata.""" + + task_id: int + generic_request: GenericRequest + api_specific_request: dict + attempts_left: int + result: list = field(default_factory=list) + prompt_formatter: PromptFormatter = field(default=None) + created_at: datetime.datetime = field(default_factory=datetime.datetime.now) + + +class BaseOnlineRequestProcessor(BaseRequestProcessor, ABC): + """Abstract base class for online request processors that make real-time API calls.""" + + def __init__( + self, + model: str, + temperature: Optional[float] = None, + top_p: Optional[float] = None, + presence_penalty: Optional[float] = None, + frequency_penalty: Optional[float] = None, + ): + super().__init__(batch_size=None) + self.model: str = model + self.temperature: float | None = temperature + self.top_p: float | None = top_p + self.presence_penalty: float | None = presence_penalty + self.frequency_penalty: float | None = frequency_penalty + self.prompt_formatter: Optional[PromptFormatter] = None + + @abstractmethod + def estimate_total_tokens(self, messages: list) -> int: + """Estimate total tokens for a request""" + pass + + @abstractmethod + def estimate_output_tokens(self) -> int: + """Estimate output tokens for a request""" + pass + + def check_structured_output_support(self) -> bool: + """Check if the model supports structured output""" + return True + + def run( + self, + dataset: Optional[Dataset], + working_dir: str, + parse_func_hash: str, + prompt_formatter: PromptFormatter, + ) -> Dataset: + """Run completions using the online API with async processing.""" + logger.info(f"Running {self.__class__.__name__} completions with model: {self.model}") + + self.prompt_formatter = prompt_formatter + if self.prompt_formatter.response_format: + if not self.check_structured_output_support(): + raise ValueError( + f"Model {self.model} does not support structured output, " + f"response_format: {self.prompt_formatter.response_format}" + ) + generic_request_files = self.create_request_files(dataset, working_dir, prompt_formatter) + generic_responses_files = [ + f"{working_dir}/responses_{i}.jsonl" for i in range(len(generic_request_files)) + ] + + for request_file, response_file in zip(generic_request_files, generic_responses_files): + run_in_event_loop( + self.process_requests_from_file( + generic_request_filepath=request_file, + save_filepath=response_file, + max_attempts=5, + resume=True, + ) + ) + + return self.create_dataset_files(working_dir, parse_func_hash, prompt_formatter) + + async def process_requests_from_file( + self, + generic_request_filepath: str, + save_filepath: str, + max_attempts: int, + resume: bool, + resume_no_retry: bool = False, + ) -> None: + """Processes API requests in parallel, throttling to stay under rate limits.""" + + # Initialize trackers + queue_of_requests_to_retry: asyncio.Queue[APIRequest] = asyncio.Queue() + status_tracker = StatusTracker() + + # Get rate limits + rate_limits = self.get_rate_limits() + status_tracker.max_requests_per_minute = rate_limits["max_requests_per_minute"] + status_tracker.max_tokens_per_minute = rate_limits["max_tokens_per_minute"] + rpm = rate_limits["max_requests_per_minute"] + + soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE) + resource.setrlimit( + resource.RLIMIT_NOFILE, + (min(hard, int(10 * status_tracker.max_requests_per_minute)), hard), + ) + + # Track completed requests for resume functionality + completed_request_ids = set() + if os.path.exists(save_filepath): + if resume: + logger.debug(f"Resuming progress from existing file: {save_filepath}") + logger.debug( + f"Removing all failed requests from {save_filepath} so they can be retried" + ) + temp_filepath = f"{save_filepath}.temp" + num_previously_failed_requests = 0 + + with open(save_filepath, "r") as input_file, open( + temp_filepath, "w" + ) as output_file: + for line in input_file: + response = GenericResponse.model_validate_json(line) + if response.response_errors: + logger.debug( + f"Request {response.generic_request.original_row_idx} previously failed due to errors: " + f"{response.response_errors}, removing from output and will retry" + ) + num_previously_failed_requests += 1 + else: + completed_request_ids.add(response.generic_request.original_row_idx) + output_file.write(line) + + logger.info( + f"Found {len(completed_request_ids)} completed requests and " + f"{num_previously_failed_requests} previously failed requests" + ) + logger.info("Failed requests and remaining requests will now be processed.") + os.replace(temp_filepath, save_filepath) + + elif resume_no_retry: + logger.warning( + f"Resuming progress from existing file: {save_filepath}, without retrying failed requests" + ) + num_previously_failed_requests = 0 + + with open(save_filepath, "r") as input_file: + for line in input_file: + response = GenericResponse.model_validate_json(line) + if response.response_errors: + logger.debug( + f"Request {response.generic_request.original_row_idx} previously failed due to errors: " + f"{response.response_errors}, will NOT retry" + ) + num_previously_failed_requests += 1 + completed_request_ids.add(response.generic_request.original_row_idx) + + logger.info( + f"Found {len(completed_request_ids)} total requests and " + f"{num_previously_failed_requests} previously failed requests" + ) + logger.info("Remaining requests will now be processed.") + + else: + user_input = input( + f"File {save_filepath} already exists.\n" + f"To resume if there are remaining requests without responses, run with --resume flag.\n" + f"Overwrite? (Y/n): " + ) + if user_input.lower() not in ["y", ""]: + logger.info("Aborting operation.") + return + + # Count total requests + total_requests = sum(1 for _ in open(generic_request_filepath)) + + # Create progress bar + status_tracker.pbar = tqdm( + initial=len(completed_request_ids), + total=total_requests, + desc=f"Processing {self.__class__.__name__} requests", + ) + + # Use higher connector limit for better throughput + connector = aiohttp.TCPConnector(limit=10 * rpm) + async with aiohttp.ClientSession( + connector=connector + ) as session: # Initialize ClientSession here + async with aiofiles.open(generic_request_filepath) as file: + pending_requests = [] + + async for line in file: + generic_request = GenericRequest.model_validate_json(line) + + if resume and generic_request.original_row_idx in completed_request_ids: + status_tracker.num_tasks_already_completed += 1 + continue + + request = APIRequest( + task_id=status_tracker.num_tasks_started, + generic_request=generic_request, + api_specific_request=self.create_api_specific_request(generic_request), + attempts_left=max_attempts, + prompt_formatter=self.prompt_formatter, + ) + + token_estimate = self.estimate_total_tokens(request.generic_request.messages) + + # Wait for capacity if needed + while not status_tracker.has_capacity(token_estimate): + await asyncio.sleep(0.1) + + # Consume capacity before making request + status_tracker.consume_capacity(token_estimate) + + task = asyncio.create_task( + self.handle_single_request_with_retries( + request=request, + session=session, + retry_queue=queue_of_requests_to_retry, + save_filepath=save_filepath, + status_tracker=status_tracker, + ) + ) + pending_requests.append(task) + + status_tracker.num_tasks_started += 1 + status_tracker.num_tasks_in_progress += 1 + + # Wait for all tasks to complete + if pending_requests: + await asyncio.gather(*pending_requests) + + # Process any remaining retries in the queue + pending_retries = set() + while not queue_of_requests_to_retry.empty() or pending_retries: + # Process new items from the queue if we have capacity + if not queue_of_requests_to_retry.empty(): + retry_request = await queue_of_requests_to_retry.get() + token_estimate = self.estimate_total_tokens( + retry_request.generic_request.messages + ) + attempt_number = 6 - retry_request.attempts_left + logger.info( + f"Processing retry for request {retry_request.task_id} " + f"(attempt #{attempt_number} of 5). " + f"Previous errors: {retry_request.result}" + ) + + # Wait for capacity if needed + while not status_tracker.has_capacity(token_estimate): + await asyncio.sleep(0.1) + + # Consume capacity before making request + status_tracker.consume_capacity(token_estimate) + + task = asyncio.create_task( + self.handle_single_request_with_retries( + request=retry_request, + session=session, + retry_queue=queue_of_requests_to_retry, + save_filepath=save_filepath, + status_tracker=status_tracker, + ) + ) + pending_retries.add(task) + + # Wait for some tasks to complete + if pending_retries: + done, pending_retries = await asyncio.wait(pending_retries, timeout=0.1) + + status_tracker.pbar.close() + + # Log final status + logger.info(f"Processing complete. Results saved to {save_filepath}") + logger.info(f"Status tracker: {status_tracker}") + + if status_tracker.num_tasks_failed > 0: + logger.warning( + f"{status_tracker.num_tasks_failed} / {status_tracker.num_tasks_started} " + f"requests failed. Errors logged to {save_filepath}." + ) + + async def handle_single_request_with_retries( + self, + request: APIRequest, + session: aiohttp.ClientSession, + retry_queue: asyncio.Queue, + save_filepath: str, + status_tracker: StatusTracker, + ) -> None: + """Common wrapper for handling a single request with error handling and retries. + + This method implements the common try/except logic and retry mechanism, + while delegating the actual API call to call_single_request. + + Args: + request (APIRequest): The request to process + session (aiohttp.ClientSession): Async HTTP session + retry_queue (asyncio.Queue): Queue for failed requests + save_filepath (str): Path to save responses + status_tracker (StatusTracker): Tracks request status + """ + try: + generic_response = await self.call_single_request( + request=request, + session=session, + status_tracker=status_tracker, + ) + + # Save response in the base class + await self.append_generic_response(generic_response, save_filepath) + + status_tracker.num_tasks_in_progress -= 1 + status_tracker.num_tasks_succeeded += 1 + status_tracker.pbar.update(1) + + except Exception as e: + logger.warning( + f"Request {request.task_id} failed with Exception {e}, attempts left {request.attempts_left}" + ) + status_tracker.num_other_errors += 1 + request.result.append(e) + + if request.attempts_left > 0: + request.attempts_left -= 1 + # Add retry queue logging + logger.info( + f"Adding request {request.task_id} to retry queue. Will retry in next available slot. " + f"Attempts remaining: {request.attempts_left}" + ) + retry_queue.put_nowait(request) + else: + logger.error( + f"Request {request.task_id} failed permanently after exhausting all 5 retry attempts. " + f"Errors: {[str(e) for e in request.result]}" + ) + generic_response = GenericResponse( + response_message=None, + response_errors=[str(e) for e in request.result], + raw_request=request.api_specific_request, + raw_response=None, + generic_request=request.generic_request, + created_at=request.created_at, + finished_at=datetime.datetime.now(), + ) + await self.append_generic_response(generic_response, save_filepath) + status_tracker.num_tasks_in_progress -= 1 + status_tracker.num_tasks_failed += 1 + + @abstractmethod + async def call_single_request( + self, + request: APIRequest, + session: aiohttp.ClientSession, + status_tracker: StatusTracker, + ) -> GenericResponse: + """Make a single API request without error handling. + + This method should implement the actual API call logic + without handling retries or errors. + + Args: + request (APIRequest): Request to process + session (aiohttp.ClientSession): Async HTTP session + status_tracker (StatusTracker): Tracks request status + + Returns: + GenericResponse: The response from the API call + """ + pass + + async def append_generic_response(self, data: GenericResponse, filename: str) -> None: + """Append a response to a jsonl file with async file operations.""" + json_string = json.dumps(data.model_dump(), default=str) + async with aiofiles.open(filename, "a") as f: + await f.write(json_string + "\n") + logger.debug(f"Successfully appended response to {filename}") diff --git a/src/bespokelabs/curator/request_processor/base_request_processor.py b/src/bespokelabs/curator/request_processor/base_request_processor.py index d1f0b4e9..a19fbf8f 100644 --- a/src/bespokelabs/curator/request_processor/base_request_processor.py +++ b/src/bespokelabs/curator/request_processor/base_request_processor.py @@ -3,6 +3,7 @@ import json import logging import os +import resource from abc import ABC, abstractmethod from math import ceil from typing import Optional @@ -18,7 +19,9 @@ from bespokelabs.curator.request_processor.generic_request import GenericRequest from bespokelabs.curator.request_processor.generic_response import GenericResponse -logger = logging.getLogger(__name__) +logger = logger = logging.getLogger(__name__) + +CACHE_MSG = "If you want to regenerate the dataset, disable or delete the cache." class BaseRequestProcessor(ABC): @@ -28,6 +31,13 @@ class BaseRequestProcessor(ABC): def __init__(self, batch_size: Optional[int] = None): self.batch_size = batch_size + # Increase the number of open file descriptors to avoid "Too many open files" errors + soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE) + desired_limit = min(10_000_000, hard) + logger.debug( + f"Adjusting file descriptor limit from {soft} to {desired_limit} (hard limit: {hard})" + ) + resource.setrlimit(resource.RLIMIT_NOFILE, (desired_limit, hard)) @abstractmethod def get_rate_limits(self) -> dict: @@ -55,7 +65,6 @@ def create_api_specific_request(self, generic_request: GenericRequest) -> dict: """ pass - @abstractmethod def run( self, dataset: Dataset, @@ -92,16 +101,13 @@ def create_request_files( list[str]: Paths to the request files that were created. """ os.makedirs(working_dir, exist_ok=True) - requests_files = glob.glob(f"{working_dir}/requests_*.jsonl") + request_files = glob.glob(f"{working_dir}/requests_*.jsonl") # By default use existing requests in working_dir - if len(requests_files) > 0: - logger.info( - f"Using existing requests in {working_dir} by default. Found {len(requests_files)} request files." - f"If this is not what you want, delete the directory or specify a new one and re-run." - ) + if len(request_files) > 0: + logger.info(f"Using cached requests. {CACHE_MSG}") # count existing jobs in file and print first job - with open(requests_files[0], "r") as f: + with open(request_files[0], "r") as f: # Count lines and store first job first_job = None num_jobs = 0 @@ -111,32 +117,32 @@ def create_request_files( num_jobs = i + 1 if num_jobs > 0: - logger.info(f"There are {num_jobs} existing requests in {requests_files[0]}") - logger.info( - f"Example request in {requests_files[0]}:\n{json.dumps(first_job, default=str, indent=2)}" + logger.debug( + f"There are {num_jobs} existing requests in {request_files[0]}\n" + f"Example request in {request_files[0]}:\n{json.dumps(first_job, default=str, indent=2)}" ) - return requests_files + return request_files # Create new requests file - requests_file = f"{working_dir}/requests_0.jsonl" - requests_files = [requests_file] + request_file = f"{working_dir}/requests_0.jsonl" + request_files = [request_file] if dataset is None: - with open(requests_file, "w") as f: + with open(request_file, "w") as f: generic_request = prompt_formatter.create_generic_request(dict(), 0) f.write(json.dumps(generic_request.model_dump(), default=str) + "\n") - return requests_files + return request_files if self.batch_size: num_batches = ceil(len(dataset) / self.batch_size) - requests_files = [f"{working_dir}/requests_{i}.jsonl" for i in range(num_batches)] + request_files = [f"{working_dir}/requests_{i}.jsonl" for i in range(num_batches)] async def create_all_request_files(): tasks = [ self.acreate_request_file( dataset, prompt_formatter, - requests_files[i], + request_files[i], start_idx=i * self.batch_size, ) for i in range(num_batches) @@ -145,9 +151,9 @@ async def create_all_request_files(): run_in_event_loop(create_all_request_files()) else: - run_in_event_loop(self.acreate_request_file(dataset, prompt_formatter, requests_file)) + run_in_event_loop(self.acreate_request_file(dataset, prompt_formatter, request_file)) - return requests_files + return request_files # NOTE(Ryan): Instead of doing this, just iterate over iterable and keep counter and change filename when hit batch_size, this will be slower but this whole thing is dominated by llm calls anyways async def acreate_request_file( @@ -172,12 +178,30 @@ async def acreate_request_file( await f.write(json.dumps(request.model_dump(), default=str) + "\n") logger.info(f"Wrote {end_idx - start_idx} requests to {request_file}.") + def attempt_loading_cached_dataset( + self, working_dir: str, parse_func_hash: str + ) -> Optional[Dataset]: + dataset_file = f"{working_dir}/{parse_func_hash}.arrow" + if os.path.exists(dataset_file): + logger.debug(f"Loading dataset from {dataset_file}") + try: + output_dataset = Dataset.from_file(dataset_file) + logger.info(f"Using cached output dataset. {CACHE_MSG}") + return output_dataset + except pyarrow.lib.ArrowInvalid as e: + os.remove(dataset_file) + logger.warning( + f"Failed to load dataset from {dataset_file}, " + "which was likely corrupted by a failed previous run. " + "Deleted file and attempting to regenerate dataset from cached LLM responses." + ) + def create_dataset_files( self, working_dir: str, parse_func_hash: str, prompt_formatter: PromptFormatter, - ) -> None: + ) -> Dataset: """ Creates the request files if they don't already exist or use existing. A single request file (requests_0.jsonl) or multiple request files @@ -198,31 +222,15 @@ def create_dataset_files( responses_files = glob.glob(f"{working_dir}/responses_*.jsonl") if len(responses_files) == 0: raise ValueError(f"No responses files found in {working_dir}") - dataset_file = f"{working_dir}/{parse_func_hash}.arrow" - if os.path.exists(dataset_file): - logger.info(f"Using existing dataset file {dataset_file}") - successful_dataset_cache_load = False - try: - output_dataset = Dataset.from_file(dataset_file) - successful_dataset_cache_load = True - except pyarrow.lib.ArrowInvalid as e: - os.remove(dataset_file) - logger.warning( - f"Failed to load dataset from {dataset_file}, " - "which was likely corrupted by a failed previous run. " - "Deleted file and attempting to regenerate dataset from cached LLM responses." - ) - - if successful_dataset_cache_load: - return output_dataset error_help = ( - f"Please check your `parse_func` is returning a valid row (dict) " + "Please check your `parse_func` is returning a valid row (dict) " "or list of rows (list of dicts) and re-run. " "Dataset will be regenerated from cached LLM responses." ) # Process all response files + dataset_file = f"{working_dir}/{parse_func_hash}.arrow" with ArrowWriter(path=dataset_file) as writer: for responses_file in responses_files: with open(responses_file, "r") as f_in: @@ -239,8 +247,24 @@ def create_dataset_files( # Response message is a string, which is converted to a dict # The dict is then used to construct the response_format Pydantic model try: + # First try to parse the response message as JSON + if isinstance(response.response_message, str): + try: + response_dict = json.loads(response.response_message) + except json.JSONDecodeError as e: + warning_msg = ( + f"Failed to parse response message as JSON: {response.response_message}. " + f"The model likely returned an invalid JSON format. Will skip this response." + ) + logger.warning(warning_msg) + failed_responses_count += 1 + continue + else: + response_dict = response.response_message + + # Then construct the Pydantic model from the parsed dict response.response_message = prompt_formatter.response_format( - **response.response_message + **response_dict ) except ValidationError as e: schema_str = json.dumps( @@ -248,7 +272,7 @@ def create_dataset_files( indent=2, ) warning_msg = ( - f"Pydantic failed to parse response message {response.response_message} with `response_format` {schema_str}." + f"Pydantic failed to parse response message {response.response_message} with `response_format` {schema_str}. " f"The model likely returned a JSON that does not match the schema of the `response_format`. Will skip this response." ) logger.warning(warning_msg) @@ -298,9 +322,7 @@ def create_dataset_files( writer.finalize() - output_dataset = Dataset.from_file(dataset_file) - - return output_dataset + return Dataset.from_file(dataset_file) def parse_response_message( diff --git a/src/bespokelabs/curator/request_processor/litellm_online_request_processor.py b/src/bespokelabs/curator/request_processor/litellm_online_request_processor.py new file mode 100644 index 00000000..4b346fcf --- /dev/null +++ b/src/bespokelabs/curator/request_processor/litellm_online_request_processor.py @@ -0,0 +1,264 @@ +import logging +from typing import Optional +import asyncio +import aiohttp +import litellm +from litellm import get_supported_openai_params +import datetime +import instructor +from bespokelabs.curator.request_processor.base_online_request_processor import ( + BaseOnlineRequestProcessor, + APIRequest, + StatusTracker, +) +from bespokelabs.curator.request_processor.generic_request import GenericRequest +from bespokelabs.curator.request_processor.generic_response import TokenUsage, GenericResponse +from pydantic import BaseModel +from bespokelabs.curator.prompter.prompt_formatter import PromptFormatter + +logger = logging.getLogger(__name__) + +litellm.suppress_debug_info = True + + +class LiteLLMOnlineRequestProcessor(BaseOnlineRequestProcessor): + """LiteLLM implementation of the OnlineRequestProcessor for multi-provider LLM support. + + This processor uses LiteLLM to handle requests across different LLM providers (OpenAI, Anthropic, etc.) + with unified interface and structured output support via instructor. + + Features: + - Multi-provider support through LiteLLM + - Structured output via instructor + - Automatic token counting and rate limiting + - Cost tracking per request + + Attributes: + model (str): The model identifier (e.g., "gpt-4", "claude-2") + temperature (Optional[float]): Temperature for response randomness + top_p (Optional[float]): Top-p sampling parameter + presence_penalty (Optional[float]): Presence penalty for response diversity + frequency_penalty (Optional[float]): Frequency penalty for response diversity + client: Instructor-wrapped LiteLLM client for structured outputs + """ + + def __init__( + self, + model: str, + temperature: Optional[float] = None, + top_p: Optional[float] = None, + presence_penalty: Optional[float] = None, + frequency_penalty: Optional[float] = None, + ): + super().__init__( + model=model, + temperature=temperature, + top_p=top_p, + presence_penalty=presence_penalty, + frequency_penalty=frequency_penalty, + ) + self.client = instructor.from_litellm(litellm.acompletion) + + def check_structured_output_support(self): + """Verify if the model supports structured output via instructor. + + Tests the model's capability to handle structured output by making a test request + with a simple schema. + + Returns: + bool: True if structured output is supported, False otherwise + + Note: + - Uses a simple User schema as test case + - Logs detailed information about support status + - Required for models that will use JSON schema responses + """ + + class User(BaseModel): + name: str + age: int + + try: + client = instructor.from_litellm(litellm.completion) + response = client.chat.completions.create( + model=self.model, + messages=[{"role": "user", "content": "Jason is 25 years old."}], + response_model=User, + ) + logger.info(f"Check instructor structure output response: {response}") + assert isinstance(response, User) + logger.info( + f"Model {self.model} supports structured output via instructor, response: {response}" + ) + return True + except instructor.exceptions.InstructorRetryException as e: + if "litellm.AuthenticationError" in str(e): + logger.warning(f"Please provide a valid API key for model {self.model}.") + raise e + else: + logger.warning( + f"Model {self.model} does not support structured output via instructor: {e} {type(e)} {e.__cause__}" + ) + return False + + def estimate_output_tokens(self) -> int: + """Estimate the number of tokens in the model's response. + + Uses LiteLLM's get_max_tokens and applies a conservative estimate + by dividing by 4 to avoid hitting context limits. + + Returns: + int: Estimated number of output tokens + + Note: + Falls back to 0 if token estimation fails + """ + try: + return litellm.get_max_tokens(model=self.model) // 4 + except Exception: + return 0 + + def estimate_total_tokens(self, messages: list) -> int: + """Calculate the total token usage for a request. + + Uses LiteLLM's token_counter for accurate input token counting + and adds estimated output tokens. + + Args: + messages (list): List of message dictionaries + + Returns: + int: Total estimated tokens (input + output) + """ + input_tokens = litellm.token_counter(model=self.model, messages=messages) + output_tokens = self.estimate_output_tokens() + return input_tokens + output_tokens + + def get_rate_limits(self) -> dict: + """Retrieve rate limits from the LLM provider via LiteLLM. + + Makes a test request to get rate limit information from response headers. + + Returns: + dict: Contains 'max_requests_per_minute' and 'max_tokens_per_minute' + + Note: + - Falls back to default values if headers are missing + - Some providers (e.g., Claude) require non-empty messages + """ + logger.info(f"Getting rate limits for model: {self.model}") + + completion = litellm.completion( + model=self.model, + messages=[ + {"role": "user", "content": "hi"} + ], # Some models (e.g. Claude) require an non-empty message to get rate limits. + ) + + headers = completion._hidden_params.get("additional_headers", {}) + logger.info(f"Rate limit headers: {headers}") + + rpm = int(headers.get("x-ratelimit-limit-requests", 3000)) + tpm = int(headers.get("x-ratelimit-limit-tokens", 150_000)) + + logger.info(f"Rate limits - Requests/min: {rpm}, Tokens/min: {tpm}") + + return {"max_requests_per_minute": rpm, "max_tokens_per_minute": tpm} + + def create_api_specific_request(self, generic_request: GenericRequest) -> dict: + """Convert a generic request into a LiteLLM-compatible format. + + Checks supported parameters for the specific model and only includes + applicable parameters. + + Args: + generic_request (GenericRequest): The generic request to convert + + Returns: + dict: LiteLLM-compatible request parameters + + Note: + Uses LiteLLM's get_supported_openai_params to check parameter support + """ + # Get supported parameters for this model + supported_params = get_supported_openai_params(model=self.model) + request = { + "model": generic_request.model, + "messages": generic_request.messages, + } + + # Only add parameters that are supported by this model + if "temperature" in supported_params and self.temperature is not None: + request["temperature"] = self.temperature + + if "top_p" in supported_params and self.top_p is not None: + request["top_p"] = self.top_p + + if "presence_penalty" in supported_params and self.presence_penalty is not None: + request["presence_penalty"] = self.presence_penalty + + if "frequency_penalty" in supported_params and self.frequency_penalty is not None: + request["frequency_penalty"] = self.frequency_penalty + + return request + + async def call_single_request( + self, + request: APIRequest, + session: aiohttp.ClientSession, + status_tracker: StatusTracker, + ) -> GenericResponse: + """Make a single request through LiteLLM. + + Handles both structured and unstructured outputs, tracks token usage + and costs. + + Args: + request (APIRequest): Request to process + session (aiohttp.ClientSession): Async HTTP session + status_tracker (StatusTracker): Tracks request status + + Returns: + GenericResponse: The response from LiteLLM + """ + # Get response directly without extra logging + if request.generic_request.response_format: + response, completion_obj = await self.client.chat.completions.create_with_completion( + **request.api_specific_request, + response_model=request.prompt_formatter.response_format, + timeout=60.0, + ) + response_message = ( + response.model_dump() if hasattr(response, "model_dump") else response + ) + else: + completion_obj = await litellm.acompletion(**request.api_specific_request, timeout=60.0) + response_message = completion_obj["choices"][0]["message"]["content"] + + # Extract token usage + usage = completion_obj.usage if hasattr(completion_obj, "usage") else {} + token_usage = TokenUsage( + prompt_tokens=usage.prompt_tokens, + completion_tokens=usage.completion_tokens, + total_tokens=usage.total_tokens, + ) + + # Calculate cost using litellm + try: + cost = litellm.completion_cost(completion_response=completion_obj.model_dump()) + except litellm.NotFoundError as e: + logger.info(f"LiteLLM does not support cost estimation for model {self.model}: {e}") + cost = 0 + + # Create and return response + return GenericResponse( + response_message=response_message, + response_errors=None, + raw_request=request.api_specific_request, + raw_response=completion_obj.model_dump(), + generic_request=request.generic_request, + created_at=request.created_at, + finished_at=datetime.datetime.now(), + token_usage=token_usage, + response_cost=cost, + ) diff --git a/src/bespokelabs/curator/request_processor/openai_batch_request_processor.py b/src/bespokelabs/curator/request_processor/openai_batch_request_processor.py index e6289ed2..6312a2d7 100644 --- a/src/bespokelabs/curator/request_processor/openai_batch_request_processor.py +++ b/src/bespokelabs/curator/request_processor/openai_batch_request_processor.py @@ -1,11 +1,13 @@ import asyncio import datetime +import glob import json import logging -import os -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Callable -import aiofiles +import glob +import os import litellm from openai import AsyncOpenAI from openai.types import Batch @@ -27,16 +29,22 @@ MAX_REQUESTS_PER_BATCH = 50_000 MAX_BYTES_PER_BATCH = 200 * 1024 * 1024 +# NOTE(Ryan): This allows us to stay under the rate limit when submitting ~1,000 batches at a time +# When submitting >1,000 batches the batch submission and batch download operations get rate limited +MAX_CONCURRENT_BATCH_OPERATIONS = 100 +MAX_RETRIES_PER_OPERATION = 50 + class OpenAIBatchRequestProcessor(BaseRequestProcessor): def __init__( self, batch_size: int, model: str, + delete_successful_batch_files: bool, + delete_failed_batch_files: bool, temperature: float | None = None, top_p: float | None = None, - check_interval: int = 10, - api_key: str = os.getenv("OPENAI_API_KEY"), + batch_check_interval: int = 60, url: str = "https://api.openai.com/v1/chat/completions", presence_penalty: float | None = None, frequency_penalty: float | None = None, @@ -48,13 +56,15 @@ def __init__( f"Please set your batch_size to be less than or equal to {MAX_REQUESTS_PER_BATCH:,}." ) super().__init__(batch_size) + self.model = model self.url: str = url - self.api_key: str = api_key - self.check_interval: int = check_interval + self.check_interval: int = batch_check_interval self.temperature: float | None = temperature self.top_p: float | None = top_p self.presence_penalty: float | None = presence_penalty self.frequency_penalty: float | None = frequency_penalty + self.delete_successful_batch_files: bool = delete_successful_batch_files + self.delete_failed_batch_files: bool = delete_failed_batch_files def get_rate_limits(self) -> dict: """ @@ -100,15 +110,23 @@ def get_rate_limits(self) -> dict: def create_api_specific_request(self, generic_request: GenericRequest) -> dict: """ - Creates a API-specific request body from a generic request body. + Creates an API-specific request body from a generic request body. - Using the api_parallel_processor, we can store whatever we want in the metadata. We will store both the row and the index. - This is so we can later construct the new dataset row. + This function transforms a GenericRequest into the format expected by OpenAI's batch API. + It handles both standard requests and those with JSON schema response formats. + + Args: + generic_request (GenericRequest): The generic request object containing model, messages, + and optional response format. Returns: - dict: API specific request body + dict: API specific request body formatted for OpenAI's batch API, including: + - custom_id: String identifier from the original row index + - method: Always "POST" + - url: OpenAI chat completions endpoint + - body: Request parameters including model, messages, and optional formatting """ - # NOTE(Ryan): We can have a shared place that creates the body (since it is the same for both online and batch). + # NOTE(Ryan): We can have a shared place that creates the body (since it is the same for both openai online and batch). if generic_request.response_format: body = { "model": generic_request.model, @@ -116,8 +134,7 @@ def create_api_specific_request(self, generic_request: GenericRequest) -> dict: "response_format": { "type": "json_schema", "json_schema": { - # TODO(ryan): not sure if this should be something else. - # TODO(ryan): also not sure if we should use strict: True + # NOTE(Ryan): we are not using strict: True "name": "output_schema", "schema": generic_request.response_format, }, @@ -150,59 +167,125 @@ def create_api_specific_request(self, generic_request: GenericRequest) -> dict: return request - async def asubmit_batch(self, batch_file: str) -> dict: - async_client = AsyncOpenAI() - # Create a list to store API-specific requests + def requests_from_generic_request_file(self, request_file: str) -> list[dict]: + """ + Reads and converts generic requests from a file into API-specific request format. + + Args: + request_file (str): Path to the file containing generic requests in JSONL format. + + Returns: + list[dict]: List of API-specific request bodies ready for batch submission. + """ api_specific_requests = [] - line_count = 0 - async with aiofiles.open(batch_file, "r") as file: - file_content = await file.read() - for line in file_content.splitlines(): - request = GenericRequest.model_validate_json(line) + with open(request_file, "r") as file: + for line in file: + request = GenericRequest.model_validate_json(line.strip()) api_specific_request = self.create_api_specific_request(request) api_specific_requests.append(json.dumps(api_specific_request)) - line_count += 1 - if line_count > MAX_REQUESTS_PER_BATCH: - raise ValueError( - f"Batch file {batch_file} contains {line_count:,} requests, " - f"which is more than the maximum of {MAX_REQUESTS_PER_BATCH:,} requests per batch that OpenAI supports. " - f"Preventing batch submission." - ) + return api_specific_requests - # Join requests with newlines and encode to bytes for upload - file_content = "\n".join(api_specific_requests).encode() - file_content_size = len(file_content) - logger.debug( - f"Batch file content size: {file_content_size / (1024*1024):.2f} MB ({file_content_size:,} bytes)" - ) - if file_content_size > MAX_BYTES_PER_BATCH: - raise ValueError( - f"Batch file content size {file_content_size:,} bytes " - f"is greater than the maximum of {MAX_BYTES_PER_BATCH:,} bytes per batch that OpenAI supports. " - f"Please reduce your batch size or request content size (via prompt_func and response_format)." - ) + def generic_response_file_from_responses( + self, responses: str, batch: Batch, response_file: str + ) -> str | None: + """Processes API-specific responses and creates a generic response file. - # this let's you upload a file that is larger than 200MB and won't error, so we catch it above - batch_file_upload = await async_client.files.create(file=file_content, purpose="batch") + Takes raw API responses from a batch request and converts them into GenericResponse objects, + writing them to a response file. Handles both successful and failed responses, including + token usage tracking and cost calculation. - logger.info(f"File uploaded: {batch_file_upload}") + Args: + responses (str): Raw response text from the API containing JSONL formatted responses. + batch (Batch): The OpenAI batch object containing metadata about the request batch. + response_file (str): Path where the generic response file should be written. - batch_object = await async_client.batches.create( - input_file_id=batch_file_upload.id, - endpoint="/v1/chat/completions", - completion_window="24h", - metadata={ - "request_file_name": batch_file - }, # for downloading the batch to similarly named responses file - ) - logger.info(f"Batch request submitted, received batch object: {batch_object}") - # Explicitly close the client. Otherwise we get something like - # future: > - await async_client.close() + Returns: + str | None: Path to the created response file, or None if creation failed. - return batch_object + Note: + The response file will contain one GenericResponse per line in JSONL format. + Failed requests will have response_message=None and include error details. + Costs are calculated using litellm with 50% discount applied for batch requests. + """ + request_file = batch.metadata["request_file_name"] + generic_request_map = {} + batch_created_at = datetime.datetime.fromtimestamp(batch.created_at) + with open(request_file, "r") as f: + for line in f: + generic_request = GenericRequest.model_validate_json(line) + generic_request_map[generic_request.original_row_idx] = generic_request + + with open(response_file, "w") as f: + for raw_response in responses.text.splitlines(): + raw_response = json.loads(raw_response) + request_idx = int(raw_response["custom_id"]) + generic_request = generic_request_map[request_idx] + + if raw_response["response"]["status_code"] != 200: + logger.warning( + f"Request {generic_request} failed with status code {raw_response['response']['status_code']}" + ) + generic_response = GenericResponse( + response_message=None, + response_errors=[ + f"Request {generic_request} failed with status code {raw_response['response']['status_code']}" + ], + raw_response=raw_response, + raw_request=None, + generic_request=generic_request, + created_at=batch_created_at, + finished_at=datetime.datetime.now(), + token_usage=None, + response_cost=None, + ) + else: + response_body = raw_response["response"]["body"] + choices = response_body["choices"] + usage = response_body.get("usage", {}) + + token_usage = TokenUsage( + prompt_tokens=usage.get("prompt_tokens", 0), + completion_tokens=usage.get("completion_tokens", 0), + total_tokens=usage.get("total_tokens", 0), + ) + + # Calculate cost using litellm (50% off for batch) + cost = ( + litellm.completion_cost( + model=generic_request.model, + prompt=str(generic_request.messages), + completion=choices[0]["message"]["content"], + ) + * 0.5 + ) + + response_message = choices[0]["message"]["content"] + response_message, response_errors = parse_response_message( + response_message, self.prompt_formatter.response_format + ) + + generic_response = GenericResponse( + response_message=response_message, + response_errors=response_errors, + raw_response=raw_response, + raw_request=None, + generic_request=generic_request, + created_at=batch_created_at, + finished_at=datetime.datetime.now(), + token_usage=token_usage, + response_cost=cost, + ) + json.dump(generic_response.model_dump(), f, default=str) + f.write("\n") + + async def run_batch_operations(self, batch_manager, request_files): + # For running in a single event loop (so sempahore does not change) + await batch_manager.submit_batches_from_request_files( + request_files, self.requests_from_generic_request_file + ) + await batch_manager.poll_and_process_batches(self.generic_response_file_from_responses) def run( self, @@ -212,335 +295,735 @@ def run( prompt_formatter: PromptFormatter, ) -> Dataset: """ - Uses the API to completing the specific map by calling the LLM. + Processes a dataset using OpenAI's batch API. + + This function orchestrates the complete batch processing workflow: + 1. Attempts to load cached results if available + 2. Creates request files from the dataset + 3. Submits and processes batches + 4. Creates output dataset files Args: - dataset (Dataset): Dataset that is being mapped over - working_dir (str): Working directory to save files (requests.jsonl, responses.jsonl, dataset.arrow) - parse_func_hash (str): Hash of the parse_func to be used as the dataset file name - prompt_formatter (PromptFormatter): Prompt formatter to be used to format the prompt + dataset (Dataset | None): Input dataset to process. + working_dir (str): Directory for storing intermediate files and results. + parse_func_hash (str): Hash of the parsing function for cache identification. + prompt_formatter (PromptFormatter): Formatter for processing prompts and responses. Returns: - Dataset: Completed dataset + Dataset: Processed dataset + + Raises: + RuntimeError: If batch processing fails or no successful responses are received. """ - requests_files = self.create_request_files(dataset, working_dir, prompt_formatter) - batch_objects_file = f"{working_dir}/batch_objects.jsonl" + # load from already completed dataset + output_dataset = self.attempt_loading_cached_dataset(working_dir, parse_func_hash) + if output_dataset is not None: + return output_dataset - # TODO(Ryan): we should have an easy way to cancel all batches in batch_objects.jsonl if the user realized they made a mistake - if os.path.exists(batch_objects_file): - logger.warning( - f"Batch objects file already exists, skipping batch submission and resuming: {batch_objects_file}" - ) - else: - # upload requests files and submit batches - # asyncio gather preserves order - async def submit_all_batches(): - tasks = [self.asubmit_batch(requests_files[i]) for i in range(len(requests_files))] - return await asyncio.gather(*tasks) - - batch_objects = run_in_event_loop(submit_all_batches()) - - with open(batch_objects_file, "w") as f: - # NOTE(Ryan): we can also store the request_file_name in this object here, instead of in the metadata during batch submission. Can find a nice abstraction across other batch APIs (e.g. claude) - for obj in batch_objects: - f.write(json.dumps(obj.model_dump(), default=str) + "\n") - logger.info(f"Batch objects written to {batch_objects_file}") - - # TODO(Ryan): Actually do accounting for tokens, so rate limits enforced locally. - # NOTE(Ryan): Although this isn't really practical since the limits are for an entire day and an entire organization. Maybe skip this and just recognize what a rate limit error for batching looks like (need to try this on a low tier account). - # rate_limits = self.get_rate_limits() - # tpd = rate_limits["max_tokens_per_day"] - # token_encoding_name = get_token_encoding_name(self.model) - - # TODO(Ryan): based on the files that are downloaded, update completed_ids. If any are errors, try to resubmit (depending on error type). - # TODO(Ryan): This creates responses_0.jsonl, responses_1.jsonl, etc. errors named same way? or errors_0.jsonl, errors_1.jsonl? - # TODO(Ryan): retries, resubmits on lagging batches - need to study this a little closer - # TODO(Ryan): likely can add some logic for smarter check_interval based on batch size and if the batch has started or not, fine to do a dumb ping for now - # NOTE(Ryan): If we allow for multiple heterogeneous requests per dataset row, we will need to update this. - n_submitted_requests = 1 if dataset is None else len(dataset) - - async def watch_batches(): - batch_watcher = BatchWatcher( - working_dir, - check_interval=self.check_interval, - n_submitted_requests=n_submitted_requests, - prompt_formatter=prompt_formatter, - ) - await batch_watcher.watch() - # Explicitly close the client. Otherwise we get something like - # future: > - await batch_watcher.close_client() + request_files = set(self.create_request_files(dataset, working_dir, prompt_formatter)) + self.prompt_formatter = prompt_formatter - run_in_event_loop(watch_batches()) + batch_manager = BatchManager( + working_dir, + self.check_interval, + prompt_formatter, + delete_successful_batch_files=self.delete_successful_batch_files, + delete_failed_batch_files=self.delete_failed_batch_files, + ) - dataset = self.create_dataset_files(working_dir, parse_func_hash, prompt_formatter) + run_in_event_loop(self.run_batch_operations(batch_manager, request_files)) - return dataset + return self.create_dataset_files(working_dir, parse_func_hash, prompt_formatter) + + def cancel_batches(self, working_dir: str) -> Dataset: + """ + Cancels all submitted batches and exits the program. + + Args: + working_dir (str): The directory where submitted batch object file is stored. + """ + batch_manager = BatchManager( + working_dir, + self.check_interval, + delete_successful_batch_files=self.delete_successful_batch_files, + delete_failed_batch_files=self.delete_failed_batch_files, + ) + + run_in_event_loop(batch_manager.cancel_batches()) + logger.warning("Exiting program after batch cancellation.") + os._exit(1) @dataclass class BatchStatusTracker: - # BATCHES - # returned batches = completed + failed + cancelled + expired - n_submitted_batches: int = 0 - n_returned_batches: int = 0 - n_completed_batches: int = 0 - n_failed_batches: int = 0 - n_cancelled_batches: int = 0 - n_expired_batches: int = 0 - - # REQUESTS - n_submitted_requests: int = 0 - n_completed_returned_requests: int = 0 - n_failed_returned_requests: int = 0 - # requests in pending batches that have not been returned yet - n_completed_in_progress_requests: int = 0 - n_failed_in_progress_requests: int = 0 - - -class BatchWatcher: + # total number of requests in all request files + n_total_requests: int = 0 + + # request files that have not been submitted yet + unsubmitted_request_files: list[str] = field(default_factory=list) + + # batches in OpenAI + submitted_batches: dict[str, Batch] = field(default_factory=dict) + finished_batches: dict[str, Batch] = field(default_factory=dict) + downloaded_batches: dict[str, Batch] = field(default_factory=dict) + + @property + def n_total_batches(self) -> int: + return ( + self.n_unsubmitted_request_files + + self.n_submitted_batches + + self.n_finished_batches + + self.n_downloaded_batches + ) + + @property + def n_unsubmitted_request_files(self) -> int: + return len(self.unsubmitted_request_files) + + @property + def n_submitted_batches(self) -> int: + return len(self.submitted_batches) + + @property + def n_finished_batches(self) -> int: + return len(self.finished_batches) + + @property + def n_downloaded_batches(self) -> int: + return len(self.downloaded_batches) + + @property + def n_finished_requests(self) -> int: + batches = list(self.submitted_batches.values()) + list(self.finished_batches.values()) + return sum(b.request_counts.completed + b.request_counts.failed for b in batches) + + @property + def n_downloaded_requests(self) -> int: + batches = list(self.downloaded_batches.values()) + return sum(b.request_counts.completed + b.request_counts.failed for b in batches) + + @property + def n_finished_or_downloaded_requests(self) -> int: + return self.n_finished_requests + self.n_downloaded_requests + + @property + def n_submitted_finished_or_downloaded_batches(self) -> int: + return self.n_submitted_batches + self.n_finished_batches + self.n_downloaded_batches + + @property + def n_finished_or_downloaded_batches(self) -> int: + return self.n_finished_batches + self.n_downloaded_batches + + def mark_as_submitted(self, request_file: str, batch_object: Batch, n_requests: int): + assert request_file in self.unsubmitted_request_files + assert n_requests > 0 + self.unsubmitted_request_files.remove(request_file) + self.submitted_batches[batch_object.id] = batch_object + self.n_total_requests += n_requests + logger.debug(f"Marked {request_file} as submitted with batch {batch_object.id}") + + def mark_as_finished(self, batch_object: Batch): + assert batch_object.id in self.submitted_batches + self.submitted_batches.pop(batch_object.id) + self.finished_batches[batch_object.id] = batch_object + logger.debug(f"Marked batch {batch_object.id} as finished") + + def mark_as_downloaded(self, batch_object: Batch): + assert batch_object.id in self.finished_batches + self.finished_batches.pop(batch_object.id) + self.downloaded_batches[batch_object.id] = batch_object + logger.debug(f"Marked batch {batch_object.id} as downloaded") + + def update_submitted(self, batch_object: Batch): + assert batch_object.id in self.submitted_batches + self.submitted_batches[batch_object.id] = batch_object + logger.debug(f"Updated submitted batch {batch_object.id} with new request counts") + + def __str__(self) -> str: + """Returns a human-readable string representation of the batch status.""" + status_lines = [ + f"Total batches: {self.n_total_batches}", + f"Unsubmitted files: {self.n_unsubmitted_request_files}", + f"Submitted batches: {self.n_submitted_batches}", + f"Finished batches: {self.n_finished_batches}", + f"Downloaded batches: {self.n_downloaded_batches}", + "", + f"Total requests: {self.n_total_requests}", + f"Finished requests: {self.n_finished_requests}", + f"Downloaded requests: {self.n_downloaded_requests}", + ] + return "\n".join(status_lines) + + +def request_file_to_response_file(request_file: str, working_dir: str) -> str: + """ + Converts a request file path to its corresponding response file path. + + Args: + request_file (str): Path to the request file (e.g., "requests_0.jsonl") + working_dir (str): Working directory containing the files + + Returns: + str: Path to the corresponding response file (e.g., "responses_0.jsonl") + """ + request_file_idx = request_file.split("/")[-1].split("_", 1)[1] + return f"{working_dir}/responses_{request_file_idx}" + + +def response_file_to_request_file(response_file: str, working_dir: str) -> str: + """ + Converts a response file path to its corresponding request file path. + + Args: + response_file (str): Path to the response file (e.g., "responses_0.jsonl") + working_dir (str): Working directory containing the files + + Returns: + str: Path to the corresponding request file (e.g., "requests_0.jsonl") + """ + response_file_idx = response_file.split("/")[-1].split("_", 1)[1] + return f"{working_dir}/requests_{response_file_idx}" + + +def requests_from_api_specific_request_file(self, request_file: str) -> list[dict]: + with open(request_file, "r") as file: + return file.read().splitlines() + + +def api_specific_response_file_from_responses( + responses: str, batch: Batch, response_file: str +) -> str | None: + open(response_file, "w").write(responses.text) + + +class BatchManager: def __init__( self, working_dir: str, - check_interval: int, - prompt_formatter: PromptFormatter, - n_submitted_requests: int, + check_interval: int = 60, + prompt_formatter: PromptFormatter | None = None, + delete_successful_batch_files: bool = False, + delete_failed_batch_files: bool = False, ) -> None: - """Initialize BatchWatcher with batch objects file and check interval. + """Initialize BatchManager to handle OpenAI batch processing operations. Args: - working_dir (str): Directory containing the batch objects JSON file. - check_interval (int): Time interval (in seconds) to check batch status. - prompt_formatter (PromptFormatter): Prompt formatter to be used to format the prompt - n_submitted_requests (int): Number of requests submitted to the batches (used for progress bar) + working_dir (str): Directory for storing batch-related files including requests, responses, + and tracking files. + check_interval (int): Time interval (in seconds) between batch status checks. + prompt_formatter (PromptFormatter): Formatter used to process prompts and validate responses. + delete_successful_batch_files (bool): Whether to delete input/output files from OpenAI + after successful batch completion. + delete_failed_batch_files (bool): Whether to delete input/error files from OpenAI + after batch failure. """ self.client = AsyncOpenAI() - with open(f"{working_dir}/batch_objects.jsonl", "r") as f: - self.batch_objects = [json.loads(line) for line in f] - self.batch_ids = [obj["id"] for obj in self.batch_objects] - self.batch_id_to_request_file_name = { - obj["id"]: obj["metadata"]["request_file_name"] for obj in self.batch_objects - } self.check_interval = check_interval self.working_dir = working_dir self.tracker = BatchStatusTracker() - self.tracker.n_submitted_batches = len(self.batch_ids) - self.tracker.n_submitted_requests = n_submitted_requests - self.remaining_batch_ids = set(self.batch_ids) self.prompt_formatter = prompt_formatter + self.semaphore = asyncio.Semaphore(MAX_CONCURRENT_BATCH_OPERATIONS) + self.delete_successful_batch_files = delete_successful_batch_files + self.delete_failed_batch_files = delete_failed_batch_files + self._submitted_batch_objects_file_lock = asyncio.Lock() + self._downloaded_batch_objects_file_lock = asyncio.Lock() + self.submitted_batch_objects_file = ( + f"{working_dir}/batch_objects_submitted_{self.client.api_key[-4:]}.jsonl" + ) + self.downloaded_batch_objects_file = ( + f"{working_dir}/batch_objects_downloaded_{self.client.api_key[-4:]}.jsonl" + ) + self.batch_submit_pbar: tqdm | None = None + self.request_pbar: tqdm | None = None - async def close_client(self): - await self.client.close() + def create_batch_file(self, api_specific_requests: list[dict]) -> str: + """ + Creates a batch file from a list of API-specific requests. - async def check_batch_status(self, batch_id: str) -> Batch | None: - """Check the status of a batch by its ID. + Args: + api_specific_requests (list[dict]): List of API-specific request bodies + + Returns: + str: The encoded file content ready for upload + + Raises: + ValueError: If the batch file contains more requests than OpenAI supports + """ + n_requests = len(api_specific_requests) + if n_requests > MAX_REQUESTS_PER_BATCH: + raise ValueError( + f"Batch file contains {n_requests:,} requests, " + f"which is more than the maximum of {MAX_REQUESTS_PER_BATCH:,} requests per batch that OpenAI supports. " + f"Preventing batch submission. Please reduce `batch_size`." + ) + + # Join requests with newlines and encode to bytes for upload + file_content = "\n".join(api_specific_requests).encode() + file_content_size = len(file_content) + logger.debug( + f"Batch file content size: {file_content_size / (1024*1024):.2f} MB ({file_content_size:,} bytes)" + ) + if file_content_size > MAX_BYTES_PER_BATCH: + raise ValueError( + f"Batch file content size {file_content_size:,} bytes " + f"is greater than the maximum of {MAX_BYTES_PER_BATCH:,} bytes per batch that OpenAI supports. " + f"Please reduce your batch size or request content size (via prompt_func and response_format)." + ) + return file_content + + async def upload_batch_file(self, file_content: bytes) -> str: + """ + Uploads a batch file to OpenAI and waits until ready. + + Args: + file_content (bytes): The encoded file content to upload + + Returns: + str: The uploaded file object from OpenAI + """ + try: + batch_file_upload = await self.client.files.create(file=file_content, purpose="batch") + except Exception as e: + logger.error(f"Error uploading batch file: {e}") + raise e + + # When submitting a file, sometimes the file is not ready immediately for status checking + # Which results in a file not found error, so we briefly pause before checking the status + await asyncio.sleep(1) + + try: + batch_file_upload = await self.client.files.wait_for_processing(batch_file_upload.id) + except Exception as e: + logger.error(f"Error waiting for batch file to be processed: {e}") + raise e + + logger.debug(f"File uploaded with id {batch_file_upload.id}") + + return batch_file_upload + + async def create_batch(self, batch_file_id: str, metadata: dict) -> Batch: + """ + Creates a batch job with OpenAI using an uploaded file. + + Args: + batch_file_id (str): ID of the uploaded file to use for the batch + metadata (dict): Metadata to be included with the batch + + Returns: + Batch: The created batch object from OpenAI + + Raises: + Exception: If batch creation fails + """ + try: + batch_object = await self.client.batches.create( + input_file_id=batch_file_id, + endpoint="/v1/chat/completions", + completion_window="24h", + metadata=metadata, + ) + logger.debug(f"Batch submitted with id {batch_object.id}") + except Exception as e: + logger.error(f"Error submitting batch: {e}") + raise e + return batch_object + + async def submit_batch(self, requests: list[dict], metadata: dict) -> Batch: + """ + Handles the complete batch submission process. Args: - batch_id (str): The ID of the batch to check. + requests (list[dict]): List of API-specific requests to submit + metadata (dict): Metadata to be included with the batch Returns: - Batch: The batch object. None if the batch has not returned yet. + Batch: The created batch object from OpenAI + + Side Effects: + - Updates tracker with submitted batch status + - Appends batch object to submitted_batch_objects_file + """ + async with self.semaphore: + file_content = self.create_batch_file(requests) + batch_file_upload = await self.upload_batch_file(file_content) + batch_object = await self.create_batch(batch_file_upload.id, metadata) + + # Simplified file writing + with open(self.submitted_batch_objects_file, "a") as f: + json.dump(batch_object.model_dump(), f, default=str) + f.write("\n") + f.flush() + + return batch_object + + async def cancel_batches(self): + if not os.path.exists(self.submitted_batch_objects_file): + logger.warning("No batches to be cancelled, but cancel_batches=True.") + else: + logger.info(f"Batch objects file exists, cancelling all batches.") + batch_ids = [] + with open(self.submitted_batch_objects_file, "r") as f: + for line in f: + batch_obj = json.loads(line.strip()) + batch_ids.append(batch_obj["id"]) + tasks = [self.cancel_batch(batch_id) for batch_id in batch_ids] + results = await asyncio.gather(*tasks) + failed = abs(sum(results)) + logger.warning( + f"{len(results)-failed:,} out of {len(results):,} batches successfully cancelled" + ) + + async def retrieve_batch(self, batch_id: str) -> Batch: + try: + batch_object = await self.client.batches.retrieve(batch_id) + except Exception as e: + logger.error(f"Error checking previously submitted batch: {e}") + raise e + return batch_object + + async def cancel_batch(self, batch_id: str) -> int: + async with self.semaphore: + batch_object = await self.retrieve_batch(batch_id) + if batch_object.status == "completed": + logger.warning(f"Batch {batch_id} is already completed, cannot cancel") + return 0 + try: + await self.client.batches.cancel(batch_id) + logger.info(f"Successfully cancelled batch: {batch_id}") + return 0 + except Exception as e: + error_msg = str(e) + logger.error(f"Failed to cancel batch {batch_id}: {error_msg}") + return -1 + + async def submit_batch_from_request_file( + self, + request_file: str, + requests_from_request_file_func: Callable = requests_from_api_specific_request_file, + ): + """ + Submits a batch from a request file. + + Args: + request_file (str): Path to the file containing requests + requests_from_request_file_func (Callable): Function to parse requests from file + + Side Effects: + - Updates batch submission progress bar + - Updates tracker with submitted batch status """ - batch = await self.client.batches.retrieve(batch_id) - assert batch.id == batch_id + metadata = {"request_file_name": request_file} + requests = requests_from_request_file_func(request_file) + batch_object = await self.submit_batch(requests, metadata) + self.tracker.mark_as_submitted(request_file, batch_object, len(requests)) + self.batch_submit_pbar.update(1) - n_completed_requests = batch.request_counts.completed - n_failed_requests = batch.request_counts.failed - n_total_requests = batch.request_counts.total + async def track_already_submitted_batches(self): + """ + Tracks previously submitted batches from the submitted batch objects file. + Side Effects: + - Updates tracker with previously submitted batch statuses + """ + if os.path.exists(self.submitted_batch_objects_file): + with open(self.submitted_batch_objects_file, "r") as f: + for line in f: + batch_object = Batch.model_validate(json.loads(line)) + request_file_name = batch_object.metadata["request_file_name"] + logger.debug( + f"Already submitted batch {batch_object.id} for request file {request_file_name}. " + f"Getting batch object to update tracker." + ) + batch_object = await self.retrieve_batch(batch_object.id) + + # Edge case where the batch is still validating, and we need to know the total number of requests + if batch_object.status == "validating": + n_requests = len(open(request_file_name, "r").readlines()) + batch_object.request_counts.total = n_requests + else: + n_requests = batch_object.request_counts.total + + if request_file_name in self.tracker.unsubmitted_request_files: + self.tracker.mark_as_submitted(request_file_name, batch_object, n_requests) + else: + # batch objects if not unsubmitted, should be downloaded + assert batch_object.id in self.tracker.downloaded_batches + + if self.tracker.n_submitted_batches > 0: + logger.info( + f"{self.tracker.n_submitted_batches:,} out of {self.tracker.n_total_batches - self.tracker.n_downloaded_batches:,} remaining batches are already submitted." + ) + + def track_already_downloaded_batches(self): + """ + Tracks previously downloaded batches from the downloaded batch objects files. + + Side Effects: + - Updates tracker with previously downloaded batch statuses + """ + downloaded_batch_object_files = set( + glob.glob(f"{self.working_dir}/batch_objects_downloaded_*.jsonl") + ) + for downloaded_batch_object_file in downloaded_batch_object_files: + with open(downloaded_batch_object_file, "r") as f: + for line in f: + batch_object = Batch.model_validate(json.loads(line)) + request_file = batch_object.metadata["request_file_name"] + response_file = request_file_to_response_file(request_file, self.working_dir) + assert request_file in self.tracker.unsubmitted_request_files + assert os.path.exists(response_file) + self.tracker.mark_as_submitted( + request_file, batch_object, batch_object.request_counts.total + ) + self.tracker.mark_as_finished(batch_object) + self.tracker.mark_as_downloaded(batch_object) + + if self.tracker.n_downloaded_batches > 0: + logger.info( + f"{self.tracker.n_downloaded_batches:,} out of {self.tracker.n_total_batches:,} batches already downloaded." + ) + + async def submit_batches_from_request_files( + self, + request_files: set[str], + requests_from_request_file_func: Callable = requests_from_api_specific_request_file, + ): + """ + Manages the submission of multiple request files as batches. + + Args: + request_files (set[str]): Set of paths to request files to process + requests_from_request_file_func (Callable): Function to parse requests from files + + Side Effects: + - Updates tracker with batch statuses + - Creates and updates batch submission progress bar + """ + self.tracker.unsubmitted_request_files = request_files + self.track_already_downloaded_batches() + await self.track_already_submitted_batches() + # exit early + if self.tracker.n_unsubmitted_request_files == 0: + return + + # submit remaining batches + self.batch_submit_pbar = tqdm( + total=self.tracker.n_total_batches, + desc="Submitting batches", + unit="batch", + initial=self.tracker.n_submitted_finished_or_downloaded_batches, + ) + tasks = [ + self.submit_batch_from_request_file(f, requests_from_request_file_func) + for f in self.tracker.unsubmitted_request_files + ] + await asyncio.gather(*tasks) + self.batch_submit_pbar.close() + assert self.tracker.unsubmitted_request_files == set() logger.debug( - f"Batch {batch.id} status: {batch.status} requests: " - f"{n_completed_requests}/{n_failed_requests}/{n_total_requests} " - "completed/failed/total" + f"All batch objects submitted and written to {self.submitted_batch_objects_file}" ) - batch_returned = False - if batch.status == "completed": - self.tracker.n_completed_batches += 1 - batch_returned = True - elif batch.status == "failed": - self.tracker.n_failed_batches += 1 - batch_returned = True - elif batch.status == "expired": - self.tracker.n_expired_batches += 1 - batch_returned = True - elif batch.status == "cancelled": - self.tracker.n_cancelled_batches += 1 - batch_returned = True - else: - if batch.status not in [ - "validating", - "finalizing", - "cancelling", - "in_progress", - ]: + async def check_batch_status(self, batch_id: str) -> Batch | None: + """ + Checks the current status of a batch job. + + Args: + batch_id (str): The ID of the batch to check + + Returns: + Batch | None: The batch object if completed (including failures), None if in progress + + Side Effects: + - Updates tracker with current batch status + - Updates request completion counts + """ + async with self.semaphore: + batch = await self.client.batches.retrieve(batch_id) + self.tracker.update_submitted(batch) + + n_completed_requests = batch.request_counts.completed + n_failed_requests = batch.request_counts.failed + n_total_requests = batch.request_counts.total + + logger.debug( + f"Batch {batch.id} status: {batch.status} requests: " + f"{n_completed_requests}/{n_failed_requests}/{n_total_requests} " + "completed/failed/total" + ) + + finished_statuses = ["completed", "failed", "expired", "cancelled"] + in_progress_statuses = ["validating", "finalizing", "cancelling", "in_progress"] + batch_returned = batch.status in finished_statuses + if batch.status not in in_progress_statuses + finished_statuses: logger.warning(f"Unknown batch status: {batch.status}") - if batch_returned: - logger.info(f"Batch {batch.id} returned with status: {batch.status}") - self.tracker.n_returned_batches += 1 - self.tracker.n_completed_returned_requests += n_completed_requests - self.tracker.n_failed_returned_requests += n_failed_requests - self.remaining_batch_ids.remove(batch.id) - return batch - else: - self.tracker.n_completed_in_progress_requests += n_completed_requests - self.tracker.n_failed_in_progress_requests += n_failed_requests - return None + if batch_returned: + logger.debug(f"Batch {batch.id} returned with status: {batch.status}") + self.tracker.mark_as_finished(batch) + return batch - async def watch(self) -> None: - """Monitor the status of batches until all are completed (includes successfully, failed, expired or cancelled).""" - # progress bar for completed requests - pbar = tqdm( - total=self.tracker.n_submitted_requests, - desc="Completed OpenAI requests in batches", + async def poll_and_process_batches( + self, + response_file_from_responses_func: Callable = api_specific_response_file_from_responses, + ) -> None: + """Monitors and processes batches until all are completed. + + Continuously polls the status of submitted batches and downloads their results + when complete. Handles successful completions, failures, expirations, and + cancellations. Progress is tracked via a progress bar showing completed requests. + + Returns: + None + + Raises: + RuntimeError: If none of the submitted batches complete successfully. + + Side Effects: + - Updates the batch tracker state + - Creates response files for completed batches + - Creates and updates requests progress bar + """ + # progress bar for finished requests + self.request_pbar = tqdm( + total=self.tracker.n_total_requests, + desc="Finished requests in batches", unit="request", + initial=self.tracker.n_finished_or_downloaded_requests, ) - all_response_files = [] # loop until all batches have been returned - while self.remaining_batch_ids: - - # need to reset in_progress numbers each time - self.tracker.n_completed_in_progress_requests = 0 - self.tracker.n_failed_in_progress_requests = 0 - + all_response_files = [] + while len(self.tracker.submitted_batches) > 0: # check batch status also updates the tracker status_tasks = [ - self.check_batch_status(batch_id) for batch_id in self.remaining_batch_ids + self.check_batch_status(batch_id) for batch_id in self.tracker.submitted_batches ] batches_to_download = await asyncio.gather(*status_tasks) batches_to_download = filter(None, batches_to_download) # update progress bar - pbar.n = 0 - pbar.n += self.tracker.n_completed_returned_requests - pbar.n += self.tracker.n_failed_returned_requests - pbar.n += self.tracker.n_completed_in_progress_requests - pbar.n += self.tracker.n_failed_in_progress_requests - pbar.refresh() + self.request_pbar.n = self.tracker.n_finished_or_downloaded_requests + self.request_pbar.refresh() download_tasks = [ - self.download_batch_to_generic_responses_file(batch) + self.download_batch_to_response_file(batch, response_file_from_responses_func) for batch in batches_to_download ] # Failed downloads return None and print any errors that occurred all_response_files.extend(await asyncio.gather(*download_tasks)) - - if self.tracker.n_returned_batches < self.tracker.n_submitted_batches: + if self.tracker.n_finished_or_downloaded_requests < self.tracker.n_total_requests: logger.debug( - f"Batches returned: {self.tracker.n_returned_batches}/{self.tracker.n_submitted_batches} " - f"Requests completed: {pbar.n}/{self.tracker.n_submitted_requests}" + f"Batches returned: {self.tracker.n_finished_or_downloaded_batches:,}/{self.tracker.n_total_batches:,} " + f"Requests completed: {self.tracker.n_finished_or_downloaded_requests:,}/{self.tracker.n_total_requests:,}" ) logger.debug(f"Sleeping for {self.check_interval} seconds...") await asyncio.sleep(self.check_interval) - pbar.close() + self.request_pbar.close() response_files = filter(None, all_response_files) - if self.tracker.n_completed_batches == 0 or not response_files: + if len(self.tracker.downloaded_batches) == 0 or not response_files: raise RuntimeError( "None of the submitted batches completed successfully. " "Please check the logs above and https://platform.openai.com/batches for errors." ) - async def download_batch_to_generic_responses_file(self, batch: Batch) -> str | None: - """Download the result of a completed batch to file. + async def delete_file(self, file_id: str, semaphore: asyncio.Semaphore): + """ + Deletes a file from OpenAI's storage. + + Args: + file_id (str): The ID of the file to delete + semaphore (asyncio.Semaphore): Semaphore to limit concurrent operations + """ + async with semaphore: + delete_response = await self.client.files.delete(file_id) + if delete_response.deleted: + logger.debug(f"Deleted file {file_id}") + else: + logger.warning(f"Failed to delete file {file_id}") + + async def download_batch(self, batch: Batch) -> str | None: + file_content = None + async with self.semaphore: + # Completed batches have an output file + if batch.status == "completed" and batch.output_file_id: + file_content = await self.client.files.content(batch.output_file_id) + logger.debug(f"Batch {batch.id} completed and downloaded") + + # Failed batches with an error file + elif batch.status == "failed" and batch.error_file_id: + file_content = await self.client.files.content(batch.error_file_id) + logger.warning(f"Batch {batch.id} failed\n. Errors will be parsed below.") + if self.delete_failed_batch_files: + await self.delete_file(batch.input_file_id, self.semaphore) + await self.delete_file(batch.error_file_id, self.semaphore) + + # Failed batches without an error file + elif batch.status == "failed" and not batch.error_file_id: + errors = "\n".join([str(error) for error in batch.errors.data]) + logger.error( + f"Batch {batch.id} failed and likely failed validation. " + f"Batch errors: {errors}. " + f"Check https://platform.openai.com/batches/{batch.id} for more details." + ) + if self.delete_failed_batch_files: + await self.delete_file(batch.input_file_id, self.semaphore) + + # Cancelled or expired batches + elif batch.status == "cancelled" or batch.status == "expired": + logger.warning(f"Batch {batch.id} was cancelled or expired") + if self.delete_failed_batch_files: + await self.delete_file(batch.input_file_id, self.semaphore) + + return file_content + + async def download_batch_to_response_file( + self, + batch: Batch, + response_file_from_responses_func: Callable = api_specific_response_file_from_responses, + ) -> str | None: + """ + Downloads and processes the results of a completed batch. + + Handles successful completions, failures, and error cases. Converts API-specific + responses to generic responses and calculates costs. Args: - batch: The batch object to download results from. + batch (Batch): The completed batch object to process Returns: - str: Path to the downloaded result file. - """ - if batch.status == "completed" and batch.output_file_id: - file_content = await self.client.files.content(batch.output_file_id) - elif batch.status == "failed" and batch.error_file_id: - file_content = await self.client.files.content(batch.error_file_id) - logger.warning(f"Batch {batch.id} failed\n. Errors will be parsed below.") - elif batch.status == "failed" and not batch.error_file_id: - errors = "\n".join([str(error) for error in batch.errors.data]) - logger.error( - f"Batch {batch.id} failed and likely failed validation. " - f"Batch errors: {errors}. " - f"Check https://platform.openai.com/batches/{batch.id} for more details." - ) - return None - elif batch.status == "cancelled" or batch.status == "expired": - logger.warning(f"Batch {batch.id} was cancelled or expired") - return None + str | None: Path to the response file if successful, None if batch failed - # Naming is consistent with the request file (e.g. requests_0.jsonl -> responses_0.jsonl) - request_file = self.batch_id_to_request_file_name[batch.id] - request_file_idx = request_file.split("/")[-1].split("_", 1)[1] - response_file = f"{self.working_dir}/responses_{request_file_idx}" + Side Effects: + - Creates response file with processed results + - Updates batch tracking state + - Appends batch object to downloaded batch objects file + - Optionally deletes batch files from OpenAI + """ + file_content = await self.download_batch(batch) - generic_request_map = {} - request_creation_times = {} # Track creation times for requests - with open(request_file, "r") as f: - for line in f: - generic_request = GenericRequest.model_validate_json(line) - generic_request_map[generic_request.original_row_idx] = generic_request - request_creation_times[generic_request.original_row_idx] = datetime.datetime.now() + if file_content is None: + return None - with open(response_file, "w") as f: - for raw_response in file_content.text.splitlines(): - raw_response = json.loads(raw_response) - request_idx = int(raw_response["custom_id"]) - generic_request = generic_request_map[request_idx] + request_file = batch.metadata["request_file_name"] + response_file = request_file_to_response_file(request_file, self.working_dir) + response_file_from_responses_func(file_content, batch, response_file) - # TODO(Ryan): Add more specific error handling - if raw_response["response"]["status_code"] != 200: - logger.warning( - f"Request {generic_request} failed with status code {raw_response['response']['status_code']}" - ) - generic_response = GenericResponse( - response_message=None, - response_errors=[ - f"Request {generic_request} failed with status code {raw_response['response']['status_code']}" - ], - raw_response=raw_response, - raw_request=None, - generic_request=generic_request, - created_at=request_creation_times[request_idx], - finished_at=datetime.datetime.now(), - token_usage=None, - response_cost=None, - ) - else: - response_body = raw_response["response"]["body"] - choices = response_body["choices"] - usage = response_body.get("usage", {}) + logger.debug(f"Batch {batch.id} written to {response_file}") - token_usage = TokenUsage( - prompt_tokens=usage.get("prompt_tokens", 0), - completion_tokens=usage.get("completion_tokens", 0), - total_tokens=usage.get("total_tokens", 0), - ) + # Simplified file writing + with open(self.downloaded_batch_objects_file, "a") as f: + json.dump(batch.model_dump(), f, default=str) + f.write("\n") + f.flush() - # Calculate cost using litellm - cost = litellm.completion_cost( - model=generic_request.model, - prompt=str( - generic_request.messages - ), # Convert messages to string for cost calculation - completion=choices[0]["message"]["content"], - ) + logger.debug(f"Batch {batch.id} written to {self.downloaded_batch_objects_file}") - response_message = choices[0]["message"]["content"] - response_message, response_errors = parse_response_message( - response_message, self.prompt_formatter.response_format - ) + if self.delete_successful_batch_files: + await self.delete_file(batch.input_file_id, self.semaphore) + await self.delete_file(batch.output_file_id, self.semaphore) + + self.tracker.mark_as_downloaded(batch) - generic_response = GenericResponse( - response_message=response_message, - response_errors=response_errors, - raw_response=raw_response, - raw_request=None, - generic_request=generic_request, - created_at=request_creation_times[request_idx], - finished_at=datetime.datetime.now(), - token_usage=token_usage, - response_cost=cost, - ) - f.write(json.dumps(generic_response.model_dump(), default=str) + "\n") return response_file diff --git a/src/bespokelabs/curator/request_processor/openai_online_request_processor.py b/src/bespokelabs/curator/request_processor/openai_online_request_processor.py index cef4f277..14fc27ea 100644 --- a/src/bespokelabs/curator/request_processor/openai_online_request_processor.py +++ b/src/bespokelabs/curator/request_processor/openai_online_request_processor.py @@ -1,37 +1,75 @@ import asyncio import datetime -import json import logging import os import re -import resource -import time -from dataclasses import dataclass, field -from functools import partial -from typing import Any, Callable, Dict, Optional, Set, Tuple, TypeVar +from typing import Optional, Any, TypeVar import aiohttp -import litellm import requests import tiktoken -from tqdm import tqdm - -from bespokelabs.curator.dataset import Dataset -from bespokelabs.curator.prompter.prompter import PromptFormatter -from bespokelabs.curator.request_processor.base_request_processor import ( - BaseRequestProcessor, - GenericRequest, - GenericResponse, - parse_response_message, +import litellm +import time + +from bespokelabs.curator.request_processor.base_online_request_processor import ( + BaseOnlineRequestProcessor, + APIRequest, + StatusTracker, ) -from bespokelabs.curator.request_processor.event_loop import run_in_event_loop -from bespokelabs.curator.request_processor.generic_response import TokenUsage +from bespokelabs.curator.request_processor.generic_request import GenericRequest +from bespokelabs.curator.request_processor.generic_response import TokenUsage, GenericResponse T = TypeVar("T") -logger = logging.getLogger(__name__) +logger = logger = logging.getLogger(__name__) + + +def get_token_encoding_name(model_name: str) -> str: + """Get the token encoding name for a given model.""" + if model_name.startswith("gpt-4"): + return "cl100k_base" + elif model_name.startswith("gpt-3.5"): + return "cl100k_base" + else: + return "cl100k_base" # Default to cl100k_base + + +def api_endpoint_from_url(request_url: str) -> str: + """Extract the API endpoint from the request URL. + This is used to determine the number of tokens consumed by the request. + """ + + # OpenAI API + match = re.search("^https://[^/]+/v\\d+/(.+)$", request_url) + if match: + return match[1] + + # for Azure OpenAI deployment urls + match = re.search(r"^https://[^/]+/openai/deployments/[^/]+/(.+?)(\?|$)", request_url) + if match: + return match[1] + + # Catch all for other API endpoints using OpenAI OpenAPI format + if "chat/completions" in request_url: + return "chat/completions" + elif "completions" in request_url: + return "completions" + else: + raise NotImplementedError(f'API endpoint "{request_url}" not implemented in Curator yet.') -class OpenAIOnlineRequestProcessor(BaseRequestProcessor): +class OpenAIOnlineRequestProcessor(BaseOnlineRequestProcessor): + """OpenAI-specific implementation of the OnlineRequestProcessor. + + Handles API requests to OpenAI's chat completion endpoints with rate limiting, + token counting, and error handling specific to OpenAI's API. + + Note: + - Supports both OpenAI and Azure OpenAI endpoints + - Automatically detects and respects API rate limits + - Handles token counting using tiktoken + - Supports structured output via JSON schema + """ + def __init__( self, model: str = "gpt-4o-mini", @@ -42,31 +80,28 @@ def __init__( presence_penalty: Optional[float] = None, frequency_penalty: Optional[float] = None, ): - super().__init__(batch_size=None) - self.model: str = model - self.url: str = url - self.api_key: str = api_key - self.temperature: float = temperature - self.top_p: float = top_p - self.presence_penalty: float = presence_penalty - self.frequency_penalty: float = frequency_penalty + super().__init__( + model=model, + temperature=temperature, + top_p=top_p, + presence_penalty=presence_penalty, + frequency_penalty=frequency_penalty, + ) + self.url = url + self.api_key = api_key + self.token_encoding = tiktoken.get_encoding(get_token_encoding_name(model)) def get_rate_limits(self) -> dict: - """ - Function to get rate limits for a given annotator. Makes a single request to openAI API - and gets the rate limits from the response headers. These rate limits vary per model - and are determined by your organization's usage tier. View the following: - https://platform.openai.com/docs/guides/rate-limits/usage-tiers - https://platform.openai.com/settings/organization/limits - - Args: - model (str): The model for which to get the rate limits. - request_url (str): The request URL for which to get the rate limits. + """Get rate limits from OpenAI API headers. Returns: - tuple[int, int]: A tuple containing the maximum number of requests and tokens per minute. + dict: Contains 'max_requests_per_minute' and 'max_tokens_per_minute' + + Note: + - Makes a dummy request to get actual rate limits + - Falls back to default values if headers are missing + - Supports both OpenAI and Azure endpoints """ - # Send a dummy request to get rate limit information response = requests.post( self.url, headers={"Authorization": f"Bearer {self.api_key}"}, @@ -84,24 +119,106 @@ def get_rate_limits(self) -> dict: logger.info(f"Automatically set max_requests_per_minute to {rpm}") logger.info(f"Automatically set max_tokens_per_minute to {tpm}") - rate_limits = { + return { "max_requests_per_minute": rpm, "max_tokens_per_minute": tpm, } - return rate_limits + def estimate_output_tokens(self) -> int: + """Estimate number of tokens in the response. - def create_api_specific_request(self, generic_request: GenericRequest) -> dict: + Returns: + int: Estimated number of output tokens + + Note: + Default implementation returns a conservative estimate. + Override this method for more accurate model-specific estimates. + """ + try: + return litellm.get_max_tokens(model=self.model) // 4 + except Exception: + return 0 + + def estimate_total_tokens(self, messages: list) -> int: + """Estimate total tokens for a request using OpenAI's token counting rules. + + Args: + messages (list): List of message dictionaries with role and content + + Returns: + int: Estimated total tokens including message formatting tokens + + Note: + Includes: + - 4 tokens per message for formatting + - Role/name tokens + - Content tokens + - 2 tokens for assistant reply priming """ - Creates a API-specific request body from a generic request body. + num_tokens = 0 + for message in messages: + num_tokens += 4 # every message follows {role/name}\n{content}\n + for key, value in message.items(): + try: + num_tokens += len(self.token_encoding.encode(str(value))) + except TypeError: + logger.warning( + f"Failed to encode value {value} with tiktoken. Assuming 1 token per 4 chars." + ) + num_tokens += len(str(value)) // 4 + if key == "name": # if there's a name, the role is omitted + num_tokens -= 1 # role is always required and always 1 token - Using the api_parallel_processor, we can store whatever we want in the metadata. We will store both the row and the index. - This is so we can later construct the new dataset row. + num_tokens += 2 # every reply is primed with assistant + output_tokens = self.estimate_output_tokens() + return num_tokens + output_tokens + + def check_structured_output_support(self) -> bool: + """Check if the model supports structured output based on model name and date. Returns: - dict: API specific request body + bool: True if model supports structured output, False otherwise + + Note: + Supports: + - gpt-4o-mini with date >= 2024-07-18 or latest + - gpt-4o with date >= 2024-08-06 or latest """ - request = { + model_name = self.model.lower() + + # Check gpt-4o-mini support + if model_name == "gpt-4o-mini": # Latest version + return True + if "gpt-4o-mini-" in model_name: + mini_date = datetime.datetime.strptime(model_name.split("gpt-4o-mini-")[1], "%Y-%m-%d") + if mini_date >= datetime(2024, 7, 18): + return True + + # Check gpt-4o support + if model_name == "gpt-4o": # Latest version + return True + if "gpt-4o-" in model_name: + base_date = datetime.datetime.strptime(model_name.split("gpt-4o-")[1], "%Y-%m-%d") + if base_date >= datetime.datetime(2024, 8, 6): + return True + + return False + + def create_api_specific_request(self, generic_request: GenericRequest) -> dict: + """Create an OpenAI-specific request from a generic request. + + Args: + generic_request (GenericRequest): Generic request object + + Returns: + dict: OpenAI API-compatible request dictionary + + Note: + - Handles JSON schema response format if specified + - Applies optional parameters (temperature, top_p, etc.) + - Maintains compatibility with both chat and completion endpoints + """ + request: dict[str, Any] = { "model": generic_request.model, "messages": generic_request.messages, } @@ -128,600 +245,67 @@ def create_api_specific_request(self, generic_request: GenericRequest) -> dict: return request - def run( + async def call_single_request( self, - dataset: Optional[Dataset], - working_dir: str, - parse_func_hash: str, - prompt_formatter: PromptFormatter, - ) -> Dataset: - """ - Uses the API to completing the specific map by calling the LLM. + request: APIRequest, + session: aiohttp.ClientSession, + status_tracker: StatusTracker, + ) -> GenericResponse: + """Make a single OpenAI API request. Args: - dataset (Dataset): Dataset that is being mapped over - working_dir (str): Working directory to save files (requests.jsonl, responses.jsonl, dataset.arrow) - parse_func_hash (str): Hash of the parse_func to be used as the dataset file name - prompt_formatter (PromptFormatter): Prompt formatter to be used to format the prompt + request (APIRequest): The request to process + session (aiohttp.ClientSession): Async HTTP session + status_tracker (StatusTracker): Tracks request status Returns: - Dataset: Completed dataset + GenericResponse: The response from OpenAI """ - generic_requests_files = self.create_request_files(dataset, working_dir, prompt_formatter) - generic_responses_files = [ - f"{working_dir}/responses_{i}.jsonl" for i in range(len(generic_requests_files)) - ] - - rate_limits = self.get_rate_limits() - rpm = rate_limits["max_requests_per_minute"] - tpm = rate_limits["max_tokens_per_minute"] - - token_encoding_name = get_token_encoding_name(prompt_formatter.model_name) - - # NOTE(Ryan): If you wanted to do this on batches, you could run a for loop here about request_files. Although I don't recommend it because you are waiting for straggler requests to finish for each batch. - # NOTE(Ryan): And if you wanted to do batches in parallel, you would have to divide rpm and tpm by the number of parallel batches. - # TODO(Ryan): Can we abstract retries from process_api_requests_from_file so you can use it even if you use liteLLM. - for generic_requests_file, generic_responses_file in zip( - generic_requests_files, generic_responses_files - ): - run_in_event_loop( - self.process_generic_requests_from_file( - generic_requests_filepath=generic_requests_file, - save_filepath=generic_responses_file, - request_url=self.url, - max_requests_per_minute=rpm, - max_tokens_per_minute=tpm, - token_encoding_name=token_encoding_name, - max_attempts=5, - resume=True, # detects existing jobs and resume from there - ) - ) - - dataset = self.create_dataset_files(working_dir, parse_func_hash, prompt_formatter) - return dataset - - async def process_generic_requests_from_file( - self, - generic_requests_filepath: str, - save_filepath: str, - request_url: str, - max_requests_per_minute: float, - max_tokens_per_minute: float, - token_encoding_name: str, - max_attempts: int, - resume: bool, - resume_no_retry: bool = False, - ) -> None: - """Processes API requests in parallel, throttling to stay under rate limits.""" - - # Increase the number of open file descriptors to avoid "Too many open files" errors - soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE) - resource.setrlimit( - resource.RLIMIT_NOFILE, - (min(hard, 10 * max_requests_per_minute), hard), - ) - - # constants - seconds_to_pause_after_rate_limit_error = 15 - seconds_to_sleep_each_loop = ( - 0.001 # 1 ms limits max throughput to 1,000 requests per second - ) - - # infer API endpoint and construct request header api_endpoint = api_endpoint_from_url(self.url) request_header = {"Authorization": f"Bearer {self.api_key}"} - # use api-key header for Azure deployments - if "/deployments" in self.url: + if "/deployments" in self.url: # Azure deployment request_header = {"api-key": f"{self.api_key}"} - # initialize trackers - queue_of_requests_to_retry = asyncio.Queue() - task_id_generator = task_id_generator_function() # generates integer IDs of 0, 1, 2, ... - status_tracker = StatusTracker() # single instance to track a collection of variables - next_request = None # variable to hold the next request to call - - # initialize available capacity counts - available_request_capacity = max_requests_per_minute - available_token_capacity = max_tokens_per_minute - last_update_time = time.time() - - # initialize flags - file_not_finished = True # after file is empty, we'll skip reading it - logger.debug(f"Initialization complete.") - - completed_request_ids: Set[int] = set() - if os.path.exists(save_filepath): - if resume: - # save all successfully completed requests to a temporary file, then overwrite the original file with the temporary file - logger.debug(f"Resuming progress from existing file: {save_filepath}") - logger.debug( - f"Removing all failed requests from {save_filepath} so they can be retried" - ) - temp_filepath = f"{save_filepath}.temp" - num_previously_failed_requests = 0 - with open(save_filepath, "r") as input_file, open( - temp_filepath, "w" - ) as output_file: - for line in input_file: - response = GenericResponse.model_validate_json(line) - if response.response_errors: - # this means that the request failed and we have a list of errors - logger.debug( - f"Request {response.generic_request.original_row_idx} previously failed due to errors: {response.response_errors}, removing from output and will retry" - ) - num_previously_failed_requests += 1 - else: - completed_request_ids.add(response.generic_request.original_row_idx) - output_file.write(line) - logger.info( - f"Found {len(completed_request_ids)} completed requests and {num_previously_failed_requests} previously failed requests" - ) - logger.info("Failed requests and remaining requests will now be processed.") - os.replace(temp_filepath, save_filepath) - elif resume_no_retry: - logger.warning( - f"Resuming progress from existing file: {save_filepath}, without retrying failed requests" - ) - num_previously_failed_requests = 0 - with open(save_filepath, "r") as input_file, open( - temp_filepath, "w" - ) as output_file: - for line in tqdm(input_file, desc="Processing existing requests"): - data = json.loads(line) - if isinstance(data[1], list): - # this means that the request failed and we have a list of errors - logger.debug( - f"Request {data[2].get('request_idx')} previously failed due to errors: {data[1]}, will NOT retry" - ) - num_previously_failed_requests += 1 - completed_request_ids.add(data[2].get("request_idx")) - logger.info( - f"Found {len(completed_request_ids)} total requests and {num_previously_failed_requests} previously failed requests" - ) - logger.info("Remaining requests will now be processed.") - else: - user_input = input( - f"File {save_filepath} already exists.\nTo resume if there are remaining requests without responses, run with --resume flag.\nOverwrite? (Y/n): " - ) - if user_input.lower() != "y" and user_input.lower() != "": - logger.info("Aborting operation.") - return - - # initialize file reading - with open(generic_requests_filepath) as file: - # `requests` will provide requests one at a time - generic_requests = file.__iter__() - logger.debug(f"File opened. Entering main loop") - - # Count total number of requests - total_requests = sum(1 for _ in open(generic_requests_filepath)) - if total_requests == len(completed_request_ids): - logger.debug("All requests have already been completed so will just reuse cache.") - return - - # Create progress bar - pbar = tqdm( - total=total_requests, - desc="Processing parallel requests to OpenAI", - ) - - connector = aiohttp.TCPConnector(limit=10 * max_requests_per_minute) - async with aiohttp.ClientSession( - connector=connector - ) as session: # Initialize ClientSession here - while True: - # get next request (if one is not already waiting for capacity) - if next_request is None: - if not queue_of_requests_to_retry.empty(): - next_request = queue_of_requests_to_retry.get_nowait() - logger.debug(f"Retrying request {next_request.task_id}: {next_request}") - elif file_not_finished: - try: - # get new generic request - generic_request_json = json.loads(next(generic_requests)) - generic_request = GenericRequest.model_validate( - generic_request_json - ) - request_idx = generic_request.original_row_idx - - # Skip requests we already have responses for - if resume and request_idx in completed_request_ids: - logger.debug( - f"Skipping already completed request {request_idx}" - ) - status_tracker.num_tasks_already_completed += 1 - continue - - # Create API-specific request - api_specific_request_json = self.create_api_specific_request( - generic_request - ) - next_request = APIRequest( - task_id=next(task_id_generator), - api_specific_request_json=api_specific_request_json, - generic_request=generic_request, - token_consumption=num_tokens_consumed_from_request( - api_specific_request_json, - api_endpoint, - token_encoding_name, - ), - attempts_left=max_attempts, - ) - status_tracker.num_tasks_started += 1 - status_tracker.num_tasks_in_progress += 1 - logger.debug( - f"Reading request {next_request.task_id}: {next_request}" - ) - except StopIteration: - # if file runs out, set flag to stop reading it - logger.debug("Read file exhausted") - file_not_finished = False - - # update available capacity - current_time = time.time() - seconds_since_update = current_time - last_update_time - available_request_capacity = min( - available_request_capacity - + max_requests_per_minute * seconds_since_update / 60.0, - max_requests_per_minute, - ) - available_token_capacity = min( - available_token_capacity - + max_tokens_per_minute * seconds_since_update / 60.0, - max_tokens_per_minute, - ) - last_update_time = current_time - - # if enough capacity available, call API - if next_request: - next_request_tokens = next_request.token_consumption - if ( - available_request_capacity >= 1 - and available_token_capacity >= next_request_tokens - ): - # update counters - available_request_capacity -= 1 - available_token_capacity -= next_request_tokens - next_request.attempts_left -= 1 - - # call API - asyncio.create_task( - next_request.call_api( - session=session, - request_url=request_url, - request_header=request_header, - retry_queue=queue_of_requests_to_retry, - save_filepath=save_filepath, - status_tracker=status_tracker, - ), - ) - next_request = None # reset next_request to empty - else: - logger.debug( - f"Not Enough Capacity: Request tokens: {next_request_tokens}, Available request capacity: {available_request_capacity}, Available token capacity: {available_token_capacity}" - ) - - # Update progress bar when a task is completed - total_completed = ( - status_tracker.num_tasks_succeeded - + status_tracker.num_tasks_failed - + status_tracker.num_tasks_already_completed - ) - if total_completed > pbar.n: - pbar.update(total_completed - pbar.n) - - # if all tasks are finished, break - if status_tracker.num_tasks_in_progress == 0: - break - - # main loop sleeps briefly so concurrent tasks can run - await asyncio.sleep(seconds_to_sleep_each_loop) + async with session.post( + self.url, + headers=request_header, + json=request.api_specific_request, + timeout=60.0, + ) as response_obj: + response = await response_obj.json() - # if a rate limit error was hit recently, pause to cool down - seconds_since_rate_limit_error = ( - time.time() - status_tracker.time_of_last_rate_limit_error - ) - if seconds_since_rate_limit_error < seconds_to_pause_after_rate_limit_error: - remaining_seconds_to_pause = ( - seconds_to_pause_after_rate_limit_error - seconds_since_rate_limit_error - ) - await asyncio.sleep(remaining_seconds_to_pause) - # ^e.g., if pause is 15 seconds and final limit was hit 5 seconds ago - logger.warn( - f"Pausing to cool down until {time.ctime(status_tracker.time_of_last_rate_limit_error + seconds_to_pause_after_rate_limit_error)}" - ) - - # Close the progress bar - pbar.close() - - # after finishing, log final status - logger.info(f"""Parallel processing complete. Results saved to {save_filepath}""") - - logger.info(f"Status tracker: {status_tracker}") - - if status_tracker.num_tasks_failed > 0: - logger.warning( - f"{status_tracker.num_tasks_failed} / {status_tracker.num_tasks_started} requests failed. Errors logged to {save_filepath}." - ) - if status_tracker.num_rate_limit_errors > 0: - logger.warning( - f"{status_tracker.num_rate_limit_errors} rate limit errors received. Consider running at a lower rate." - ) - - -@dataclass -class StatusTracker: - """Stores metadata about the script's progress. Only one instance is created.""" - - num_tasks_already_completed: int = 0 - num_tasks_started: int = 0 - num_tasks_in_progress: int = 0 # script ends when this reaches 0 - num_tasks_succeeded: int = 0 - num_tasks_failed: int = 0 - num_rate_limit_errors: int = 0 - num_api_errors: int = 0 # excluding rate limit errors, counted above - num_other_errors: int = 0 - time_of_last_rate_limit_error: int = 0 # used to cool off after hitting rate limits - - -@dataclass -class APIRequest: - """Stores an API request's inputs, outputs, and other metadata. Contains a method to make an API call.""" - - task_id: int - generic_request: GenericRequest - api_specific_request_json: dict - token_consumption: int - attempts_left: int - result: list = field(default_factory=list) - created_at: datetime.datetime = field(default_factory=datetime.datetime.now) - - async def call_api( - self, - session: aiohttp.ClientSession, - request_url: str, - request_header: dict, - retry_queue: asyncio.Queue, - save_filepath: str, - status_tracker: StatusTracker, - ) -> None: - """Calls the OpenAI API and saves results.""" - logger.debug(f"Starting request #{self.task_id}") - error = None - try: - async with session.post( - url=request_url, - headers=request_header, - json=self.api_specific_request_json, - ) as response: - response = await response.json() if "error" in response: - logger.warning(f"Request {self.task_id} failed with error {response['error']}") status_tracker.num_api_errors += 1 - error = response - if "rate limit" in response["error"].get("message", "").lower(): + error = response["error"] + if "rate limit" in error.get("message", "").lower(): status_tracker.time_of_last_rate_limit_error = time.time() status_tracker.num_rate_limit_errors += 1 - status_tracker.num_api_errors -= 1 # rate limit errors are counted separately + status_tracker.num_api_errors -= 1 + raise Exception(f"API error: {error}") + + if response_obj.status != 200: + raise Exception(f"API request failed with status {response_obj.status}: {response}") - except ( - Exception - ) as e: # catching naked exceptions is bad practice, but in this case we'll log & save them - logger.warning( - f"Request {self.task_id} failed with Exception {e}, attempts left {self.attempts_left}" - ) - status_tracker.num_other_errors += 1 - error = e - if error: - self.result.append(error) - if self.attempts_left: - retry_queue.put_nowait(self) - else: - generic_response = GenericResponse( - response_message=None, - response_errors=[str(e) for e in self.result], - raw_request=self.api_specific_request_json, - raw_response=None, - generic_request=self.generic_request, - created_at=self.created_at, - finished_at=datetime.datetime.now(), - ) - append_generic_response(generic_response, save_filepath) - status_tracker.num_tasks_in_progress -= 1 - status_tracker.num_tasks_failed += 1 - logger.error( - f"Request {self.api_specific_request_json} failed after all attempts." - f"Saved errors {self.result} to {save_filepath}" - ) - else: response_message = response["choices"][0]["message"]["content"] - response_message, response_errors = parse_response_message( - response_message, self.generic_request.response_format - ) - usage = response.get("usage", {}) + usage = response["usage"] token_usage = TokenUsage( - prompt_tokens=usage.get("prompt_tokens", 0), - completion_tokens=usage.get("completion_tokens", 0), - total_tokens=usage.get("total_tokens", 0), + prompt_tokens=usage["prompt_tokens"], + completion_tokens=usage["completion_tokens"], + total_tokens=usage["total_tokens"], ) # Calculate cost using litellm cost = litellm.completion_cost(completion_response=response) - generic_response = GenericResponse( + # Create and return response + return GenericResponse( response_message=response_message, - response_errors=response_errors, - raw_request=self.api_specific_request_json, + response_errors=None, + raw_request=request.api_specific_request, raw_response=response, - generic_request=self.generic_request, - created_at=self.created_at, + generic_request=request.generic_request, + created_at=request.created_at, finished_at=datetime.datetime.now(), token_usage=token_usage, response_cost=cost, ) - append_generic_response(generic_response, save_filepath) - status_tracker.num_tasks_in_progress -= 1 - status_tracker.num_tasks_succeeded += 1 - logger.debug(f"Request {self.task_id} saved to {save_filepath}") - - -def get_token_encoding_name(model: str) -> str: - """Get the token encoding name for a given model.""" - if "gpt" in model: - return tiktoken.encoding_for_model(model).name - else: - logger.warning( - f'Token encoding name for model "{model}" not implemented, using cl100k_base for token counting' - ) - return "cl100k_base" - - -def get_rate_limits(model: str, request_url: str, api_key: str) -> Tuple[int, int]: - """ - Function to get rate limits for a given annotator. Makes a single request to openAI API - and gets the rate limits from the response headers. These rate limits vary per model - and are determined by your organization's usage tier. View the following: - https://platform.openai.com/docs/guides/rate-limits/usage-tiers - https://platform.openai.com/settings/organization/limits - - Args: - model (str): The model for which to get the rate limits. - request_url (str): The request URL for which to get the rate limits. - - Returns: - Tuple[int, int]: The maximum number of requests and tokens per minute. - """ - if "api.openai.com" in request_url: - # Send a dummy request to get rate limit information - response = requests.post( - request_url, - headers={"Authorization": f"Bearer {api_key}"}, - json={"model": model, "messages": []}, - ) - # Extract rate limit information from headers - max_requests = int(response.headers.get("x-ratelimit-limit-requests", 30_000)) - max_tokens = int(response.headers.get("x-ratelimit-limit-tokens", 150_000_000)) - elif "api.sambanova.ai" in request_url: - # Send a dummy request to get rate limit information - max_requests = 50 - max_tokens = 100_000_000 - else: - raise NotImplementedError(f'Rate limits for API endpoint "{request_url}" not implemented') - - return max_requests, max_tokens - - -def get_api_key(request_url: str) -> str: - """Get the API key for a given request URL.""" - if "api.openai.com" in request_url: - return os.getenv("OPENAI_API_KEY") - elif "api.sambanova.ai" in request_url: - return os.getenv("SAMBANOVA_API_KEY") - else: - raise NotImplementedError( - f'Default API key environment variable for API endpoint "{request_url}" not implemented' - ) - - -def api_endpoint_from_url(request_url: str) -> str: - """Extract the API endpoint from the request URL. - This is used to determine the number of tokens consumed by the request. - """ - - # OpenAI API - match = re.search("^https://[^/]+/v\\d+/(.+)$", request_url) - if match: - return match[1] - - # for Azure OpenAI deployment urls - match = re.search(r"^https://[^/]+/openai/deployments/[^/]+/(.+?)(\?|$)", request_url) - if match: - return match[1] - - # Catch all for other API endpoints using OpenAI OpenAPI format - if "chat/completions" in request_url: - return "chat/completions" - elif "completions" in request_url: - return "completions" - else: - raise NotImplementedError(f'API endpoint "{request_url}" not implemented in this script') - - -def append_generic_response(data: GenericResponse, filename: str) -> None: - """Append a json payload to the end of a jsonl file.""" - json_string = json.dumps(data.model_dump(), default=str) - with open(filename, "a") as f: - f.write(json_string + "\n") - - -def num_tokens_consumed_from_request( - api_specific_request_json: dict, - api_endpoint: str, - token_encoding_name: str, -): - """Count the number of tokens in the request. Only supports completion and embedding requests.""" - encoding = tiktoken.get_encoding(token_encoding_name) - # if completions request, tokens = prompt + n * max_tokens - if api_endpoint.endswith("completions"): - max_tokens = api_specific_request_json.get("max_tokens", 15) - n = api_specific_request_json.get("n", 1) - completion_tokens = n * max_tokens - - # chat completions - if api_endpoint.startswith("chat/"): - num_tokens = 0 - for message in api_specific_request_json["messages"]: - num_tokens += 4 # every message follows {role/name}\n{content}\n - for key, value in message.items(): - try: - num_tokens += len(encoding.encode(str(value), disallowed_special=())) - except TypeError: - logger.warning( - f"Failed to encode value {value} with tiktoken to count tokens. Instead assuming a token for every 4 characters." - ) - num_tokens += len(str(value)) // 4 - if key == "name": # if there's a name, the role is omitted - num_tokens -= 1 # role is always required and always 1 token - num_tokens += 2 # every reply is primed with assistant - return num_tokens + completion_tokens - # normal completions - else: - prompt = api_specific_request_json["prompt"] - if isinstance(prompt, str): # single prompt - prompt_tokens = len(encoding.encode(prompt, disallowed_special=())) - num_tokens = prompt_tokens + completion_tokens - return num_tokens - elif isinstance(prompt, list): # multiple prompts - prompt_tokens = sum( - [len(encoding.encode(p, disallowed_special=())) for p in prompt] - ) - num_tokens = prompt_tokens + completion_tokens * len(prompt) - return num_tokens - else: - raise TypeError( - 'Expecting either string or list of strings for "prompt" field in completion request' - ) - # if embeddings request, tokens = input tokens - elif api_endpoint == "embeddings": - input = api_specific_request_json["input"] - if isinstance(input, str): # single input - num_tokens = len(encoding.encode(input, disallowed_special=())) - return num_tokens - elif isinstance(input, list): # multiple inputs - num_tokens = sum([len(encoding.encode(i, disallowed_special=())) for i in input]) - return num_tokens - else: - raise TypeError( - 'Expecting either string or list of strings for "inputs" field in embedding request' - ) - # more logic needed to support other API calls (e.g., edits, inserts, DALL-E) - else: - raise NotImplementedError(f'API endpoint "{api_endpoint}" not implemented in this script') - - -def task_id_generator_function(): - """Generate integers 0, 1, 2, and so on.""" - task_id = 0 - while True: - yield task_id - task_id += 1 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/batch/__init__.py b/tests/batch/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/batch/simple_batch.py b/tests/batch/simple_batch.py new file mode 100644 index 00000000..68fbd38c --- /dev/null +++ b/tests/batch/simple_batch.py @@ -0,0 +1,42 @@ +from bespokelabs.curator import Prompter +from datasets import Dataset +import logging +import argparse + +# python tests/batch_tests/simple_batch.py --log-level DEBUG --n-requests 3 --batch-size 1 --batch-check-interval 10 + + +def main(args): + if args.log_level is not None: + logger = logging.getLogger("bespokelabs.curator") + logger.setLevel(args.log_level) + + dataset = Dataset.from_dict({"prompt": ["just say 'hi'"] * args.n_requests}) + + prompter = Prompter( + prompt_func=lambda row: row["prompt"], + model_name="gpt-4o-mini", + response_format=None, + batch=True, + batch_size=args.batch_size, + batch_check_interval=args.batch_check_interval, + ) + + dataset = prompter(dataset, batch_cancel=args.cancel) + print(dataset.to_pandas()) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Simple batch test bed") + parser.add_argument("--cancel", action="store_true", default=False, help="Cancel the batches") + parser.add_argument("--batch-size", type=int, default=1_000, help="Batch size") + parser.add_argument("--batch-check-interval", type=int, default=60, help="Batch check interval") + parser.add_argument("--n-requests", type=int, help="Number of requests to process") + parser.add_argument( + "--log-level", + type=lambda x: getattr(logging, x.upper()), + default=None, + help="Set the logging level (e.g., DEBUG, INFO, WARNING, ERROR, CRITICAL)", + ) + args = parser.parse_args() + main(args) diff --git a/tests/batch/test_resume.py b/tests/batch/test_resume.py new file mode 100644 index 00000000..0248da20 --- /dev/null +++ b/tests/batch/test_resume.py @@ -0,0 +1,46 @@ +import pytest +import time +import os +from tests.helpers import run_script +from tests.helpers import prepare_test_cache + +""" +USAGE: +pytest -s tests/batch/test_resume.py +""" + + +@pytest.mark.cache_dir(os.path.expanduser("~/.cache/curator-tests/test-batch-resume")) +@pytest.mark.usefixtures("prepare_test_cache") +def test_batch_resume(): + script = [ + "python", + "tests/batch/simple_batch.py", + "--log-level", + "DEBUG", + "--n-requests", + "2", + "--batch-size", + "1", + "--batch-check-interval", + "10", + ] + + env = os.environ.copy() + + print("FIRST RUN") + stop_line_pattern = r"Marked batch ID batch_[a-f0-9]{32} as downloaded" + output1, _ = run_script(script, stop_line_pattern, env=env) + print(output1) + + # Small delay to ensure files are written + time.sleep(1) + + # Second run should process the remaining batch + print("SECOND RUN") + output2, _ = run_script(script, env=env) + print(output2) + + # checks + assert "1 out of 2 batches already downloaded." in output2 + assert "1 out of 1 remaining batches are already submitted." in output2 diff --git a/tests/batch/test_switch_keys.py b/tests/batch/test_switch_keys.py new file mode 100644 index 00000000..80eb3984 --- /dev/null +++ b/tests/batch/test_switch_keys.py @@ -0,0 +1,49 @@ +import pytest +import time +import os +from tests.helpers import run_script +from tests.helpers import prepare_test_cache + +""" +USAGE: +pytest -s tests/batch/test_switch_keys.py +""" + + +@pytest.mark.cache_dir(os.path.expanduser("~/.cache/curator-tests/test-batch-switch-keys")) +@pytest.mark.usefixtures("prepare_test_cache") +def test_batch_switch_keys(): + script = [ + "python", + "tests/batch/simple_batch.py", + "--log-level", + "DEBUG", + "--n-requests", + "2", + "--batch-size", + "1", + "--batch-check-interval", + "10", + ] + + env = os.environ.copy() + + # First run should process 1 batch and exit + print("FIRST RUN") + + env["OPENAI_API_KEY"] = os.environ["OPENAI_API_KEY_1"] + stop_line_pattern = r"Marked batch ID batch_[a-f0-9]{32} as downloaded" + output1, _ = run_script(script, stop_line_pattern, env=env) + print(output1) + + # Small delay to ensure files are written + time.sleep(1) + + # Second run should process the remaining batch + print("SECOND RUN") + env["OPENAI_API_KEY"] = os.environ["OPENAI_API_KEY_2"] + output2, _ = run_script(script, env=env) + print(output2) + + # checks + assert "1 out of 1 batches already downloaded." in output2 diff --git a/tests/cache/__init__.py b/tests/cache/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cache/one.py b/tests/cache/one.py new file mode 100644 index 00000000..090b5b44 --- /dev/null +++ b/tests/cache/one.py @@ -0,0 +1,32 @@ +from bespokelabs.curator import Prompter +from datasets import Dataset +import logging +import argparse + +logger = logging.getLogger("bespokelabs.curator") +logger.setLevel(logging.INFO) + + +def main(delete_cache: bool = False): + dataset = Dataset.from_dict({"prompt": ["just say 'hi'"] * 3}) + + prompter = Prompter( + prompt_func=lambda row: row["prompt"], + model_name="gpt-4o-mini", + response_format=None, + delete_cache=delete_cache, + ) + + dataset = prompter(dataset) + print(dataset.to_pandas()) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Run prompter with cache control") + parser.add_argument( + "--delete-cache", + action="store_true", + help="Delete the cache before running", + ) + args = parser.parse_args() + main(delete_cache=args.delete_cache) diff --git a/tests/cache/test_different_files.py b/tests/cache/test_different_files.py new file mode 100644 index 00000000..6b18de07 --- /dev/null +++ b/tests/cache/test_different_files.py @@ -0,0 +1,32 @@ +import pytest +import os +from tests.helpers import run_script +from tests.helpers import prepare_test_cache + +""" +USAGE: +pytest -s tests/cache/test_different_files.py +""" + + +@pytest.mark.cache_dir(os.path.expanduser("~/.cache/curator-tests/test-different-files")) +@pytest.mark.usefixtures("prepare_test_cache") +def test_cache_behavior(): + cache_hit_log = "Using cached output dataset." + + # Run one.py twice and check for cache behavior + print("RUNNING ONE.PY") + output1, _ = run_script(["python", "tests/cache_tests/different_files/one.py"]) + print(output1) + assert cache_hit_log not in output1, "First run of one.py should not hit cache" + + print("RUNNING ONE.PY AGAIN") + output2, _ = run_script(["python", "tests/cache_tests/different_files/one.py"]) + print(output2) + assert cache_hit_log in output2, "Second run of one.py should hit cache" + + # Run two.py and check for cache behavior + print("RUNNING TWO.PY") + output3, _ = run_script(["python", "tests/cache_tests/different_files/two.py"]) + print(output3) + assert cache_hit_log in output3, "First run of two.py should hit cache" diff --git a/tests/cache/two.py b/tests/cache/two.py new file mode 100644 index 00000000..090b5b44 --- /dev/null +++ b/tests/cache/two.py @@ -0,0 +1,32 @@ +from bespokelabs.curator import Prompter +from datasets import Dataset +import logging +import argparse + +logger = logging.getLogger("bespokelabs.curator") +logger.setLevel(logging.INFO) + + +def main(delete_cache: bool = False): + dataset = Dataset.from_dict({"prompt": ["just say 'hi'"] * 3}) + + prompter = Prompter( + prompt_func=lambda row: row["prompt"], + model_name="gpt-4o-mini", + response_format=None, + delete_cache=delete_cache, + ) + + dataset = prompter(dataset) + print(dataset.to_pandas()) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Run prompter with cache control") + parser.add_argument( + "--delete-cache", + action="store_true", + help="Delete the cache before running", + ) + args = parser.parse_args() + main(delete_cache=args.delete_cache) diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 00000000..6eb595c4 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,46 @@ +import pytest +import subprocess +import time +import shutil +import os +import re + + +@pytest.fixture +def prepare_test_cache(request): + """Fixture to ensure clean caches before tests""" + # Get cache_dir from marker if provided, otherwise use default + marker = request.node.get_closest_marker("cache_dir") + cache_dir = marker.args[0] + + os.environ["CURATOR_CACHE_DIR"] = cache_dir + + # Delete caches before test + shutil.rmtree(cache_dir, ignore_errors=True) + + # Run test + yield + + +def run_script(script, stop_line_pattern=None, env=None): + process = subprocess.Popen( + script, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env + ) + + lines = "" + for line in process.stderr: + print(line, end="") # Print each line as it is received + lines += line + if stop_line_pattern and re.search(stop_line_pattern, line): + process.terminate() + break + + for line in process.stdout: + print(line, end="") # Print each line as it is received + lines += line + if stop_line_pattern and re.search(stop_line_pattern, line): + process.terminate() + break + + process.wait() + return lines, process.returncode diff --git a/tests/litellm/__init__.py b/tests/litellm/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/litellm/test_models.py b/tests/litellm/test_models.py new file mode 100644 index 00000000..05bb9b7c --- /dev/null +++ b/tests/litellm/test_models.py @@ -0,0 +1,55 @@ +import pytest +import os +import logging +from datasets import Dataset +from bespokelabs.curator import Prompter +from tests.helpers import prepare_test_cache + +""" +USAGE: +pytest -s tests/litellm/test_models.py +""" + + +@pytest.mark.cache_dir(os.path.expanduser("~/.cache/curator-tests/test-models")) +@pytest.mark.usefixtures("prepare_test_cache") +def test_litellm_models(): + + env = os.environ.copy() + assert "ANTHROPIC_API_KEY" in env, "ANTHROPIC_API_KEY must be set" + assert "OPENAI_API_KEY" in env, "OPENAI_API_KEY must be set" + assert "GEMINI_API_KEY" in env, "GEMINI_API_KEY must be set" + assert "TOGETHER_API_KEY" in env, "TOGETHER_API_KEY must be set" + + models_list = [ + "claude-3-5-sonnet-20240620", # https://docs.litellm.ai/docs/providers/anthropic # anthropic has a different hidden param tokens structure. + "claude-3-5-haiku-20241022", + "claude-3-haiku-20240307", + "claude-3-opus-20240229", + "claude-3-sonnet-20240229", + "gpt-4o-mini", # https://docs.litellm.ai/docs/providers/openai + "gpt-4o-2024-08-06", + "gpt-4-0125-preview", + "gpt-3.5-turbo-1106", + "gemini/gemini-1.5-flash", # https://docs.litellm.ai/docs/providers/gemini; https://ai.google.dev/gemini-api/docs/models # 20-30 iter/s + "gemini/gemini-1.5-pro", # 20-30 iter/s + "together_ai/meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo", # https://docs.together.ai/docs/serverless-models + "together_ai/meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo", + ] + + for model in models_list: + print(f"\n\n========== TESTING {model} ==========\n\n") + logger = logging.getLogger("bespokelabs.curator") + logger.setLevel(logging.DEBUG) + + dataset = Dataset.from_dict({"prompt": ["just say 'hi'"]}) + + prompter = Prompter( + prompt_func=lambda row: row["prompt"], + model_name=model, + response_format=None, + backend="litellm", + ) + + dataset = prompter(dataset) + print(dataset.to_pandas())