Skip to content

Instantly share code, notes, and snippets.

@simonw
Created December 18, 2025 17:54
Show Gist options
  • Select an option

  • Save simonw/5541f099d23044954c6eb63207f15d5e to your computer and use it in GitHub Desktop.

Select an option

Save simonw/5541f099d23044954c6eb63207f15d5e to your computer and use it in GitHub Desktop.
{"type":"summary","summary":"Fix gemma models media resolution bug","leafUuid":"57935458-46f4-4de2-acd9-c2d9b0cfce5f"}
{"type":"file-history-snapshot","messageId":"2d3ea5b0-e396-47eb-9201-ad68121b1145","snapshot":{"messageId":"2d3ea5b0-e396-47eb-9201-ad68121b1145","trackedFileBackups":{},"timestamp":"2025-12-18T17:34:53.085Z"},"isSnapshotUpdate":false}
{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","type":"user","message":{"role":"user","content":"Make it so the media_resolution option has None as the default value, then the code that uses it swaps in MediaResolution.LOW if None is found - this is so we do not log a bunch of records as having deliberately used that media resolution when actually the user didn't specify one"},"uuid":"2d3ea5b0-e396-47eb-9201-ad68121b1145","timestamp":"2025-12-18T17:34:53.080Z","thinkingMetadata":{"level":"high","disabled":false,"triggers":[]},"todos":[]}
{"parentUuid":"2d3ea5b0-e396-47eb-9201-ad68121b1145","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","message":{"model":"claude-haiku-4-5-20251001","id":"msg_015TENj7L9jkMvxu79mZBd9T","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to modify how the `media_resolution` option works. Currently, it probably defaults to something (likely `MediaResolution.LOW`), but they want to:\n\n1. Make the default value `None`\n2. Have the code that uses it check for `None` and swap it in with `MediaResolution.LOW` if needed\n3. The reason is to avoid logging records as having deliberately used `MediaResolution.LOW` when the user didn't specify one\n\nLet me start by exploring the codebase to understand the current implementation. I should search for `media_resolution` to find where it's defined and used.","signature":"EugFCkYIChgCKkAFuApNE8LZdLMsqYR6DAmIfda+zaW+soHsUDe77EGUBbnzTkILLb2s20y/6rMtzXIHdpyaqvABV09l0CkLCXK9EgxHqzCWdyPTPLNTBO8aDGCqHjAAAnDwWcB6lCIwkelgTB87p49ZpdhSHeXA4hexc/x0qcHw0e6L0oQf15z38KA+ZX1jCUkrl3y2U2/RKs8E09yVCyGVyIAjU5585d1bzfNa92L1W0WyRyYtx7/UGwwR4xqhR7uOJ6DR5yVKLlv+wk76sB196PtNdBy/JbQGJIvq5iBSpGcfa7+yeFZ0/ZEnSjC1nFrUGEpAeCrd+R5J12ONBzGDtPUb8HvGzYjFGIUtXdjYBYCFHPGLvwF7VdIGVwp6v199Nb2wRnzhgwUYI+KbDBIHbstwLJ4J9FPp+7iwaOBzb6ommnbeJLfBJydGiQ2qUIqaxa1XeWBATucSXnHy6w/EYpCPReVhguA581113TZWHDZAV65aZ1cUoB8tMj0FqbEchae3Sc5srJ5HfMr3xnr1sFqyljiNwoZSe4O7AKRBmNld+RM2psNOo/Qm/DJt0aMwoOmJV7y9mi8nZPmCtPWZi9ogsNK3Vkx8iCXCoEmoWiNOWYYUlQgHCJaC6xMfdVPIThnUtZCIQBPDOo0D6w8/Yir4fHIn44NDrMzU8MQ6zljhi7C+CplkqjO8NQFrJT9ojEZqP79CveUV+XDQKNYOg8Vs66P9NxOJqGBYhNxJ/wxwHgozHHVSCt8L7UNoF8/M7hHq13stf00pbvwk1TM8kZG2af320ckf0hhcCGct3A1f2EKP8Tj1UFXS3PIZjdohqoWkRLVbR2rHfNO+POtU9yKel8RKbBGsvGCxrUv5YiY8iYwAeWvRE4oGpR4bx5W/ehX0BqSp//5Hv6bkgKovyhYZmQcbXhQegOFMERPjIQzFIWb8l953g1VGFAaNHmnemYT/7VqOuIU15uudXpyKPWo65pJdIttdGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":3737,"cache_read_input_tokens":14940,"cache_creation":{"ephemeral_5m_input_tokens":3737,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWEZAx5prDZnNnwstMvzc","type":"assistant","uuid":"3b74a0f3-9d52-4075-bd8d-aacff37385ce","timestamp":"2025-12-18T17:34:55.490Z"}
{"parentUuid":"3b74a0f3-9d52-4075-bd8d-aacff37385ce","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","message":{"model":"claude-haiku-4-5-20251001","id":"msg_015TENj7L9jkMvxu79mZBd9T","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JiB41iCRmAwCuhQyS2mzTv","name":"Grep","input":{"pattern":"media_resolution","output_mode":"files_with_matches"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":3737,"cache_read_input_tokens":14940,"cache_creation":{"ephemeral_5m_input_tokens":3737,"ephemeral_1h_input_tokens":0},"output_tokens":223,"service_tier":"standard"}},"requestId":"req_011CWEZAx5prDZnNnwstMvzc","type":"assistant","uuid":"70b13c8a-0299-435f-91df-d01f62db1eb9","timestamp":"2025-12-18T17:34:55.804Z"}
{"parentUuid":"70b13c8a-0299-435f-91df-d01f62db1eb9","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JiB41iCRmAwCuhQyS2mzTv","type":"tool_result","content":"Found 2 files\nllm_gemini.py\nREADME.md"}]},"uuid":"0bc5d593-9a88-4731-972b-3f14b5d2c1a1","timestamp":"2025-12-18T17:34:55.851Z","toolUseResult":{"mode":"files_with_matches","filenames":["llm_gemini.py","README.md"],"numFiles":2}}
{"parentUuid":"0bc5d593-9a88-4731-972b-3f14b5d2c1a1","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Cvq3CkNuBoCXNig4m6jCjh","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Good, found 2 files. Let me read the main file to see the current implementation.","signature":"EvkBCkYIChgCKkBhYlm9FHeRvKStgbHsKBBEAjolV7wRjAheppceGnTZhxLcZegT4XBgfGfSBBctE0eDhNMkk2ZezLJbGJ98peSxEgyoa4pco3Q7V7bMvhsaDF9HRm/yUnenHuigFCIwjc1lSD2JIDTgDITzTrF/GGO3IeAvecQ6m9OBr8IrKcbVSdgLo2sz5DtVaS2XvrG2KmG02lEQWvUyKAdRvKzUyNbOn529IkC3gBAYhxz9mNyH5v4pfwPNEz+AM+JbZg9rfRVYJ7mgEFwzyTsQGOa5uO8vH/89uyg9/QgHEOWNUdrOuTgijI9mYmUcCw4qXgygoyaRGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":13,"cache_creation_input_tokens":253,"cache_read_input_tokens":18677,"cache_creation":{"ephemeral_5m_input_tokens":253,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWEZB9dMaSbyZdV7ERYmB","type":"assistant","uuid":"cfcba542-b63c-47eb-91ee-1a669752f08a","timestamp":"2025-12-18T17:34:57.692Z"}
{"parentUuid":"cfcba542-b63c-47eb-91ee-1a669752f08a","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Cvq3CkNuBoCXNig4m6jCjh","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Dboa6r11EssFXP1hrLiTRt","name":"Read","input":{"file_path":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini/llm_gemini.py"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":13,"cache_creation_input_tokens":253,"cache_read_input_tokens":18677,"cache_creation":{"ephemeral_5m_input_tokens":253,"ephemeral_1h_input_tokens":0},"output_tokens":106,"service_tier":"standard"}},"requestId":"req_011CWEZB9dMaSbyZdV7ERYmB","type":"assistant","uuid":"c4483e91-bb3d-482a-bce2-6cb88515b127","timestamp":"2025-12-18T17:34:57.876Z"}
{"parentUuid":"c4483e91-bb3d-482a-bce2-6cb88515b127","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Dboa6r11EssFXP1hrLiTRt","type":"tool_result","content":" 1→import click\n 2→import copy\n 3→from enum import Enum\n 4→import httpx\n 5→import ijson\n 6→import json\n 7→import llm\n 8→import re\n 9→from pydantic import Field, create_model\n 10→from typing import Optional\n 11→\n 12→SAFETY_SETTINGS = [\n 13→ {\n 14→ \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n 15→ \"threshold\": \"BLOCK_NONE\",\n 16→ },\n 17→ {\n 18→ \"category\": \"HARM_CATEGORY_SEXUALLY_EXPLICIT\",\n 19→ \"threshold\": \"BLOCK_NONE\",\n 20→ },\n 21→ {\n 22→ \"category\": \"HARM_CATEGORY_HATE_SPEECH\",\n 23→ \"threshold\": \"BLOCK_NONE\",\n 24→ },\n 25→ {\n 26→ \"category\": \"HARM_CATEGORY_HARASSMENT\",\n 27→ \"threshold\": \"BLOCK_NONE\",\n 28→ },\n 29→]\n 30→\n 31→# https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/ground-gemini#supported_models_2\n 32→GOOGLE_SEARCH_MODELS = {\n 33→ \"gemini-1.5-pro-latest\",\n 34→ \"gemini-1.5-flash-latest\",\n 35→ \"gemini-1.5-pro-001\",\n 36→ \"gemini-1.5-flash-001\",\n 37→ \"gemini-1.5-pro-002\",\n 38→ \"gemini-1.5-flash-002\",\n 39→ \"gemini-2.0-flash-exp\",\n 40→ \"gemini-2.0-flash\",\n 41→ \"gemini-2.5-pro-preview-03-25\",\n 42→ \"gemini-2.5-pro-exp-03-25\",\n 43→ \"gemini-2.5-flash-preview-04-17\",\n 44→ \"gemini-2.5-pro-preview-05-06\",\n 45→ \"gemini-2.5-flash-preview-05-20\",\n 46→ \"gemini-2.5-pro-preview-06-05\",\n 47→ \"gemini-2.5-pro\",\n 48→ \"gemini-2.5-flash\",\n 49→ \"gemini-2.5-flash-lite\",\n 50→ \"gemini-flash-latest\",\n 51→ \"gemini-flash-lite-latest\",\n 52→ \"gemini-2.5-flash-preview-09-2025\",\n 53→ \"gemini-2.5-flash-lite-preview-09-2025\",\n 54→ \"gemini-3-pro-preview\",\n 55→ \"gemini-3-flash-preview\",\n 56→}\n 57→\n 58→# Older Google models used google_search_retrieval instead of google_search\n 59→GOOGLE_SEARCH_MODELS_USING_SEARCH_RETRIEVAL = {\n 60→ \"gemini-1.5-pro-latest\",\n 61→ \"gemini-1.5-flash-latest\",\n 62→ \"gemini-1.5-pro-001\",\n 63→ \"gemini-1.5-flash-001\",\n 64→ \"gemini-1.5-pro-002\",\n 65→ \"gemini-1.5-flash-002\",\n 66→ \"gemini-2.0-flash-exp\",\n 67→}\n 68→\n 69→THINKING_BUDGET_MODELS = {\n 70→ \"gemini-2.0-flash-thinking-exp-01-21\",\n 71→ \"gemini-2.0-flash-thinking-exp-1219\",\n 72→ \"gemini-2.5-flash-preview-04-17\",\n 73→ \"gemini-2.5-pro-exp-03-25\",\n 74→ \"gemini-2.5-pro-preview-03-25\",\n 75→ \"gemini-2.5-pro-preview-05-06\",\n 76→ \"gemini-2.5-flash-preview-05-20\",\n 77→ \"gemini-2.5-pro-preview-06-05\",\n 78→ \"gemini-2.5-pro\",\n 79→ \"gemini-2.5-flash\",\n 80→ \"gemini-2.5-flash-lite\",\n 81→ \"gemini-flash-latest\",\n 82→ \"gemini-flash-lite-latest\",\n 83→ \"gemini-2.5-flash-preview-09-2025\",\n 84→ \"gemini-2.5-flash-lite-preview-09-2025\",\n 85→}\n 86→\n 87→# Model-specific thinking levels - models not listed here don't support thinking_level\n 88→MODEL_THINKING_LEVELS = {\n 89→ \"gemini-3-flash-preview\": [\"minimal\", \"low\", \"medium\", \"high\"],\n 90→ \"gemini-3-pro-preview\": [\"low\", \"high\"],\n 91→}\n 92→\n 93→NO_VISION_MODELS = {\"gemma-3-1b-it\", \"gemma-3n-e4b-it\"}\n 94→\n 95→NO_MEDIA_RESOLUTION_MODELS = {\n 96→ \"gemma-3-1b-it\",\n 97→ \"gemma-3-4b-it\",\n 98→ \"gemma-3-12b-it\",\n 99→ \"gemma-3-27b-it\",\n 100→ \"gemma-3n-e4b-it\",\n 101→}\n 102→\n 103→\n 104→class MediaResolution(str, Enum):\n 105→ \"\"\"Allowed media resolution values for Gemini models.\"\"\"\n 106→\n 107→ LOW = \"low\"\n 108→ MEDIUM = \"medium\"\n 109→ HIGH = \"high\"\n 110→ UNSPECIFIED = \"unspecified\"\n 111→\n 112→\n 113→ATTACHMENT_TYPES = {\n 114→ # Text\n 115→ \"text/plain\",\n 116→ \"text/csv\",\n 117→ \"text/html; charset=utf-8\",\n 118→ # PDF\n 119→ \"application/pdf\",\n 120→ # Images\n 121→ \"image/png\",\n 122→ \"image/jpeg\",\n 123→ \"image/webp\",\n 124→ \"image/heic\",\n 125→ \"image/heif\",\n 126→ # Audio\n 127→ \"audio/wav\",\n 128→ \"audio/mp3\",\n 129→ \"audio/aiff\",\n 130→ \"audio/aac\",\n 131→ \"audio/ogg\",\n 132→ \"application/ogg\",\n 133→ \"audio/flac\",\n 134→ \"audio/mpeg\", # Treated as audio/mp3\n 135→ # Video\n 136→ \"video/mp4\",\n 137→ \"video/mpeg\",\n 138→ \"video/mov\",\n 139→ \"video/avi\",\n 140→ \"video/x-flv\",\n 141→ \"video/mpg\",\n 142→ \"video/webm\",\n 143→ \"video/wmv\",\n 144→ \"video/3gpp\",\n 145→ \"video/quicktime\",\n 146→ \"video/youtube\",\n 147→}\n 148→\n 149→\n 150→@llm.hookimpl\n 151→def register_models(register):\n 152→ # Register both sync and async versions of each model\n 153→ for model_id in (\n 154→ \"gemini-pro\",\n 155→ \"gemini-1.5-pro-latest\",\n 156→ \"gemini-1.5-flash-latest\",\n 157→ \"gemini-1.5-pro-001\",\n 158→ \"gemini-1.5-flash-001\",\n 159→ \"gemini-1.5-pro-002\",\n 160→ \"gemini-1.5-flash-002\",\n 161→ \"gemini-1.5-flash-8b-latest\",\n 162→ \"gemini-1.5-flash-8b-001\",\n 163→ \"gemini-exp-1114\",\n 164→ \"gemini-exp-1121\",\n 165→ \"gemini-exp-1206\",\n 166→ \"gemini-2.0-flash-exp\",\n 167→ \"learnlm-1.5-pro-experimental\",\n 168→ # Gemma 3 models:\n 169→ \"gemma-3-1b-it\",\n 170→ \"gemma-3-4b-it\",\n 171→ \"gemma-3-12b-it\", # 12th March 2025\n 172→ \"gemma-3-27b-it\",\n 173→ \"gemma-3n-e4b-it\", # 20th May 2025\n 174→ \"gemini-2.0-flash-thinking-exp-1219\",\n 175→ \"gemini-2.0-flash-thinking-exp-01-21\",\n 176→ # Released 5th Feb 2025:\n 177→ \"gemini-2.0-flash\",\n 178→ \"gemini-2.0-pro-exp-02-05\",\n 179→ # Released 25th Feb 2025:\n 180→ \"gemini-2.0-flash-lite\",\n 181→ # 25th March 2025:\n 182→ \"gemini-2.5-pro-exp-03-25\",\n 183→ # 4th April 2025 (paid):\n 184→ \"gemini-2.5-pro-preview-03-25\",\n 185→ # 17th April 2025:\n 186→ \"gemini-2.5-flash-preview-04-17\",\n 187→ # 6th May 2025:\n 188→ \"gemini-2.5-pro-preview-05-06\",\n 189→ # 20th May 2025:\n 190→ \"gemini-2.5-flash-preview-05-20\",\n 191→ # 5th June 2025:\n 192→ \"gemini-2.5-pro-preview-06-05\",\n 193→ \"gemini-2.5-flash\",\n 194→ \"gemini-2.5-pro\",\n 195→ # 22nd July 2025:\n 196→ \"gemini-2.5-flash-lite\",\n 197→ # 25th Spetember 2025:\n 198→ \"gemini-flash-latest\",\n 199→ \"gemini-flash-lite-latest\",\n 200→ \"gemini-2.5-flash-preview-09-2025\",\n 201→ \"gemini-2.5-flash-lite-preview-09-2025\",\n 202→ # 18th November 2025:\n 203→ \"gemini-3-pro-preview\",\n 204→ # 17th December 2025:\n 205→ \"gemini-3-flash-preview\",\n 206→ ):\n 207→ can_google_search = model_id in GOOGLE_SEARCH_MODELS\n 208→ can_thinking_budget = model_id in THINKING_BUDGET_MODELS\n 209→ thinking_levels = MODEL_THINKING_LEVELS.get(model_id)\n 210→ can_vision = model_id not in NO_VISION_MODELS\n 211→ can_schema = \"flash-thinking\" not in model_id and \"gemma-3\" not in model_id\n 212→ can_media_resolution = model_id not in NO_MEDIA_RESOLUTION_MODELS\n 213→ register(\n 214→ GeminiPro(\n 215→ model_id,\n 216→ can_vision=can_vision,\n 217→ can_google_search=can_google_search,\n 218→ can_thinking_budget=can_thinking_budget,\n 219→ thinking_levels=thinking_levels,\n 220→ can_schema=can_schema,\n 221→ can_media_resolution=can_media_resolution,\n 222→ ),\n 223→ AsyncGeminiPro(\n 224→ model_id,\n 225→ can_vision=can_vision,\n 226→ can_google_search=can_google_search,\n 227→ can_thinking_budget=can_thinking_budget,\n 228→ thinking_levels=thinking_levels,\n 229→ can_schema=can_schema,\n 230→ can_media_resolution=can_media_resolution,\n 231→ ),\n 232→ aliases=(model_id,),\n 233→ )\n 234→\n 235→\n 236→def resolve_type(attachment):\n 237→ mime_type = attachment.resolve_type()\n 238→ # https://github.com/simonw/llm/issues/587#issuecomment-2439785140\n 239→ if mime_type == \"audio/mpeg\":\n 240→ mime_type = \"audio/mp3\"\n 241→ if mime_type == \"application/ogg\":\n 242→ mime_type = \"audio/ogg\"\n 243→ # Check if this is a YouTube URL\n 244→ if attachment.url and is_youtube_url(attachment.url):\n 245→ return \"video/youtube\"\n 246→ return mime_type\n 247→\n 248→\n 249→def is_youtube_url(url):\n 250→ \"\"\"Check if a URL is a YouTube video URL\"\"\"\n 251→ if not url:\n 252→ return False\n 253→ youtube_patterns = [\n 254→ r\"^https?://(www\\.)?youtube\\.com/watch\\?v=\",\n 255→ r\"^https?://youtu\\.be/\",\n 256→ r\"^https?://(www\\.)?youtube\\.com/embed/\",\n 257→ ]\n 258→ return any(re.match(pattern, url) for pattern in youtube_patterns)\n 259→\n 260→\n 261→def cleanup_schema(schema, in_properties=False):\n 262→ \"Gemini supports only a subset of JSON schema\"\n 263→ keys_to_remove = (\"$schema\", \"additionalProperties\", \"title\")\n 264→\n 265→ # First pass: resolve $ref references using $defs\n 266→ if isinstance(schema, dict) and \"$defs\" in schema:\n 267→ defs = schema.pop(\"$defs\")\n 268→ _resolve_refs(schema, defs)\n 269→\n 270→ if isinstance(schema, dict):\n 271→ # Only remove keys if we're not inside a 'properties' block.\n 272→ if not in_properties:\n 273→ for key in keys_to_remove:\n 274→ schema.pop(key, None)\n 275→ for key, value in list(schema.items()):\n 276→ # If the key is 'properties', set the flag for its value.\n 277→ if key == \"properties\" and isinstance(value, dict):\n 278→ cleanup_schema(value, in_properties=True)\n 279→ else:\n 280→ cleanup_schema(value, in_properties=in_properties)\n 281→ elif isinstance(schema, list):\n 282→ for item in schema:\n 283→ cleanup_schema(item, in_properties=in_properties)\n 284→ return schema\n 285→\n 286→\n 287→def _resolve_refs(schema, defs, expansion_stack=None):\n 288→ \"\"\"Recursively resolve $ref references in schema using definitions.\n 289→\n 290→ Args:\n 291→ schema: The schema to resolve references in\n 292→ defs: Dictionary of definitions from $defs\n 293→ expansion_stack: List tracking the order of definition expansions (for cycle detection)\n 294→\n 295→ Raises:\n 296→ ValueError: If a recursive self-reference is detected\n 297→ \"\"\"\n 298→ if expansion_stack is None:\n 299→ expansion_stack = []\n 300→\n 301→ if isinstance(schema, dict):\n 302→ if \"$ref\" in schema:\n 303→ # Extract the reference path (e.g., \"#/$defs/Dog\" -> \"Dog\")\n 304→ ref_path = schema.pop(\"$ref\")\n 305→ if ref_path.startswith(\"#/$defs/\"):\n 306→ def_name = ref_path.split(\"/\")[-1]\n 307→ if def_name in defs:\n 308→ # Check for recursive reference\n 309→ if def_name in expansion_stack:\n 310→ # Determine if this is direct or indirect recursion\n 311→ if expansion_stack[-1] == def_name:\n 312→ # Direct recursion: the definition references itself\n 313→ raise ValueError(\n 314→ f\"Recursive schema detected: '{def_name}' directly references itself. \"\n 315→ f\"The Gemini API does not support recursive Pydantic models. \"\n 316→ f\"Please use a non-recursive schema structure.\"\n 317→ )\n 318→ else:\n 319→ # Indirect recursion: the class being referenced is earlier in the stack\n 320→ # The last item in expansion_stack is the immediate class that references def_name\n 321→ intermediate = expansion_stack[-1]\n 322→ raise ValueError(\n 323→ f\"Recursive schema detected: '{def_name}' indirectly references itself through '{intermediate}'. \"\n 324→ f\"The Gemini API does not support recursive Pydantic models. \"\n 325→ f\"Please use a non-recursive schema structure.\"\n 326→ )\n 327→\n 328→ # Add this definition to the expansion stack\n 329→ expansion_stack.append(def_name)\n 330→ # Replace the $ref with the actual definition\n 331→ resolved = copy.deepcopy(defs[def_name])\n 332→ schema.update(resolved)\n 333→ # Resolve any nested refs in the newly added definition\n 334→ _resolve_refs(schema, defs, expansion_stack)\n 335→ # Remove from stack after processing\n 336→ expansion_stack.pop()\n 337→ return\n 338→\n 339→ # Recursively resolve refs in nested structures\n 340→ for value in schema.values():\n 341→ _resolve_refs(value, defs, expansion_stack)\n 342→ elif isinstance(schema, list):\n 343→ for item in schema:\n 344→ _resolve_refs(item, defs, expansion_stack)\n 345→\n 346→\n 347→class _SharedGemini:\n 348→ needs_key = \"gemini\"\n 349→ key_env_var = \"LLM_GEMINI_KEY\"\n 350→ can_stream = True\n 351→ supports_schema = True\n 352→ supports_tools = True\n 353→\n 354→ attachment_types = set()\n 355→\n 356→ class Options(llm.Options):\n 357→ code_execution: Optional[bool] = Field(\n 358→ description=\"Enables the model to generate and run Python code\",\n 359→ default=None,\n 360→ )\n 361→ temperature: Optional[float] = Field(\n 362→ description=(\n 363→ \"Controls the randomness of the output. Use higher values for \"\n 364→ \"more creative responses, and lower values for more \"\n 365→ \"deterministic responses.\"\n 366→ ),\n 367→ default=None,\n 368→ ge=0.0,\n 369→ le=2.0,\n 370→ )\n 371→ max_output_tokens: Optional[int] = Field(\n 372→ description=\"Sets the maximum number of tokens to include in a candidate.\",\n 373→ default=None,\n 374→ )\n 375→ top_p: Optional[float] = Field(\n 376→ description=(\n 377→ \"Changes how the model selects tokens for output. Tokens are \"\n 378→ \"selected from the most to least probable until the sum of \"\n 379→ \"their probabilities equals the topP value.\"\n 380→ ),\n 381→ default=None,\n 382→ ge=0.0,\n 383→ le=1.0,\n 384→ )\n 385→ top_k: Optional[int] = Field(\n 386→ description=(\n 387→ \"Changes how the model selects tokens for output. A topK of 1 \"\n 388→ \"means the selected token is the most probable among all the \"\n 389→ \"tokens in the model's vocabulary, while a topK of 3 means \"\n 390→ \"that the next token is selected from among the 3 most \"\n 391→ \"probable using the temperature.\"\n 392→ ),\n 393→ default=None,\n 394→ ge=1,\n 395→ )\n 396→ json_object: Optional[bool] = Field(\n 397→ description=\"Output a valid JSON object {...}\",\n 398→ default=None,\n 399→ )\n 400→ timeout: Optional[float] = Field(\n 401→ description=(\n 402→ \"The maximum time in seconds to wait for a response. \"\n 403→ \"If the model does not respond within this time, \"\n 404→ \"the request will be aborted.\"\n 405→ ),\n 406→ default=None,\n 407→ )\n 408→ url_context: Optional[bool] = Field(\n 409→ description=(\n 410→ \"Enable the URL context tool so the model can fetch content \"\n 411→ \"from URLs mentioned in the prompt\"\n 412→ ),\n 413→ default=None,\n 414→ )\n 415→\n 416→ def __init__(\n 417→ self,\n 418→ gemini_model_id,\n 419→ can_vision=True,\n 420→ can_google_search=False,\n 421→ can_thinking_budget=False,\n 422→ thinking_levels=None,\n 423→ can_schema=False,\n 424→ can_media_resolution=True,\n 425→ ):\n 426→ self.model_id = \"gemini/{}\".format(gemini_model_id)\n 427→ self.gemini_model_id = gemini_model_id\n 428→ self.can_google_search = can_google_search\n 429→ self.supports_schema = can_schema\n 430→ self.can_thinking_budget = can_thinking_budget\n 431→ self.thinking_levels = thinking_levels\n 432→ self.can_media_resolution = can_media_resolution\n 433→\n 434→ # Build Options class dynamically based on capabilities\n 435→ extra_fields = {}\n 436→\n 437→ if can_google_search:\n 438→ extra_fields[\"google_search\"] = (\n 439→ Optional[bool],\n 440→ Field(\n 441→ description=\"Enables the model to use Google Search to improve the accuracy and recency of responses from the model\",\n 442→ default=None,\n 443→ ),\n 444→ )\n 445→\n 446→ if can_thinking_budget:\n 447→ extra_fields[\"thinking_budget\"] = (\n 448→ Optional[int],\n 449→ Field(\n 450→ description=\"Indicates the thinking budget in tokens. Set to 0 to disable.\",\n 451→ default=None,\n 452→ ),\n 453→ )\n 454→\n 455→ if thinking_levels:\n 456→ # Create a dynamic enum with only the supported levels\n 457→ ThinkingLevelEnum = Enum(\n 458→ \"ThinkingLevel\",\n 459→ {level.upper(): level for level in thinking_levels},\n 460→ type=str,\n 461→ )\n 462→ level_choices = \", \".join(f\"'{level}'\" for level in thinking_levels)\n 463→ extra_fields[\"thinking_level\"] = (\n 464→ Optional[ThinkingLevelEnum],\n 465→ Field(\n 466→ description=f\"Indicates the thinking level. Can be {level_choices}.\",\n 467→ default=None,\n 468→ ),\n 469→ )\n 470→\n 471→ if can_media_resolution:\n 472→ extra_fields[\"media_resolution\"] = (\n 473→ Optional[MediaResolution],\n 474→ Field(\n 475→ description=(\n 476→ \"Media resolution for the input media (esp. YouTube) \"\n 477→ \"- default is low, other values are medium, high, or unspecified\"\n 478→ ),\n 479→ default=MediaResolution.LOW,\n 480→ ),\n 481→ )\n 482→\n 483→ if extra_fields:\n 484→ self.Options = create_model(\n 485→ \"Options\",\n 486→ __base__=self.Options,\n 487→ **extra_fields,\n 488→ )\n 489→\n 490→ if can_vision:\n 491→ self.attachment_types = ATTACHMENT_TYPES\n 492→\n 493→ def build_messages(self, prompt, conversation):\n 494→ messages = []\n 495→ if conversation:\n 496→ for response in conversation.responses:\n 497→ parts = []\n 498→ for attachment in response.attachments:\n 499→ mime_type = resolve_type(attachment)\n 500→ parts.append(self._build_attachment_part(attachment, mime_type))\n 501→ if response.prompt.prompt:\n 502→ parts.append({\"text\": response.prompt.prompt})\n 503→ if response.prompt.tool_results:\n 504→ parts.extend(\n 505→ [\n 506→ {\n 507→ \"function_response\": {\n 508→ \"name\": tool_result.name,\n 509→ \"response\": {\n 510→ \"output\": tool_result.output,\n 511→ },\n 512→ }\n 513→ }\n 514→ for tool_result in response.prompt.tool_results\n 515→ ]\n 516→ )\n 517→ messages.append({\"role\": \"user\", \"parts\": parts})\n 518→ model_parts = []\n 519→ response_text = response.text_or_raise()\n 520→ model_parts.append({\"text\": response_text})\n 521→ tool_calls = response.tool_calls_or_raise()\n 522→ if tool_calls:\n 523→ model_parts.extend(\n 524→ [\n 525→ {\n 526→ \"function_call\": {\n 527→ \"name\": tool_call.name,\n 528→ \"args\": tool_call.arguments,\n 529→ }\n 530→ }\n 531→ for tool_call in tool_calls\n 532→ ]\n 533→ )\n 534→ messages.append({\"role\": \"model\", \"parts\": model_parts})\n 535→\n 536→ parts = []\n 537→ if prompt.prompt:\n 538→ parts.append({\"text\": prompt.prompt})\n 539→ if prompt.tool_results:\n 540→ parts.extend(\n 541→ [\n 542→ {\n 543→ \"function_response\": {\n 544→ \"name\": tool_result.name,\n 545→ \"response\": {\n 546→ \"output\": tool_result.output,\n 547→ },\n 548→ }\n 549→ }\n 550→ for tool_result in prompt.tool_results\n 551→ ]\n 552→ )\n 553→ for attachment in prompt.attachments:\n 554→ mime_type = resolve_type(attachment)\n 555→ parts.append(self._build_attachment_part(attachment, mime_type))\n 556→\n 557→ messages.append({\"role\": \"user\", \"parts\": parts})\n 558→ return messages\n 559→\n 560→ def _build_attachment_part(self, attachment, mime_type):\n 561→ \"\"\"Build the appropriate part for an attachment based on its type\"\"\"\n 562→ if mime_type == \"video/youtube\":\n 563→ return {\"fileData\": {\"mimeType\": mime_type, \"fileUri\": attachment.url}}\n 564→ else:\n 565→ return {\n 566→ \"inlineData\": {\n 567→ \"data\": attachment.base64_content(),\n 568→ \"mimeType\": mime_type,\n 569→ }\n 570→ }\n 571→\n 572→ def build_request_body(self, prompt, conversation):\n 573→ body = {\n 574→ \"contents\": self.build_messages(prompt, conversation),\n 575→ \"safetySettings\": SAFETY_SETTINGS,\n 576→ }\n 577→ if prompt.system:\n 578→ body[\"systemInstruction\"] = {\"parts\": [{\"text\": prompt.system}]}\n 579→\n 580→ tools = []\n 581→ if prompt.options and prompt.options.code_execution:\n 582→ tools.append({\"codeExecution\": {}})\n 583→ if prompt.options and self.can_google_search and prompt.options.google_search:\n 584→ tool_name = (\n 585→ \"google_search_retrieval\"\n 586→ if self.model_id in GOOGLE_SEARCH_MODELS_USING_SEARCH_RETRIEVAL\n 587→ else \"google_search\"\n 588→ )\n 589→ tools.append({tool_name: {}})\n 590→ if prompt.options and prompt.options.url_context:\n 591→ tools.append({\"url_context\": {}})\n 592→ if prompt.tools:\n 593→ tools.append(\n 594→ {\n 595→ \"functionDeclarations\": [\n 596→ {\n 597→ \"name\": tool.name,\n 598→ \"description\": tool.description,\n 599→ \"parameters\": cleanup_schema(\n 600→ copy.deepcopy(tool.input_schema)\n 601→ ),\n 602→ }\n 603→ for tool in prompt.tools\n 604→ ]\n 605→ }\n 606→ )\n 607→ if tools:\n 608→ body[\"tools\"] = tools\n 609→\n 610→ generation_config = {}\n 611→\n 612→ if prompt.schema:\n 613→ generation_config.update(\n 614→ {\n 615→ \"response_mime_type\": \"application/json\",\n 616→ \"response_schema\": cleanup_schema(copy.deepcopy(prompt.schema)),\n 617→ }\n 618→ )\n 619→\n 620→ if self.can_thinking_budget and prompt.options.thinking_budget is not None:\n 621→ generation_config[\"thinking_config\"] = {\n 622→ \"thinking_budget\": prompt.options.thinking_budget\n 623→ }\n 624→\n 625→ if (\n 626→ self.thinking_levels\n 627→ and getattr(prompt.options, \"thinking_level\", None) is not None\n 628→ ):\n 629→ # Get the string value from the enum\n 630→ thinking_level = prompt.options.thinking_level\n 631→ if hasattr(thinking_level, \"value\"):\n 632→ thinking_level = thinking_level.value\n 633→ generation_config[\"thinkingConfig\"] = {\"thinkingLevel\": thinking_level}\n 634→\n 635→ config_map = {\n 636→ \"temperature\": \"temperature\",\n 637→ \"max_output_tokens\": \"maxOutputTokens\",\n 638→ \"top_p\": \"topP\",\n 639→ \"top_k\": \"topK\",\n 640→ }\n 641→ if prompt.options and prompt.options.json_object:\n 642→ generation_config[\"response_mime_type\"] = \"application/json\"\n 643→\n 644→ if any(\n 645→ getattr(prompt.options, key, None) is not None for key in config_map.keys()\n 646→ ):\n 647→ for key, other_key in config_map.items():\n 648→ config_value = getattr(prompt.options, key, None)\n 649→ if config_value is not None:\n 650→ generation_config[other_key] = config_value\n 651→\n 652→ has_youtube = any(\n 653→ attachment.url and is_youtube_url(attachment.url)\n 654→ for attachment in prompt.attachments\n 655→ ) or (\n 656→ conversation\n 657→ and any(\n 658→ attachment.url and is_youtube_url(attachment.url)\n 659→ for response in conversation.responses\n 660→ for attachment in response.attachments\n 661→ )\n 662→ )\n 663→\n 664→ # See https://ai.google.dev/api/generate-content#MediaResolution for mediaResolution token counts\n 665→ if self.can_media_resolution:\n 666→ media_resolution = getattr(prompt.options, \"media_resolution\", None)\n 667→ if media_resolution:\n 668→ generation_config[\"mediaResolution\"] = (\n 669→ f\"MEDIA_RESOLUTION_{media_resolution.value.upper()}\"\n 670→ )\n 671→ elif has_youtube: # support longer videos even if no option set\n 672→ generation_config[\"mediaResolution\"] = \"MEDIA_RESOLUTION_LOW\"\n 673→\n 674→ if generation_config:\n 675→ body[\"generationConfig\"] = generation_config\n 676→\n 677→ return body\n 678→\n 679→ def process_part(self, part, response):\n 680→ if \"functionCall\" in part:\n 681→ response.add_tool_call(\n 682→ llm.ToolCall(\n 683→ name=part[\"functionCall\"][\"name\"],\n 684→ arguments=part[\"functionCall\"][\"args\"],\n 685→ )\n 686→ )\n 687→ if \"text\" in part:\n 688→ return part[\"text\"]\n 689→ elif \"executableCode\" in part:\n 690→ return f'```{part[\"executableCode\"][\"language\"].lower()}\\n{part[\"executableCode\"][\"code\"].strip()}\\n```\\n'\n 691→ elif \"codeExecutionResult\" in part:\n 692→ return f'```\\n{part[\"codeExecutionResult\"][\"output\"].strip()}\\n```\\n'\n 693→ return \"\"\n 694→\n 695→ def process_candidates(self, candidates, response):\n 696→ # We only use the first candidate\n 697→ for part in candidates[0][\"content\"][\"parts\"]:\n 698→ yield self.process_part(part, response)\n 699→\n 700→ def set_usage(self, response):\n 701→ try:\n 702→ # Don't record the \"content\" key from that last candidate\n 703→ for candidate in response.response_json[\"candidates\"]:\n 704→ candidate.pop(\"content\", None)\n 705→ usage = response.response_json.pop(\"usageMetadata\")\n 706→ input_tokens = usage.pop(\"promptTokenCount\", None)\n 707→ # See https://github.com/simonw/llm-gemini/issues/75#issuecomment-2861827509\n 708→ candidates_token_count = usage.get(\"candidatesTokenCount\") or 0\n 709→ thoughts_token_count = usage.get(\"thoughtsTokenCount\") or 0\n 710→ output_tokens = candidates_token_count + thoughts_token_count\n 711→ tool_token_count = usage.get(\"toolUsePromptTokenCount\") or 0\n 712→ if tool_token_count:\n 713→ if input_tokens is None:\n 714→ input_tokens = tool_token_count\n 715→ else:\n 716→ input_tokens += tool_token_count\n 717→ usage.pop(\"totalTokenCount\", None)\n 718→ if input_tokens is not None:\n 719→ response.set_usage(\n 720→ input=input_tokens, output=output_tokens, details=usage or None\n 721→ )\n 722→ except (IndexError, KeyError):\n 723→ pass\n 724→\n 725→\n 726→class GeminiPro(_SharedGemini, llm.KeyModel):\n 727→ def execute(self, prompt, stream, response, conversation, key):\n 728→ url = f\"https://generativelanguage.googleapis.com/v1beta/models/{self.gemini_model_id}:streamGenerateContent\"\n 729→ gathered = []\n 730→ body = self.build_request_body(prompt, conversation)\n 731→\n 732→ with httpx.stream(\n 733→ \"POST\",\n 734→ url,\n 735→ timeout=prompt.options.timeout,\n 736→ headers={\"x-goog-api-key\": self.get_key(key)},\n 737→ json=body,\n 738→ ) as http_response:\n 739→ events = ijson.sendable_list()\n 740→ coro = ijson.items_coro(events, \"item\")\n 741→ for chunk in http_response.iter_bytes():\n 742→ coro.send(chunk)\n 743→ if events:\n 744→ for event in events:\n 745→ if isinstance(event, dict) and \"error\" in event:\n 746→ raise llm.ModelError(event[\"error\"][\"message\"])\n 747→ try:\n 748→ yield from self.process_candidates(\n 749→ event[\"candidates\"], response\n 750→ )\n 751→ except KeyError:\n 752→ yield \"\"\n 753→ gathered.append(event)\n 754→ events.clear()\n 755→ response.response_json = gathered[-1]\n 756→ resolved_model = gathered[-1][\"modelVersion\"]\n 757→ response.set_resolved_model(resolved_model)\n 758→ self.set_usage(response)\n 759→\n 760→\n 761→class AsyncGeminiPro(_SharedGemini, llm.AsyncKeyModel):\n 762→ async def execute(self, prompt, stream, response, conversation, key):\n 763→ url = f\"https://generativelanguage.googleapis.com/v1beta/models/{self.gemini_model_id}:streamGenerateContent\"\n 764→ gathered = []\n 765→ body = self.build_request_body(prompt, conversation)\n 766→\n 767→ async with httpx.AsyncClient() as client:\n 768→ async with client.stream(\n 769→ \"POST\",\n 770→ url,\n 771→ timeout=prompt.options.timeout,\n 772→ headers={\"x-goog-api-key\": self.get_key(key)},\n 773→ json=body,\n 774→ ) as http_response:\n 775→ events = ijson.sendable_list()\n 776→ coro = ijson.items_coro(events, \"item\")\n 777→ async for chunk in http_response.aiter_bytes():\n 778→ coro.send(chunk)\n 779→ if events:\n 780→ for event in events:\n 781→ if isinstance(event, dict) and \"error\" in event:\n 782→ raise llm.ModelError(event[\"error\"][\"message\"])\n 783→ try:\n 784→ for chunk in self.process_candidates(\n 785→ event[\"candidates\"], response\n 786→ ):\n 787→ yield chunk\n 788→ except KeyError:\n 789→ yield \"\"\n 790→ gathered.append(event)\n 791→ events.clear()\n 792→ response.response_json = gathered[-1]\n 793→ self.set_usage(response)\n 794→\n 795→\n 796→@llm.hookimpl\n 797→def register_embedding_models(register):\n 798→ register(GeminiEmbeddingModel(\"text-embedding-004\", \"text-embedding-004\"))\n 799→ # gemini-embedding-exp-03-07 in different truncation sizes\n 800→ register(\n 801→ GeminiEmbeddingModel(\n 802→ \"gemini-embedding-exp-03-07\", \"gemini-embedding-exp-03-07\"\n 803→ ),\n 804→ )\n 805→ for i in (128, 256, 512, 1024, 2048):\n 806→ register(\n 807→ GeminiEmbeddingModel(\n 808→ f\"gemini-embedding-exp-03-07-{i}\", f\"gemini-embedding-exp-03-07\", i\n 809→ ),\n 810→ )\n 811→\n 812→\n 813→class GeminiEmbeddingModel(llm.EmbeddingModel):\n 814→ needs_key = \"gemini\"\n 815→ key_env_var = \"LLM_GEMINI_KEY\"\n 816→ batch_size = 20\n 817→\n 818→ def __init__(self, model_id, gemini_model_id, truncate=None):\n 819→ self.model_id = model_id\n 820→ self.gemini_model_id = gemini_model_id\n 821→ self.truncate = truncate\n 822→\n 823→ def embed_batch(self, items):\n 824→ headers = {\n 825→ \"Content-Type\": \"application/json\",\n 826→ \"x-goog-api-key\": self.get_key(),\n 827→ }\n 828→ data = {\n 829→ \"requests\": [\n 830→ {\n 831→ \"model\": \"models/\" + self.gemini_model_id,\n 832→ \"content\": {\"parts\": [{\"text\": item}]},\n 833→ }\n 834→ for item in items\n 835→ ]\n 836→ }\n 837→\n 838→ with httpx.Client() as client:\n 839→ response = client.post(\n 840→ f\"https://generativelanguage.googleapis.com/v1beta/models/{self.gemini_model_id}:batchEmbedContents\",\n 841→ headers=headers,\n 842→ json=data,\n 843→ timeout=None,\n 844→ )\n 845→\n 846→ response.raise_for_status()\n 847→ values = [item[\"values\"] for item in response.json()[\"embeddings\"]]\n 848→ if self.truncate:\n 849→ values = [value[: self.truncate] for value in values]\n 850→ return values\n 851→\n 852→\n 853→@llm.hookimpl\n 854→def register_commands(cli):\n 855→ @cli.group()\n 856→ def gemini():\n 857→ \"Commands relating to the llm-gemini plugin\"\n 858→\n 859→ @gemini.command()\n 860→ @click.option(\"--key\", help=\"API key to use\")\n 861→ @click.option(\n 862→ \"methods\",\n 863→ \"--method\",\n 864→ multiple=True,\n 865→ help=\"Filter by supported generation methods\",\n 866→ )\n 867→ def models(key, methods):\n 868→ \"\"\"\n 869→ List of Gemini models pulled from their API\n 870→\n 871→ Use --method to filter by supported generation methods for example:\n 872→\n 873→ llm gemini models --method generateContent --method embedContent\n 874→ \"\"\"\n 875→ key = llm.get_key(key, \"gemini\", \"LLM_GEMINI_KEY\")\n 876→ if not key:\n 877→ raise click.ClickException(\n 878→ \"You must set the LLM_GEMINI_KEY environment variable or use --key\"\n 879→ )\n 880→ url = f\"https://generativelanguage.googleapis.com/v1beta/models\"\n 881→ response = httpx.get(url, headers={\"x-goog-api-key\": key})\n 882→ response.raise_for_status()\n 883→ models = response.json()[\"models\"]\n 884→ if methods:\n 885→ models = [\n 886→ model\n 887→ for model in models\n 888→ if any(\n 889→ method in model[\"supportedGenerationMethods\"] for method in methods\n 890→ )\n 891→ ]\n 892→ click.echo(json.dumps(models, indent=2))\n 893→\n 894→ @gemini.command()\n 895→ @click.option(\"--key\", help=\"API key to use\")\n 896→ def files(key):\n 897→ \"List of files uploaded to the Gemini API\"\n 898→ key = llm.get_key(key, \"gemini\", \"LLM_GEMINI_KEY\")\n 899→ response = httpx.get(\n 900→ f\"https://generativelanguage.googleapis.com/v1beta/files?key={key}\",\n 901→ )\n 902→ response.raise_for_status()\n 903→ if \"files\" in response.json():\n 904→ click.echo(json.dumps(response.json()[\"files\"], indent=2))\n 905→ else:\n 906→ click.echo(\"No files uploaded to the Gemini API.\", err=True)\n 907→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"uuid":"78d367c5-5630-45bf-a86e-32ca866d8d80","timestamp":"2025-12-18T17:34:58.105Z","toolUseResult":{"type":"text","file":{"filePath":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini/llm_gemini.py","content":"import click\nimport copy\nfrom enum import Enum\nimport httpx\nimport ijson\nimport json\nimport llm\nimport re\nfrom pydantic import Field, create_model\nfrom typing import Optional\n\nSAFETY_SETTINGS = [\n {\n \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n \"threshold\": \"BLOCK_NONE\",\n },\n {\n \"category\": \"HARM_CATEGORY_SEXUALLY_EXPLICIT\",\n \"threshold\": \"BLOCK_NONE\",\n },\n {\n \"category\": \"HARM_CATEGORY_HATE_SPEECH\",\n \"threshold\": \"BLOCK_NONE\",\n },\n {\n \"category\": \"HARM_CATEGORY_HARASSMENT\",\n \"threshold\": \"BLOCK_NONE\",\n },\n]\n\n# https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/ground-gemini#supported_models_2\nGOOGLE_SEARCH_MODELS = {\n \"gemini-1.5-pro-latest\",\n \"gemini-1.5-flash-latest\",\n \"gemini-1.5-pro-001\",\n \"gemini-1.5-flash-001\",\n \"gemini-1.5-pro-002\",\n \"gemini-1.5-flash-002\",\n \"gemini-2.0-flash-exp\",\n \"gemini-2.0-flash\",\n \"gemini-2.5-pro-preview-03-25\",\n \"gemini-2.5-pro-exp-03-25\",\n \"gemini-2.5-flash-preview-04-17\",\n \"gemini-2.5-pro-preview-05-06\",\n \"gemini-2.5-flash-preview-05-20\",\n \"gemini-2.5-pro-preview-06-05\",\n \"gemini-2.5-pro\",\n \"gemini-2.5-flash\",\n \"gemini-2.5-flash-lite\",\n \"gemini-flash-latest\",\n \"gemini-flash-lite-latest\",\n \"gemini-2.5-flash-preview-09-2025\",\n \"gemini-2.5-flash-lite-preview-09-2025\",\n \"gemini-3-pro-preview\",\n \"gemini-3-flash-preview\",\n}\n\n# Older Google models used google_search_retrieval instead of google_search\nGOOGLE_SEARCH_MODELS_USING_SEARCH_RETRIEVAL = {\n \"gemini-1.5-pro-latest\",\n \"gemini-1.5-flash-latest\",\n \"gemini-1.5-pro-001\",\n \"gemini-1.5-flash-001\",\n \"gemini-1.5-pro-002\",\n \"gemini-1.5-flash-002\",\n \"gemini-2.0-flash-exp\",\n}\n\nTHINKING_BUDGET_MODELS = {\n \"gemini-2.0-flash-thinking-exp-01-21\",\n \"gemini-2.0-flash-thinking-exp-1219\",\n \"gemini-2.5-flash-preview-04-17\",\n \"gemini-2.5-pro-exp-03-25\",\n \"gemini-2.5-pro-preview-03-25\",\n \"gemini-2.5-pro-preview-05-06\",\n \"gemini-2.5-flash-preview-05-20\",\n \"gemini-2.5-pro-preview-06-05\",\n \"gemini-2.5-pro\",\n \"gemini-2.5-flash\",\n \"gemini-2.5-flash-lite\",\n \"gemini-flash-latest\",\n \"gemini-flash-lite-latest\",\n \"gemini-2.5-flash-preview-09-2025\",\n \"gemini-2.5-flash-lite-preview-09-2025\",\n}\n\n# Model-specific thinking levels - models not listed here don't support thinking_level\nMODEL_THINKING_LEVELS = {\n \"gemini-3-flash-preview\": [\"minimal\", \"low\", \"medium\", \"high\"],\n \"gemini-3-pro-preview\": [\"low\", \"high\"],\n}\n\nNO_VISION_MODELS = {\"gemma-3-1b-it\", \"gemma-3n-e4b-it\"}\n\nNO_MEDIA_RESOLUTION_MODELS = {\n \"gemma-3-1b-it\",\n \"gemma-3-4b-it\",\n \"gemma-3-12b-it\",\n \"gemma-3-27b-it\",\n \"gemma-3n-e4b-it\",\n}\n\n\nclass MediaResolution(str, Enum):\n \"\"\"Allowed media resolution values for Gemini models.\"\"\"\n\n LOW = \"low\"\n MEDIUM = \"medium\"\n HIGH = \"high\"\n UNSPECIFIED = \"unspecified\"\n\n\nATTACHMENT_TYPES = {\n # Text\n \"text/plain\",\n \"text/csv\",\n \"text/html; charset=utf-8\",\n # PDF\n \"application/pdf\",\n # Images\n \"image/png\",\n \"image/jpeg\",\n \"image/webp\",\n \"image/heic\",\n \"image/heif\",\n # Audio\n \"audio/wav\",\n \"audio/mp3\",\n \"audio/aiff\",\n \"audio/aac\",\n \"audio/ogg\",\n \"application/ogg\",\n \"audio/flac\",\n \"audio/mpeg\", # Treated as audio/mp3\n # Video\n \"video/mp4\",\n \"video/mpeg\",\n \"video/mov\",\n \"video/avi\",\n \"video/x-flv\",\n \"video/mpg\",\n \"video/webm\",\n \"video/wmv\",\n \"video/3gpp\",\n \"video/quicktime\",\n \"video/youtube\",\n}\n\n\n@llm.hookimpl\ndef register_models(register):\n # Register both sync and async versions of each model\n for model_id in (\n \"gemini-pro\",\n \"gemini-1.5-pro-latest\",\n \"gemini-1.5-flash-latest\",\n \"gemini-1.5-pro-001\",\n \"gemini-1.5-flash-001\",\n \"gemini-1.5-pro-002\",\n \"gemini-1.5-flash-002\",\n \"gemini-1.5-flash-8b-latest\",\n \"gemini-1.5-flash-8b-001\",\n \"gemini-exp-1114\",\n \"gemini-exp-1121\",\n \"gemini-exp-1206\",\n \"gemini-2.0-flash-exp\",\n \"learnlm-1.5-pro-experimental\",\n # Gemma 3 models:\n \"gemma-3-1b-it\",\n \"gemma-3-4b-it\",\n \"gemma-3-12b-it\", # 12th March 2025\n \"gemma-3-27b-it\",\n \"gemma-3n-e4b-it\", # 20th May 2025\n \"gemini-2.0-flash-thinking-exp-1219\",\n \"gemini-2.0-flash-thinking-exp-01-21\",\n # Released 5th Feb 2025:\n \"gemini-2.0-flash\",\n \"gemini-2.0-pro-exp-02-05\",\n # Released 25th Feb 2025:\n \"gemini-2.0-flash-lite\",\n # 25th March 2025:\n \"gemini-2.5-pro-exp-03-25\",\n # 4th April 2025 (paid):\n \"gemini-2.5-pro-preview-03-25\",\n # 17th April 2025:\n \"gemini-2.5-flash-preview-04-17\",\n # 6th May 2025:\n \"gemini-2.5-pro-preview-05-06\",\n # 20th May 2025:\n \"gemini-2.5-flash-preview-05-20\",\n # 5th June 2025:\n \"gemini-2.5-pro-preview-06-05\",\n \"gemini-2.5-flash\",\n \"gemini-2.5-pro\",\n # 22nd July 2025:\n \"gemini-2.5-flash-lite\",\n # 25th Spetember 2025:\n \"gemini-flash-latest\",\n \"gemini-flash-lite-latest\",\n \"gemini-2.5-flash-preview-09-2025\",\n \"gemini-2.5-flash-lite-preview-09-2025\",\n # 18th November 2025:\n \"gemini-3-pro-preview\",\n # 17th December 2025:\n \"gemini-3-flash-preview\",\n ):\n can_google_search = model_id in GOOGLE_SEARCH_MODELS\n can_thinking_budget = model_id in THINKING_BUDGET_MODELS\n thinking_levels = MODEL_THINKING_LEVELS.get(model_id)\n can_vision = model_id not in NO_VISION_MODELS\n can_schema = \"flash-thinking\" not in model_id and \"gemma-3\" not in model_id\n can_media_resolution = model_id not in NO_MEDIA_RESOLUTION_MODELS\n register(\n GeminiPro(\n model_id,\n can_vision=can_vision,\n can_google_search=can_google_search,\n can_thinking_budget=can_thinking_budget,\n thinking_levels=thinking_levels,\n can_schema=can_schema,\n can_media_resolution=can_media_resolution,\n ),\n AsyncGeminiPro(\n model_id,\n can_vision=can_vision,\n can_google_search=can_google_search,\n can_thinking_budget=can_thinking_budget,\n thinking_levels=thinking_levels,\n can_schema=can_schema,\n can_media_resolution=can_media_resolution,\n ),\n aliases=(model_id,),\n )\n\n\ndef resolve_type(attachment):\n mime_type = attachment.resolve_type()\n # https://github.com/simonw/llm/issues/587#issuecomment-2439785140\n if mime_type == \"audio/mpeg\":\n mime_type = \"audio/mp3\"\n if mime_type == \"application/ogg\":\n mime_type = \"audio/ogg\"\n # Check if this is a YouTube URL\n if attachment.url and is_youtube_url(attachment.url):\n return \"video/youtube\"\n return mime_type\n\n\ndef is_youtube_url(url):\n \"\"\"Check if a URL is a YouTube video URL\"\"\"\n if not url:\n return False\n youtube_patterns = [\n r\"^https?://(www\\.)?youtube\\.com/watch\\?v=\",\n r\"^https?://youtu\\.be/\",\n r\"^https?://(www\\.)?youtube\\.com/embed/\",\n ]\n return any(re.match(pattern, url) for pattern in youtube_patterns)\n\n\ndef cleanup_schema(schema, in_properties=False):\n \"Gemini supports only a subset of JSON schema\"\n keys_to_remove = (\"$schema\", \"additionalProperties\", \"title\")\n\n # First pass: resolve $ref references using $defs\n if isinstance(schema, dict) and \"$defs\" in schema:\n defs = schema.pop(\"$defs\")\n _resolve_refs(schema, defs)\n\n if isinstance(schema, dict):\n # Only remove keys if we're not inside a 'properties' block.\n if not in_properties:\n for key in keys_to_remove:\n schema.pop(key, None)\n for key, value in list(schema.items()):\n # If the key is 'properties', set the flag for its value.\n if key == \"properties\" and isinstance(value, dict):\n cleanup_schema(value, in_properties=True)\n else:\n cleanup_schema(value, in_properties=in_properties)\n elif isinstance(schema, list):\n for item in schema:\n cleanup_schema(item, in_properties=in_properties)\n return schema\n\n\ndef _resolve_refs(schema, defs, expansion_stack=None):\n \"\"\"Recursively resolve $ref references in schema using definitions.\n\n Args:\n schema: The schema to resolve references in\n defs: Dictionary of definitions from $defs\n expansion_stack: List tracking the order of definition expansions (for cycle detection)\n\n Raises:\n ValueError: If a recursive self-reference is detected\n \"\"\"\n if expansion_stack is None:\n expansion_stack = []\n\n if isinstance(schema, dict):\n if \"$ref\" in schema:\n # Extract the reference path (e.g., \"#/$defs/Dog\" -> \"Dog\")\n ref_path = schema.pop(\"$ref\")\n if ref_path.startswith(\"#/$defs/\"):\n def_name = ref_path.split(\"/\")[-1]\n if def_name in defs:\n # Check for recursive reference\n if def_name in expansion_stack:\n # Determine if this is direct or indirect recursion\n if expansion_stack[-1] == def_name:\n # Direct recursion: the definition references itself\n raise ValueError(\n f\"Recursive schema detected: '{def_name}' directly references itself. \"\n f\"The Gemini API does not support recursive Pydantic models. \"\n f\"Please use a non-recursive schema structure.\"\n )\n else:\n # Indirect recursion: the class being referenced is earlier in the stack\n # The last item in expansion_stack is the immediate class that references def_name\n intermediate = expansion_stack[-1]\n raise ValueError(\n f\"Recursive schema detected: '{def_name}' indirectly references itself through '{intermediate}'. \"\n f\"The Gemini API does not support recursive Pydantic models. \"\n f\"Please use a non-recursive schema structure.\"\n )\n\n # Add this definition to the expansion stack\n expansion_stack.append(def_name)\n # Replace the $ref with the actual definition\n resolved = copy.deepcopy(defs[def_name])\n schema.update(resolved)\n # Resolve any nested refs in the newly added definition\n _resolve_refs(schema, defs, expansion_stack)\n # Remove from stack after processing\n expansion_stack.pop()\n return\n\n # Recursively resolve refs in nested structures\n for value in schema.values():\n _resolve_refs(value, defs, expansion_stack)\n elif isinstance(schema, list):\n for item in schema:\n _resolve_refs(item, defs, expansion_stack)\n\n\nclass _SharedGemini:\n needs_key = \"gemini\"\n key_env_var = \"LLM_GEMINI_KEY\"\n can_stream = True\n supports_schema = True\n supports_tools = True\n\n attachment_types = set()\n\n class Options(llm.Options):\n code_execution: Optional[bool] = Field(\n description=\"Enables the model to generate and run Python code\",\n default=None,\n )\n temperature: Optional[float] = Field(\n description=(\n \"Controls the randomness of the output. Use higher values for \"\n \"more creative responses, and lower values for more \"\n \"deterministic responses.\"\n ),\n default=None,\n ge=0.0,\n le=2.0,\n )\n max_output_tokens: Optional[int] = Field(\n description=\"Sets the maximum number of tokens to include in a candidate.\",\n default=None,\n )\n top_p: Optional[float] = Field(\n description=(\n \"Changes how the model selects tokens for output. Tokens are \"\n \"selected from the most to least probable until the sum of \"\n \"their probabilities equals the topP value.\"\n ),\n default=None,\n ge=0.0,\n le=1.0,\n )\n top_k: Optional[int] = Field(\n description=(\n \"Changes how the model selects tokens for output. A topK of 1 \"\n \"means the selected token is the most probable among all the \"\n \"tokens in the model's vocabulary, while a topK of 3 means \"\n \"that the next token is selected from among the 3 most \"\n \"probable using the temperature.\"\n ),\n default=None,\n ge=1,\n )\n json_object: Optional[bool] = Field(\n description=\"Output a valid JSON object {...}\",\n default=None,\n )\n timeout: Optional[float] = Field(\n description=(\n \"The maximum time in seconds to wait for a response. \"\n \"If the model does not respond within this time, \"\n \"the request will be aborted.\"\n ),\n default=None,\n )\n url_context: Optional[bool] = Field(\n description=(\n \"Enable the URL context tool so the model can fetch content \"\n \"from URLs mentioned in the prompt\"\n ),\n default=None,\n )\n\n def __init__(\n self,\n gemini_model_id,\n can_vision=True,\n can_google_search=False,\n can_thinking_budget=False,\n thinking_levels=None,\n can_schema=False,\n can_media_resolution=True,\n ):\n self.model_id = \"gemini/{}\".format(gemini_model_id)\n self.gemini_model_id = gemini_model_id\n self.can_google_search = can_google_search\n self.supports_schema = can_schema\n self.can_thinking_budget = can_thinking_budget\n self.thinking_levels = thinking_levels\n self.can_media_resolution = can_media_resolution\n\n # Build Options class dynamically based on capabilities\n extra_fields = {}\n\n if can_google_search:\n extra_fields[\"google_search\"] = (\n Optional[bool],\n Field(\n description=\"Enables the model to use Google Search to improve the accuracy and recency of responses from the model\",\n default=None,\n ),\n )\n\n if can_thinking_budget:\n extra_fields[\"thinking_budget\"] = (\n Optional[int],\n Field(\n description=\"Indicates the thinking budget in tokens. Set to 0 to disable.\",\n default=None,\n ),\n )\n\n if thinking_levels:\n # Create a dynamic enum with only the supported levels\n ThinkingLevelEnum = Enum(\n \"ThinkingLevel\",\n {level.upper(): level for level in thinking_levels},\n type=str,\n )\n level_choices = \", \".join(f\"'{level}'\" for level in thinking_levels)\n extra_fields[\"thinking_level\"] = (\n Optional[ThinkingLevelEnum],\n Field(\n description=f\"Indicates the thinking level. Can be {level_choices}.\",\n default=None,\n ),\n )\n\n if can_media_resolution:\n extra_fields[\"media_resolution\"] = (\n Optional[MediaResolution],\n Field(\n description=(\n \"Media resolution for the input media (esp. YouTube) \"\n \"- default is low, other values are medium, high, or unspecified\"\n ),\n default=MediaResolution.LOW,\n ),\n )\n\n if extra_fields:\n self.Options = create_model(\n \"Options\",\n __base__=self.Options,\n **extra_fields,\n )\n\n if can_vision:\n self.attachment_types = ATTACHMENT_TYPES\n\n def build_messages(self, prompt, conversation):\n messages = []\n if conversation:\n for response in conversation.responses:\n parts = []\n for attachment in response.attachments:\n mime_type = resolve_type(attachment)\n parts.append(self._build_attachment_part(attachment, mime_type))\n if response.prompt.prompt:\n parts.append({\"text\": response.prompt.prompt})\n if response.prompt.tool_results:\n parts.extend(\n [\n {\n \"function_response\": {\n \"name\": tool_result.name,\n \"response\": {\n \"output\": tool_result.output,\n },\n }\n }\n for tool_result in response.prompt.tool_results\n ]\n )\n messages.append({\"role\": \"user\", \"parts\": parts})\n model_parts = []\n response_text = response.text_or_raise()\n model_parts.append({\"text\": response_text})\n tool_calls = response.tool_calls_or_raise()\n if tool_calls:\n model_parts.extend(\n [\n {\n \"function_call\": {\n \"name\": tool_call.name,\n \"args\": tool_call.arguments,\n }\n }\n for tool_call in tool_calls\n ]\n )\n messages.append({\"role\": \"model\", \"parts\": model_parts})\n\n parts = []\n if prompt.prompt:\n parts.append({\"text\": prompt.prompt})\n if prompt.tool_results:\n parts.extend(\n [\n {\n \"function_response\": {\n \"name\": tool_result.name,\n \"response\": {\n \"output\": tool_result.output,\n },\n }\n }\n for tool_result in prompt.tool_results\n ]\n )\n for attachment in prompt.attachments:\n mime_type = resolve_type(attachment)\n parts.append(self._build_attachment_part(attachment, mime_type))\n\n messages.append({\"role\": \"user\", \"parts\": parts})\n return messages\n\n def _build_attachment_part(self, attachment, mime_type):\n \"\"\"Build the appropriate part for an attachment based on its type\"\"\"\n if mime_type == \"video/youtube\":\n return {\"fileData\": {\"mimeType\": mime_type, \"fileUri\": attachment.url}}\n else:\n return {\n \"inlineData\": {\n \"data\": attachment.base64_content(),\n \"mimeType\": mime_type,\n }\n }\n\n def build_request_body(self, prompt, conversation):\n body = {\n \"contents\": self.build_messages(prompt, conversation),\n \"safetySettings\": SAFETY_SETTINGS,\n }\n if prompt.system:\n body[\"systemInstruction\"] = {\"parts\": [{\"text\": prompt.system}]}\n\n tools = []\n if prompt.options and prompt.options.code_execution:\n tools.append({\"codeExecution\": {}})\n if prompt.options and self.can_google_search and prompt.options.google_search:\n tool_name = (\n \"google_search_retrieval\"\n if self.model_id in GOOGLE_SEARCH_MODELS_USING_SEARCH_RETRIEVAL\n else \"google_search\"\n )\n tools.append({tool_name: {}})\n if prompt.options and prompt.options.url_context:\n tools.append({\"url_context\": {}})\n if prompt.tools:\n tools.append(\n {\n \"functionDeclarations\": [\n {\n \"name\": tool.name,\n \"description\": tool.description,\n \"parameters\": cleanup_schema(\n copy.deepcopy(tool.input_schema)\n ),\n }\n for tool in prompt.tools\n ]\n }\n )\n if tools:\n body[\"tools\"] = tools\n\n generation_config = {}\n\n if prompt.schema:\n generation_config.update(\n {\n \"response_mime_type\": \"application/json\",\n \"response_schema\": cleanup_schema(copy.deepcopy(prompt.schema)),\n }\n )\n\n if self.can_thinking_budget and prompt.options.thinking_budget is not None:\n generation_config[\"thinking_config\"] = {\n \"thinking_budget\": prompt.options.thinking_budget\n }\n\n if (\n self.thinking_levels\n and getattr(prompt.options, \"thinking_level\", None) is not None\n ):\n # Get the string value from the enum\n thinking_level = prompt.options.thinking_level\n if hasattr(thinking_level, \"value\"):\n thinking_level = thinking_level.value\n generation_config[\"thinkingConfig\"] = {\"thinkingLevel\": thinking_level}\n\n config_map = {\n \"temperature\": \"temperature\",\n \"max_output_tokens\": \"maxOutputTokens\",\n \"top_p\": \"topP\",\n \"top_k\": \"topK\",\n }\n if prompt.options and prompt.options.json_object:\n generation_config[\"response_mime_type\"] = \"application/json\"\n\n if any(\n getattr(prompt.options, key, None) is not None for key in config_map.keys()\n ):\n for key, other_key in config_map.items():\n config_value = getattr(prompt.options, key, None)\n if config_value is not None:\n generation_config[other_key] = config_value\n\n has_youtube = any(\n attachment.url and is_youtube_url(attachment.url)\n for attachment in prompt.attachments\n ) or (\n conversation\n and any(\n attachment.url and is_youtube_url(attachment.url)\n for response in conversation.responses\n for attachment in response.attachments\n )\n )\n\n # See https://ai.google.dev/api/generate-content#MediaResolution for mediaResolution token counts\n if self.can_media_resolution:\n media_resolution = getattr(prompt.options, \"media_resolution\", None)\n if media_resolution:\n generation_config[\"mediaResolution\"] = (\n f\"MEDIA_RESOLUTION_{media_resolution.value.upper()}\"\n )\n elif has_youtube: # support longer videos even if no option set\n generation_config[\"mediaResolution\"] = \"MEDIA_RESOLUTION_LOW\"\n\n if generation_config:\n body[\"generationConfig\"] = generation_config\n\n return body\n\n def process_part(self, part, response):\n if \"functionCall\" in part:\n response.add_tool_call(\n llm.ToolCall(\n name=part[\"functionCall\"][\"name\"],\n arguments=part[\"functionCall\"][\"args\"],\n )\n )\n if \"text\" in part:\n return part[\"text\"]\n elif \"executableCode\" in part:\n return f'```{part[\"executableCode\"][\"language\"].lower()}\\n{part[\"executableCode\"][\"code\"].strip()}\\n```\\n'\n elif \"codeExecutionResult\" in part:\n return f'```\\n{part[\"codeExecutionResult\"][\"output\"].strip()}\\n```\\n'\n return \"\"\n\n def process_candidates(self, candidates, response):\n # We only use the first candidate\n for part in candidates[0][\"content\"][\"parts\"]:\n yield self.process_part(part, response)\n\n def set_usage(self, response):\n try:\n # Don't record the \"content\" key from that last candidate\n for candidate in response.response_json[\"candidates\"]:\n candidate.pop(\"content\", None)\n usage = response.response_json.pop(\"usageMetadata\")\n input_tokens = usage.pop(\"promptTokenCount\", None)\n # See https://github.com/simonw/llm-gemini/issues/75#issuecomment-2861827509\n candidates_token_count = usage.get(\"candidatesTokenCount\") or 0\n thoughts_token_count = usage.get(\"thoughtsTokenCount\") or 0\n output_tokens = candidates_token_count + thoughts_token_count\n tool_token_count = usage.get(\"toolUsePromptTokenCount\") or 0\n if tool_token_count:\n if input_tokens is None:\n input_tokens = tool_token_count\n else:\n input_tokens += tool_token_count\n usage.pop(\"totalTokenCount\", None)\n if input_tokens is not None:\n response.set_usage(\n input=input_tokens, output=output_tokens, details=usage or None\n )\n except (IndexError, KeyError):\n pass\n\n\nclass GeminiPro(_SharedGemini, llm.KeyModel):\n def execute(self, prompt, stream, response, conversation, key):\n url = f\"https://generativelanguage.googleapis.com/v1beta/models/{self.gemini_model_id}:streamGenerateContent\"\n gathered = []\n body = self.build_request_body(prompt, conversation)\n\n with httpx.stream(\n \"POST\",\n url,\n timeout=prompt.options.timeout,\n headers={\"x-goog-api-key\": self.get_key(key)},\n json=body,\n ) as http_response:\n events = ijson.sendable_list()\n coro = ijson.items_coro(events, \"item\")\n for chunk in http_response.iter_bytes():\n coro.send(chunk)\n if events:\n for event in events:\n if isinstance(event, dict) and \"error\" in event:\n raise llm.ModelError(event[\"error\"][\"message\"])\n try:\n yield from self.process_candidates(\n event[\"candidates\"], response\n )\n except KeyError:\n yield \"\"\n gathered.append(event)\n events.clear()\n response.response_json = gathered[-1]\n resolved_model = gathered[-1][\"modelVersion\"]\n response.set_resolved_model(resolved_model)\n self.set_usage(response)\n\n\nclass AsyncGeminiPro(_SharedGemini, llm.AsyncKeyModel):\n async def execute(self, prompt, stream, response, conversation, key):\n url = f\"https://generativelanguage.googleapis.com/v1beta/models/{self.gemini_model_id}:streamGenerateContent\"\n gathered = []\n body = self.build_request_body(prompt, conversation)\n\n async with httpx.AsyncClient() as client:\n async with client.stream(\n \"POST\",\n url,\n timeout=prompt.options.timeout,\n headers={\"x-goog-api-key\": self.get_key(key)},\n json=body,\n ) as http_response:\n events = ijson.sendable_list()\n coro = ijson.items_coro(events, \"item\")\n async for chunk in http_response.aiter_bytes():\n coro.send(chunk)\n if events:\n for event in events:\n if isinstance(event, dict) and \"error\" in event:\n raise llm.ModelError(event[\"error\"][\"message\"])\n try:\n for chunk in self.process_candidates(\n event[\"candidates\"], response\n ):\n yield chunk\n except KeyError:\n yield \"\"\n gathered.append(event)\n events.clear()\n response.response_json = gathered[-1]\n self.set_usage(response)\n\n\n@llm.hookimpl\ndef register_embedding_models(register):\n register(GeminiEmbeddingModel(\"text-embedding-004\", \"text-embedding-004\"))\n # gemini-embedding-exp-03-07 in different truncation sizes\n register(\n GeminiEmbeddingModel(\n \"gemini-embedding-exp-03-07\", \"gemini-embedding-exp-03-07\"\n ),\n )\n for i in (128, 256, 512, 1024, 2048):\n register(\n GeminiEmbeddingModel(\n f\"gemini-embedding-exp-03-07-{i}\", f\"gemini-embedding-exp-03-07\", i\n ),\n )\n\n\nclass GeminiEmbeddingModel(llm.EmbeddingModel):\n needs_key = \"gemini\"\n key_env_var = \"LLM_GEMINI_KEY\"\n batch_size = 20\n\n def __init__(self, model_id, gemini_model_id, truncate=None):\n self.model_id = model_id\n self.gemini_model_id = gemini_model_id\n self.truncate = truncate\n\n def embed_batch(self, items):\n headers = {\n \"Content-Type\": \"application/json\",\n \"x-goog-api-key\": self.get_key(),\n }\n data = {\n \"requests\": [\n {\n \"model\": \"models/\" + self.gemini_model_id,\n \"content\": {\"parts\": [{\"text\": item}]},\n }\n for item in items\n ]\n }\n\n with httpx.Client() as client:\n response = client.post(\n f\"https://generativelanguage.googleapis.com/v1beta/models/{self.gemini_model_id}:batchEmbedContents\",\n headers=headers,\n json=data,\n timeout=None,\n )\n\n response.raise_for_status()\n values = [item[\"values\"] for item in response.json()[\"embeddings\"]]\n if self.truncate:\n values = [value[: self.truncate] for value in values]\n return values\n\n\n@llm.hookimpl\ndef register_commands(cli):\n @cli.group()\n def gemini():\n \"Commands relating to the llm-gemini plugin\"\n\n @gemini.command()\n @click.option(\"--key\", help=\"API key to use\")\n @click.option(\n \"methods\",\n \"--method\",\n multiple=True,\n help=\"Filter by supported generation methods\",\n )\n def models(key, methods):\n \"\"\"\n List of Gemini models pulled from their API\n\n Use --method to filter by supported generation methods for example:\n\n llm gemini models --method generateContent --method embedContent\n \"\"\"\n key = llm.get_key(key, \"gemini\", \"LLM_GEMINI_KEY\")\n if not key:\n raise click.ClickException(\n \"You must set the LLM_GEMINI_KEY environment variable or use --key\"\n )\n url = f\"https://generativelanguage.googleapis.com/v1beta/models\"\n response = httpx.get(url, headers={\"x-goog-api-key\": key})\n response.raise_for_status()\n models = response.json()[\"models\"]\n if methods:\n models = [\n model\n for model in models\n if any(\n method in model[\"supportedGenerationMethods\"] for method in methods\n )\n ]\n click.echo(json.dumps(models, indent=2))\n\n @gemini.command()\n @click.option(\"--key\", help=\"API key to use\")\n def files(key):\n \"List of files uploaded to the Gemini API\"\n key = llm.get_key(key, \"gemini\", \"LLM_GEMINI_KEY\")\n response = httpx.get(\n f\"https://generativelanguage.googleapis.com/v1beta/files?key={key}\",\n )\n response.raise_for_status()\n if \"files\" in response.json():\n click.echo(json.dumps(response.json()[\"files\"], indent=2))\n else:\n click.echo(\"No files uploaded to the Gemini API.\", err=True)\n","numLines":907,"startLine":1,"totalLines":907}}}
{"parentUuid":"78d367c5-5630-45bf-a86e-32ca866d8d80","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01JYcV1o8RpZWQHbfjKCPSF4","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now I understand the issue. Looking at the code:\n\n1. Line 479: `media_resolution` field is defined with `default=MediaResolution.LOW`\n2. Lines 665-672: The code that uses it checks if `media_resolution` is truthy, and if it is, uses it. If not and there's YouTube, it defaults to `MEDIA_RESOLUTION_LOW`.\n\nThe issue is that when the default is `MediaResolution.LOW`, every request gets logged as deliberately using that resolution, even when the user didn't specify one.\n\nThe solution is:\n1. Change the default to `None` (line 479)\n2. In the code that uses it (lines 665-672), when `media_resolution` is `None`, replace it with `MediaResolution.LOW` before building the request\n\nLet me plan this:\n1. Change line 479 from `default=MediaResolution.LOW` to `default=None`\n2. Modify lines 665-672 to check if `media_resolution` is `None` and set it to `MediaResolution.LOW` if so\n\nLet me create a todo list and then make these changes.","signature":"Er8ICkYIChgCKkCL+CXgliS8VjuW/W7Vt5UB0MnZTaZ3sMg+0OFsf8prGr9S6Vby75eyr9oiaiYhPCv0JJEkfM00VRNGntNHVESVEgxQtu8HQHg6SR7HhiQaDL1oTeFjJ2Zzcwj+TiIwkx+D9vKztdfQW7Tr7RaF85+4kOwv17SNqJjQPSj1yIGOCJa01Z7cWLGPcfs4AWqWKqYHsyM95Q2+OhE3H2u7tBByqnQTcI2LPgLkRvdl+B3yDm3jeTx5gFmrrR2dQ7OBMchN7CZIoD9Q8T8wKdDvKibTAuwnSzXpTon/eNVbf3FfMybgaZLE5MG2BPo5hLk8bY821uUWNhQpXZlodXRkDp01NweY7CSGrD3cHEgTrUvt5GFMvlfbo0ar5WQA/2m4B/msmCl013Z9btl8Qy0p90BCE/ST5iT6O6egmE6MybcnodDtt0J7h30zAmu0Sdlh09HgDlPKRslrPcJbM0TX5PiK6CzQlChycNzAEepDkqSnOsw+SDHuptkr0ONU2qovXhc0o1hEhBVLQuTTpEtbrCDzuxiWlFLqeo4IAtunpLHnFwlORgkzRz/acVfJmD+FoK2OHZme/f1ljROyzAmwswMLlFkOL7lVp6TVEvpZZgm0rg7fIbqC3Bb/D5NwvQAS0hPHRqDSF8pBN+Kvj39gf/Rg/DeIi6YQW9OJQsLg3JyoAoPbQBwFOmPYZJvOsIW8QlzN1ZDIMR+7Mh0MVNIRp7WOtKMyT0QD5PyZ05VIn8tXieNkPw6+IQWUypMzfpU2ts0+jh6KCpRF+/7H1FxYZDmMynRB6vtWa1NXikJRrXLnf9hHsuGJXMkr4vYyLPvtH6Xv9HYPjCBwx+lQDQ2EC5o+Yt2+4E3+1prT4gwb0L3OwfebeAjE/bxqweAijxxEPHn/eQuYaKEEXbK8IyPHh+NQYZKPS+uI7eyd8ZLX7jXQpFHwhVixCQ16LWf0lXgkwA71+oAwgKzAGq18Jt+tuuhkDSx9REa8oeLIB/x9fAnxxkrS2A4A68mJh9jj5KGSTOM7KnYRCrMgmDTBVwab6k7MMTroVpAO9YLag5oPlSKmpGibopKsq/6I80M8YHT310SPu6Y09gOxB1JS0p5S05ZvobnnDf2c/wwsMJMsTpJiqpPCs9dONwTqdUzDkl6ns+wTdNJ3JlBYleUf45k6r3UCbtbyUAVk5IeBgA0B4sgqxACh+B6Fd80mpOzXf7ahkmkMy5sRM1XrrjKKM+tKWdBzNr2LkeHooohkdYnn2VnHDlRttrnLu+Jzr7EhawU40XlATP50rO5jLses8hJSgANk5fW9svU4XYOLtR4RF4w9oEzT1snWWPBynn93tLv4cTg3AYp/FUZhumHy7Te+eo2I0TwZAov6Nf3GgY7IfT+oGQOGZXV1Th00c8PzPlhqrk7eE6pUAwv7Jf57eg5iibJFWSXqB26hRRgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":13,"cache_creation_input_tokens":12058,"cache_read_input_tokens":18930,"cache_creation":{"ephemeral_5m_input_tokens":12058,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWEZBKCKyRjVKfNaNiYaU","type":"assistant","uuid":"fe79a6e6-116f-4433-a196-3dc5d2802826","timestamp":"2025-12-18T17:35:01.976Z"}
{"parentUuid":"fe79a6e6-116f-4433-a196-3dc5d2802826","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01JYcV1o8RpZWQHbfjKCPSF4","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PyoRok7Z8QtCmVbb4rsKaY","name":"TodoWrite","input":{"todos":[{"content":"Change media_resolution default from MediaResolution.LOW to None","status":"in_progress","activeForm":"Changing media_resolution default"},{"content":"Update request building code to swap None with MediaResolution.LOW","status":"pending","activeForm":"Updating request building code"}]}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":13,"cache_creation_input_tokens":12058,"cache_read_input_tokens":18930,"cache_creation":{"ephemeral_5m_input_tokens":12058,"ephemeral_1h_input_tokens":0},"output_tokens":421,"service_tier":"standard"}},"requestId":"req_011CWEZBKCKyRjVKfNaNiYaU","type":"assistant","uuid":"d4121f08-987a-4556-9bc4-9b7e62f82473","timestamp":"2025-12-18T17:35:02.769Z"}
{"parentUuid":"d4121f08-987a-4556-9bc4-9b7e62f82473","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PyoRok7Z8QtCmVbb4rsKaY","type":"tool_result","content":"Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable"}]},"uuid":"bd8d378f-3a29-44b0-a297-23dd3a9c7b71","timestamp":"2025-12-18T17:35:02.793Z","toolUseResult":{"oldTodos":[],"newTodos":[{"content":"Change media_resolution default from MediaResolution.LOW to None","status":"in_progress","activeForm":"Changing media_resolution default"},{"content":"Update request building code to swap None with MediaResolution.LOW","status":"pending","activeForm":"Updating request building code"}]}}
{"parentUuid":"bd8d378f-3a29-44b0-a297-23dd3a9c7b71","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01C51mVoLjRRFLVYSfyzCmuR","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now let me make the first change. I need to update line 479 to change the default from `MediaResolution.LOW` to `None`.","signature":"EqACCkYIChgCKkAPKu5aE+Vg/CsG5Qp6P5fmKzhpP0rPt+GhTkH6/Eh7ldRKc54rmcwxnOt3NUMdWnIziyanN9p6bhQ+oeUX4iEfEgxd//jMbO7q95CQI3waDGTW+BEUr47ot9wz6yIw7miAP77umLNkrDwxS0zIEw9wD33hMaWEl3b/r3QX1UET3TkXbp/MzXLxIZGZR50XKocBeZ6B+9dX3Z4VQRO53oRvnBzYOdZ9r+o9q1Wd5wOVexHd1DSlNu/sn5dGIoQf8xmIyoJ90gDBnJ80wENQn5YFEv9RXzI+zU/QcF+Ft3Fa3svMwSPaBmoAbgfppS5bqd+Ttednc3ifJSG2pLm/coxEvujj5hjdVlivOpVtApj4wBB8R8LldEn1GAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":13,"cache_creation_input_tokens":441,"cache_read_input_tokens":30988,"cache_creation":{"ephemeral_5m_input_tokens":441,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard"}},"requestId":"req_011CWEZBfCbi4Zwgak5oLoqz","type":"assistant","uuid":"e8ac2ddc-4c78-40d7-891a-d8e69688ea26","timestamp":"2025-12-18T17:35:05.692Z"}
{"parentUuid":"e8ac2ddc-4c78-40d7-891a-d8e69688ea26","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01C51mVoLjRRFLVYSfyzCmuR","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_014gzRdNUa1ZaHcVqaBDoreJ","name":"Edit","input":{"file_path":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini/llm_gemini.py","old_string":" if can_media_resolution:\n extra_fields[\"media_resolution\"] = (\n Optional[MediaResolution],\n Field(\n description=(\n \"Media resolution for the input media (esp. YouTube) \"\n \"- default is low, other values are medium, high, or unspecified\"\n ),\n default=MediaResolution.LOW,\n ),\n )","new_string":" if can_media_resolution:\n extra_fields[\"media_resolution\"] = (\n Optional[MediaResolution],\n Field(\n description=(\n \"Media resolution for the input media (esp. YouTube) \"\n \"- default is low, other values are medium, high, or unspecified\"\n ),\n default=None,\n ),\n )"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":13,"cache_creation_input_tokens":441,"cache_read_input_tokens":30988,"cache_creation":{"ephemeral_5m_input_tokens":441,"ephemeral_1h_input_tokens":0},"output_tokens":340,"service_tier":"standard"}},"requestId":"req_011CWEZBfCbi4Zwgak5oLoqz","type":"assistant","uuid":"585e089e-39ae-4856-831d-78bfdfc69cae","timestamp":"2025-12-18T17:35:08.749Z"}
{"type":"file-history-snapshot","messageId":"585e089e-39ae-4856-831d-78bfdfc69cae","snapshot":{"messageId":"2d3ea5b0-e396-47eb-9201-ad68121b1145","trackedFileBackups":{"llm_gemini.py":{"backupFileName":"c970ac0709d7b8fa@v1","version":1,"backupTime":"2025-12-18T17:35:08.807Z"}},"timestamp":"2025-12-18T17:34:53.085Z"},"isSnapshotUpdate":true}
{"parentUuid":"585e089e-39ae-4856-831d-78bfdfc69cae","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_014gzRdNUa1ZaHcVqaBDoreJ","type":"tool_result","content":"The file /Users/simon/Dropbox/dev/ecosystem/llm-gemini/llm_gemini.py has been updated. Here's the result of running `cat -n` on a snippet of the edited file:\n 467→ default=None,\n 468→ ),\n 469→ )\n 470→\n 471→ if can_media_resolution:\n 472→ extra_fields[\"media_resolution\"] = (\n 473→ Optional[MediaResolution],\n 474→ Field(\n 475→ description=(\n 476→ \"Media resolution for the input media (esp. YouTube) \"\n 477→ \"- default is low, other values are medium, high, or unspecified\"\n 478→ ),\n 479→ default=None,\n 480→ ),\n 481→ )\n 482→\n 483→ if extra_fields:\n 484→ self.Options = create_model(\n 485→ \"Options\","}]},"uuid":"cbd53a4f-d4c3-4d30-953f-0ee5bf5e2314","timestamp":"2025-12-18T17:35:08.800Z","toolUseResult":{"filePath":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini/llm_gemini.py","oldString":" if can_media_resolution:\n extra_fields[\"media_resolution\"] = (\n Optional[MediaResolution],\n Field(\n description=(\n \"Media resolution for the input media (esp. YouTube) \"\n \"- default is low, other values are medium, high, or unspecified\"\n ),\n default=MediaResolution.LOW,\n ),\n )","newString":" if can_media_resolution:\n extra_fields[\"media_resolution\"] = (\n Optional[MediaResolution],\n Field(\n description=(\n \"Media resolution for the input media (esp. YouTube) \"\n \"- default is low, other values are medium, high, or unspecified\"\n ),\n default=None,\n ),\n )","originalFile":"import click\nimport copy\nfrom enum import Enum\nimport httpx\nimport ijson\nimport json\nimport llm\nimport re\nfrom pydantic import Field, create_model\nfrom typing import Optional\n\nSAFETY_SETTINGS = [\n {\n \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n \"threshold\": \"BLOCK_NONE\",\n },\n {\n \"category\": \"HARM_CATEGORY_SEXUALLY_EXPLICIT\",\n \"threshold\": \"BLOCK_NONE\",\n },\n {\n \"category\": \"HARM_CATEGORY_HATE_SPEECH\",\n \"threshold\": \"BLOCK_NONE\",\n },\n {\n \"category\": \"HARM_CATEGORY_HARASSMENT\",\n \"threshold\": \"BLOCK_NONE\",\n },\n]\n\n# https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/ground-gemini#supported_models_2\nGOOGLE_SEARCH_MODELS = {\n \"gemini-1.5-pro-latest\",\n \"gemini-1.5-flash-latest\",\n \"gemini-1.5-pro-001\",\n \"gemini-1.5-flash-001\",\n \"gemini-1.5-pro-002\",\n \"gemini-1.5-flash-002\",\n \"gemini-2.0-flash-exp\",\n \"gemini-2.0-flash\",\n \"gemini-2.5-pro-preview-03-25\",\n \"gemini-2.5-pro-exp-03-25\",\n \"gemini-2.5-flash-preview-04-17\",\n \"gemini-2.5-pro-preview-05-06\",\n \"gemini-2.5-flash-preview-05-20\",\n \"gemini-2.5-pro-preview-06-05\",\n \"gemini-2.5-pro\",\n \"gemini-2.5-flash\",\n \"gemini-2.5-flash-lite\",\n \"gemini-flash-latest\",\n \"gemini-flash-lite-latest\",\n \"gemini-2.5-flash-preview-09-2025\",\n \"gemini-2.5-flash-lite-preview-09-2025\",\n \"gemini-3-pro-preview\",\n \"gemini-3-flash-preview\",\n}\n\n# Older Google models used google_search_retrieval instead of google_search\nGOOGLE_SEARCH_MODELS_USING_SEARCH_RETRIEVAL = {\n \"gemini-1.5-pro-latest\",\n \"gemini-1.5-flash-latest\",\n \"gemini-1.5-pro-001\",\n \"gemini-1.5-flash-001\",\n \"gemini-1.5-pro-002\",\n \"gemini-1.5-flash-002\",\n \"gemini-2.0-flash-exp\",\n}\n\nTHINKING_BUDGET_MODELS = {\n \"gemini-2.0-flash-thinking-exp-01-21\",\n \"gemini-2.0-flash-thinking-exp-1219\",\n \"gemini-2.5-flash-preview-04-17\",\n \"gemini-2.5-pro-exp-03-25\",\n \"gemini-2.5-pro-preview-03-25\",\n \"gemini-2.5-pro-preview-05-06\",\n \"gemini-2.5-flash-preview-05-20\",\n \"gemini-2.5-pro-preview-06-05\",\n \"gemini-2.5-pro\",\n \"gemini-2.5-flash\",\n \"gemini-2.5-flash-lite\",\n \"gemini-flash-latest\",\n \"gemini-flash-lite-latest\",\n \"gemini-2.5-flash-preview-09-2025\",\n \"gemini-2.5-flash-lite-preview-09-2025\",\n}\n\n# Model-specific thinking levels - models not listed here don't support thinking_level\nMODEL_THINKING_LEVELS = {\n \"gemini-3-flash-preview\": [\"minimal\", \"low\", \"medium\", \"high\"],\n \"gemini-3-pro-preview\": [\"low\", \"high\"],\n}\n\nNO_VISION_MODELS = {\"gemma-3-1b-it\", \"gemma-3n-e4b-it\"}\n\nNO_MEDIA_RESOLUTION_MODELS = {\n \"gemma-3-1b-it\",\n \"gemma-3-4b-it\",\n \"gemma-3-12b-it\",\n \"gemma-3-27b-it\",\n \"gemma-3n-e4b-it\",\n}\n\n\nclass MediaResolution(str, Enum):\n \"\"\"Allowed media resolution values for Gemini models.\"\"\"\n\n LOW = \"low\"\n MEDIUM = \"medium\"\n HIGH = \"high\"\n UNSPECIFIED = \"unspecified\"\n\n\nATTACHMENT_TYPES = {\n # Text\n \"text/plain\",\n \"text/csv\",\n \"text/html; charset=utf-8\",\n # PDF\n \"application/pdf\",\n # Images\n \"image/png\",\n \"image/jpeg\",\n \"image/webp\",\n \"image/heic\",\n \"image/heif\",\n # Audio\n \"audio/wav\",\n \"audio/mp3\",\n \"audio/aiff\",\n \"audio/aac\",\n \"audio/ogg\",\n \"application/ogg\",\n \"audio/flac\",\n \"audio/mpeg\", # Treated as audio/mp3\n # Video\n \"video/mp4\",\n \"video/mpeg\",\n \"video/mov\",\n \"video/avi\",\n \"video/x-flv\",\n \"video/mpg\",\n \"video/webm\",\n \"video/wmv\",\n \"video/3gpp\",\n \"video/quicktime\",\n \"video/youtube\",\n}\n\n\n@llm.hookimpl\ndef register_models(register):\n # Register both sync and async versions of each model\n for model_id in (\n \"gemini-pro\",\n \"gemini-1.5-pro-latest\",\n \"gemini-1.5-flash-latest\",\n \"gemini-1.5-pro-001\",\n \"gemini-1.5-flash-001\",\n \"gemini-1.5-pro-002\",\n \"gemini-1.5-flash-002\",\n \"gemini-1.5-flash-8b-latest\",\n \"gemini-1.5-flash-8b-001\",\n \"gemini-exp-1114\",\n \"gemini-exp-1121\",\n \"gemini-exp-1206\",\n \"gemini-2.0-flash-exp\",\n \"learnlm-1.5-pro-experimental\",\n # Gemma 3 models:\n \"gemma-3-1b-it\",\n \"gemma-3-4b-it\",\n \"gemma-3-12b-it\", # 12th March 2025\n \"gemma-3-27b-it\",\n \"gemma-3n-e4b-it\", # 20th May 2025\n \"gemini-2.0-flash-thinking-exp-1219\",\n \"gemini-2.0-flash-thinking-exp-01-21\",\n # Released 5th Feb 2025:\n \"gemini-2.0-flash\",\n \"gemini-2.0-pro-exp-02-05\",\n # Released 25th Feb 2025:\n \"gemini-2.0-flash-lite\",\n # 25th March 2025:\n \"gemini-2.5-pro-exp-03-25\",\n # 4th April 2025 (paid):\n \"gemini-2.5-pro-preview-03-25\",\n # 17th April 2025:\n \"gemini-2.5-flash-preview-04-17\",\n # 6th May 2025:\n \"gemini-2.5-pro-preview-05-06\",\n # 20th May 2025:\n \"gemini-2.5-flash-preview-05-20\",\n # 5th June 2025:\n \"gemini-2.5-pro-preview-06-05\",\n \"gemini-2.5-flash\",\n \"gemini-2.5-pro\",\n # 22nd July 2025:\n \"gemini-2.5-flash-lite\",\n # 25th Spetember 2025:\n \"gemini-flash-latest\",\n \"gemini-flash-lite-latest\",\n \"gemini-2.5-flash-preview-09-2025\",\n \"gemini-2.5-flash-lite-preview-09-2025\",\n # 18th November 2025:\n \"gemini-3-pro-preview\",\n # 17th December 2025:\n \"gemini-3-flash-preview\",\n ):\n can_google_search = model_id in GOOGLE_SEARCH_MODELS\n can_thinking_budget = model_id in THINKING_BUDGET_MODELS\n thinking_levels = MODEL_THINKING_LEVELS.get(model_id)\n can_vision = model_id not in NO_VISION_MODELS\n can_schema = \"flash-thinking\" not in model_id and \"gemma-3\" not in model_id\n can_media_resolution = model_id not in NO_MEDIA_RESOLUTION_MODELS\n register(\n GeminiPro(\n model_id,\n can_vision=can_vision,\n can_google_search=can_google_search,\n can_thinking_budget=can_thinking_budget,\n thinking_levels=thinking_levels,\n can_schema=can_schema,\n can_media_resolution=can_media_resolution,\n ),\n AsyncGeminiPro(\n model_id,\n can_vision=can_vision,\n can_google_search=can_google_search,\n can_thinking_budget=can_thinking_budget,\n thinking_levels=thinking_levels,\n can_schema=can_schema,\n can_media_resolution=can_media_resolution,\n ),\n aliases=(model_id,),\n )\n\n\ndef resolve_type(attachment):\n mime_type = attachment.resolve_type()\n # https://github.com/simonw/llm/issues/587#issuecomment-2439785140\n if mime_type == \"audio/mpeg\":\n mime_type = \"audio/mp3\"\n if mime_type == \"application/ogg\":\n mime_type = \"audio/ogg\"\n # Check if this is a YouTube URL\n if attachment.url and is_youtube_url(attachment.url):\n return \"video/youtube\"\n return mime_type\n\n\ndef is_youtube_url(url):\n \"\"\"Check if a URL is a YouTube video URL\"\"\"\n if not url:\n return False\n youtube_patterns = [\n r\"^https?://(www\\.)?youtube\\.com/watch\\?v=\",\n r\"^https?://youtu\\.be/\",\n r\"^https?://(www\\.)?youtube\\.com/embed/\",\n ]\n return any(re.match(pattern, url) for pattern in youtube_patterns)\n\n\ndef cleanup_schema(schema, in_properties=False):\n \"Gemini supports only a subset of JSON schema\"\n keys_to_remove = (\"$schema\", \"additionalProperties\", \"title\")\n\n # First pass: resolve $ref references using $defs\n if isinstance(schema, dict) and \"$defs\" in schema:\n defs = schema.pop(\"$defs\")\n _resolve_refs(schema, defs)\n\n if isinstance(schema, dict):\n # Only remove keys if we're not inside a 'properties' block.\n if not in_properties:\n for key in keys_to_remove:\n schema.pop(key, None)\n for key, value in list(schema.items()):\n # If the key is 'properties', set the flag for its value.\n if key == \"properties\" and isinstance(value, dict):\n cleanup_schema(value, in_properties=True)\n else:\n cleanup_schema(value, in_properties=in_properties)\n elif isinstance(schema, list):\n for item in schema:\n cleanup_schema(item, in_properties=in_properties)\n return schema\n\n\ndef _resolve_refs(schema, defs, expansion_stack=None):\n \"\"\"Recursively resolve $ref references in schema using definitions.\n\n Args:\n schema: The schema to resolve references in\n defs: Dictionary of definitions from $defs\n expansion_stack: List tracking the order of definition expansions (for cycle detection)\n\n Raises:\n ValueError: If a recursive self-reference is detected\n \"\"\"\n if expansion_stack is None:\n expansion_stack = []\n\n if isinstance(schema, dict):\n if \"$ref\" in schema:\n # Extract the reference path (e.g., \"#/$defs/Dog\" -> \"Dog\")\n ref_path = schema.pop(\"$ref\")\n if ref_path.startswith(\"#/$defs/\"):\n def_name = ref_path.split(\"/\")[-1]\n if def_name in defs:\n # Check for recursive reference\n if def_name in expansion_stack:\n # Determine if this is direct or indirect recursion\n if expansion_stack[-1] == def_name:\n # Direct recursion: the definition references itself\n raise ValueError(\n f\"Recursive schema detected: '{def_name}' directly references itself. \"\n f\"The Gemini API does not support recursive Pydantic models. \"\n f\"Please use a non-recursive schema structure.\"\n )\n else:\n # Indirect recursion: the class being referenced is earlier in the stack\n # The last item in expansion_stack is the immediate class that references def_name\n intermediate = expansion_stack[-1]\n raise ValueError(\n f\"Recursive schema detected: '{def_name}' indirectly references itself through '{intermediate}'. \"\n f\"The Gemini API does not support recursive Pydantic models. \"\n f\"Please use a non-recursive schema structure.\"\n )\n\n # Add this definition to the expansion stack\n expansion_stack.append(def_name)\n # Replace the $ref with the actual definition\n resolved = copy.deepcopy(defs[def_name])\n schema.update(resolved)\n # Resolve any nested refs in the newly added definition\n _resolve_refs(schema, defs, expansion_stack)\n # Remove from stack after processing\n expansion_stack.pop()\n return\n\n # Recursively resolve refs in nested structures\n for value in schema.values():\n _resolve_refs(value, defs, expansion_stack)\n elif isinstance(schema, list):\n for item in schema:\n _resolve_refs(item, defs, expansion_stack)\n\n\nclass _SharedGemini:\n needs_key = \"gemini\"\n key_env_var = \"LLM_GEMINI_KEY\"\n can_stream = True\n supports_schema = True\n supports_tools = True\n\n attachment_types = set()\n\n class Options(llm.Options):\n code_execution: Optional[bool] = Field(\n description=\"Enables the model to generate and run Python code\",\n default=None,\n )\n temperature: Optional[float] = Field(\n description=(\n \"Controls the randomness of the output. Use higher values for \"\n \"more creative responses, and lower values for more \"\n \"deterministic responses.\"\n ),\n default=None,\n ge=0.0,\n le=2.0,\n )\n max_output_tokens: Optional[int] = Field(\n description=\"Sets the maximum number of tokens to include in a candidate.\",\n default=None,\n )\n top_p: Optional[float] = Field(\n description=(\n \"Changes how the model selects tokens for output. Tokens are \"\n \"selected from the most to least probable until the sum of \"\n \"their probabilities equals the topP value.\"\n ),\n default=None,\n ge=0.0,\n le=1.0,\n )\n top_k: Optional[int] = Field(\n description=(\n \"Changes how the model selects tokens for output. A topK of 1 \"\n \"means the selected token is the most probable among all the \"\n \"tokens in the model's vocabulary, while a topK of 3 means \"\n \"that the next token is selected from among the 3 most \"\n \"probable using the temperature.\"\n ),\n default=None,\n ge=1,\n )\n json_object: Optional[bool] = Field(\n description=\"Output a valid JSON object {...}\",\n default=None,\n )\n timeout: Optional[float] = Field(\n description=(\n \"The maximum time in seconds to wait for a response. \"\n \"If the model does not respond within this time, \"\n \"the request will be aborted.\"\n ),\n default=None,\n )\n url_context: Optional[bool] = Field(\n description=(\n \"Enable the URL context tool so the model can fetch content \"\n \"from URLs mentioned in the prompt\"\n ),\n default=None,\n )\n\n def __init__(\n self,\n gemini_model_id,\n can_vision=True,\n can_google_search=False,\n can_thinking_budget=False,\n thinking_levels=None,\n can_schema=False,\n can_media_resolution=True,\n ):\n self.model_id = \"gemini/{}\".format(gemini_model_id)\n self.gemini_model_id = gemini_model_id\n self.can_google_search = can_google_search\n self.supports_schema = can_schema\n self.can_thinking_budget = can_thinking_budget\n self.thinking_levels = thinking_levels\n self.can_media_resolution = can_media_resolution\n\n # Build Options class dynamically based on capabilities\n extra_fields = {}\n\n if can_google_search:\n extra_fields[\"google_search\"] = (\n Optional[bool],\n Field(\n description=\"Enables the model to use Google Search to improve the accuracy and recency of responses from the model\",\n default=None,\n ),\n )\n\n if can_thinking_budget:\n extra_fields[\"thinking_budget\"] = (\n Optional[int],\n Field(\n description=\"Indicates the thinking budget in tokens. Set to 0 to disable.\",\n default=None,\n ),\n )\n\n if thinking_levels:\n # Create a dynamic enum with only the supported levels\n ThinkingLevelEnum = Enum(\n \"ThinkingLevel\",\n {level.upper(): level for level in thinking_levels},\n type=str,\n )\n level_choices = \", \".join(f\"'{level}'\" for level in thinking_levels)\n extra_fields[\"thinking_level\"] = (\n Optional[ThinkingLevelEnum],\n Field(\n description=f\"Indicates the thinking level. Can be {level_choices}.\",\n default=None,\n ),\n )\n\n if can_media_resolution:\n extra_fields[\"media_resolution\"] = (\n Optional[MediaResolution],\n Field(\n description=(\n \"Media resolution for the input media (esp. YouTube) \"\n \"- default is low, other values are medium, high, or unspecified\"\n ),\n default=MediaResolution.LOW,\n ),\n )\n\n if extra_fields:\n self.Options = create_model(\n \"Options\",\n __base__=self.Options,\n **extra_fields,\n )\n\n if can_vision:\n self.attachment_types = ATTACHMENT_TYPES\n\n def build_messages(self, prompt, conversation):\n messages = []\n if conversation:\n for response in conversation.responses:\n parts = []\n for attachment in response.attachments:\n mime_type = resolve_type(attachment)\n parts.append(self._build_attachment_part(attachment, mime_type))\n if response.prompt.prompt:\n parts.append({\"text\": response.prompt.prompt})\n if response.prompt.tool_results:\n parts.extend(\n [\n {\n \"function_response\": {\n \"name\": tool_result.name,\n \"response\": {\n \"output\": tool_result.output,\n },\n }\n }\n for tool_result in response.prompt.tool_results\n ]\n )\n messages.append({\"role\": \"user\", \"parts\": parts})\n model_parts = []\n response_text = response.text_or_raise()\n model_parts.append({\"text\": response_text})\n tool_calls = response.tool_calls_or_raise()\n if tool_calls:\n model_parts.extend(\n [\n {\n \"function_call\": {\n \"name\": tool_call.name,\n \"args\": tool_call.arguments,\n }\n }\n for tool_call in tool_calls\n ]\n )\n messages.append({\"role\": \"model\", \"parts\": model_parts})\n\n parts = []\n if prompt.prompt:\n parts.append({\"text\": prompt.prompt})\n if prompt.tool_results:\n parts.extend(\n [\n {\n \"function_response\": {\n \"name\": tool_result.name,\n \"response\": {\n \"output\": tool_result.output,\n },\n }\n }\n for tool_result in prompt.tool_results\n ]\n )\n for attachment in prompt.attachments:\n mime_type = resolve_type(attachment)\n parts.append(self._build_attachment_part(attachment, mime_type))\n\n messages.append({\"role\": \"user\", \"parts\": parts})\n return messages\n\n def _build_attachment_part(self, attachment, mime_type):\n \"\"\"Build the appropriate part for an attachment based on its type\"\"\"\n if mime_type == \"video/youtube\":\n return {\"fileData\": {\"mimeType\": mime_type, \"fileUri\": attachment.url}}\n else:\n return {\n \"inlineData\": {\n \"data\": attachment.base64_content(),\n \"mimeType\": mime_type,\n }\n }\n\n def build_request_body(self, prompt, conversation):\n body = {\n \"contents\": self.build_messages(prompt, conversation),\n \"safetySettings\": SAFETY_SETTINGS,\n }\n if prompt.system:\n body[\"systemInstruction\"] = {\"parts\": [{\"text\": prompt.system}]}\n\n tools = []\n if prompt.options and prompt.options.code_execution:\n tools.append({\"codeExecution\": {}})\n if prompt.options and self.can_google_search and prompt.options.google_search:\n tool_name = (\n \"google_search_retrieval\"\n if self.model_id in GOOGLE_SEARCH_MODELS_USING_SEARCH_RETRIEVAL\n else \"google_search\"\n )\n tools.append({tool_name: {}})\n if prompt.options and prompt.options.url_context:\n tools.append({\"url_context\": {}})\n if prompt.tools:\n tools.append(\n {\n \"functionDeclarations\": [\n {\n \"name\": tool.name,\n \"description\": tool.description,\n \"parameters\": cleanup_schema(\n copy.deepcopy(tool.input_schema)\n ),\n }\n for tool in prompt.tools\n ]\n }\n )\n if tools:\n body[\"tools\"] = tools\n\n generation_config = {}\n\n if prompt.schema:\n generation_config.update(\n {\n \"response_mime_type\": \"application/json\",\n \"response_schema\": cleanup_schema(copy.deepcopy(prompt.schema)),\n }\n )\n\n if self.can_thinking_budget and prompt.options.thinking_budget is not None:\n generation_config[\"thinking_config\"] = {\n \"thinking_budget\": prompt.options.thinking_budget\n }\n\n if (\n self.thinking_levels\n and getattr(prompt.options, \"thinking_level\", None) is not None\n ):\n # Get the string value from the enum\n thinking_level = prompt.options.thinking_level\n if hasattr(thinking_level, \"value\"):\n thinking_level = thinking_level.value\n generation_config[\"thinkingConfig\"] = {\"thinkingLevel\": thinking_level}\n\n config_map = {\n \"temperature\": \"temperature\",\n \"max_output_tokens\": \"maxOutputTokens\",\n \"top_p\": \"topP\",\n \"top_k\": \"topK\",\n }\n if prompt.options and prompt.options.json_object:\n generation_config[\"response_mime_type\"] = \"application/json\"\n\n if any(\n getattr(prompt.options, key, None) is not None for key in config_map.keys()\n ):\n for key, other_key in config_map.items():\n config_value = getattr(prompt.options, key, None)\n if config_value is not None:\n generation_config[other_key] = config_value\n\n has_youtube = any(\n attachment.url and is_youtube_url(attachment.url)\n for attachment in prompt.attachments\n ) or (\n conversation\n and any(\n attachment.url and is_youtube_url(attachment.url)\n for response in conversation.responses\n for attachment in response.attachments\n )\n )\n\n # See https://ai.google.dev/api/generate-content#MediaResolution for mediaResolution token counts\n if self.can_media_resolution:\n media_resolution = getattr(prompt.options, \"media_resolution\", None)\n if media_resolution:\n generation_config[\"mediaResolution\"] = (\n f\"MEDIA_RESOLUTION_{media_resolution.value.upper()}\"\n )\n elif has_youtube: # support longer videos even if no option set\n generation_config[\"mediaResolution\"] = \"MEDIA_RESOLUTION_LOW\"\n\n if generation_config:\n body[\"generationConfig\"] = generation_config\n\n return body\n\n def process_part(self, part, response):\n if \"functionCall\" in part:\n response.add_tool_call(\n llm.ToolCall(\n name=part[\"functionCall\"][\"name\"],\n arguments=part[\"functionCall\"][\"args\"],\n )\n )\n if \"text\" in part:\n return part[\"text\"]\n elif \"executableCode\" in part:\n return f'```{part[\"executableCode\"][\"language\"].lower()}\\n{part[\"executableCode\"][\"code\"].strip()}\\n```\\n'\n elif \"codeExecutionResult\" in part:\n return f'```\\n{part[\"codeExecutionResult\"][\"output\"].strip()}\\n```\\n'\n return \"\"\n\n def process_candidates(self, candidates, response):\n # We only use the first candidate\n for part in candidates[0][\"content\"][\"parts\"]:\n yield self.process_part(part, response)\n\n def set_usage(self, response):\n try:\n # Don't record the \"content\" key from that last candidate\n for candidate in response.response_json[\"candidates\"]:\n candidate.pop(\"content\", None)\n usage = response.response_json.pop(\"usageMetadata\")\n input_tokens = usage.pop(\"promptTokenCount\", None)\n # See https://github.com/simonw/llm-gemini/issues/75#issuecomment-2861827509\n candidates_token_count = usage.get(\"candidatesTokenCount\") or 0\n thoughts_token_count = usage.get(\"thoughtsTokenCount\") or 0\n output_tokens = candidates_token_count + thoughts_token_count\n tool_token_count = usage.get(\"toolUsePromptTokenCount\") or 0\n if tool_token_count:\n if input_tokens is None:\n input_tokens = tool_token_count\n else:\n input_tokens += tool_token_count\n usage.pop(\"totalTokenCount\", None)\n if input_tokens is not None:\n response.set_usage(\n input=input_tokens, output=output_tokens, details=usage or None\n )\n except (IndexError, KeyError):\n pass\n\n\nclass GeminiPro(_SharedGemini, llm.KeyModel):\n def execute(self, prompt, stream, response, conversation, key):\n url = f\"https://generativelanguage.googleapis.com/v1beta/models/{self.gemini_model_id}:streamGenerateContent\"\n gathered = []\n body = self.build_request_body(prompt, conversation)\n\n with httpx.stream(\n \"POST\",\n url,\n timeout=prompt.options.timeout,\n headers={\"x-goog-api-key\": self.get_key(key)},\n json=body,\n ) as http_response:\n events = ijson.sendable_list()\n coro = ijson.items_coro(events, \"item\")\n for chunk in http_response.iter_bytes():\n coro.send(chunk)\n if events:\n for event in events:\n if isinstance(event, dict) and \"error\" in event:\n raise llm.ModelError(event[\"error\"][\"message\"])\n try:\n yield from self.process_candidates(\n event[\"candidates\"], response\n )\n except KeyError:\n yield \"\"\n gathered.append(event)\n events.clear()\n response.response_json = gathered[-1]\n resolved_model = gathered[-1][\"modelVersion\"]\n response.set_resolved_model(resolved_model)\n self.set_usage(response)\n\n\nclass AsyncGeminiPro(_SharedGemini, llm.AsyncKeyModel):\n async def execute(self, prompt, stream, response, conversation, key):\n url = f\"https://generativelanguage.googleapis.com/v1beta/models/{self.gemini_model_id}:streamGenerateContent\"\n gathered = []\n body = self.build_request_body(prompt, conversation)\n\n async with httpx.AsyncClient() as client:\n async with client.stream(\n \"POST\",\n url,\n timeout=prompt.options.timeout,\n headers={\"x-goog-api-key\": self.get_key(key)},\n json=body,\n ) as http_response:\n events = ijson.sendable_list()\n coro = ijson.items_coro(events, \"item\")\n async for chunk in http_response.aiter_bytes():\n coro.send(chunk)\n if events:\n for event in events:\n if isinstance(event, dict) and \"error\" in event:\n raise llm.ModelError(event[\"error\"][\"message\"])\n try:\n for chunk in self.process_candidates(\n event[\"candidates\"], response\n ):\n yield chunk\n except KeyError:\n yield \"\"\n gathered.append(event)\n events.clear()\n response.response_json = gathered[-1]\n self.set_usage(response)\n\n\n@llm.hookimpl\ndef register_embedding_models(register):\n register(GeminiEmbeddingModel(\"text-embedding-004\", \"text-embedding-004\"))\n # gemini-embedding-exp-03-07 in different truncation sizes\n register(\n GeminiEmbeddingModel(\n \"gemini-embedding-exp-03-07\", \"gemini-embedding-exp-03-07\"\n ),\n )\n for i in (128, 256, 512, 1024, 2048):\n register(\n GeminiEmbeddingModel(\n f\"gemini-embedding-exp-03-07-{i}\", f\"gemini-embedding-exp-03-07\", i\n ),\n )\n\n\nclass GeminiEmbeddingModel(llm.EmbeddingModel):\n needs_key = \"gemini\"\n key_env_var = \"LLM_GEMINI_KEY\"\n batch_size = 20\n\n def __init__(self, model_id, gemini_model_id, truncate=None):\n self.model_id = model_id\n self.gemini_model_id = gemini_model_id\n self.truncate = truncate\n\n def embed_batch(self, items):\n headers = {\n \"Content-Type\": \"application/json\",\n \"x-goog-api-key\": self.get_key(),\n }\n data = {\n \"requests\": [\n {\n \"model\": \"models/\" + self.gemini_model_id,\n \"content\": {\"parts\": [{\"text\": item}]},\n }\n for item in items\n ]\n }\n\n with httpx.Client() as client:\n response = client.post(\n f\"https://generativelanguage.googleapis.com/v1beta/models/{self.gemini_model_id}:batchEmbedContents\",\n headers=headers,\n json=data,\n timeout=None,\n )\n\n response.raise_for_status()\n values = [item[\"values\"] for item in response.json()[\"embeddings\"]]\n if self.truncate:\n values = [value[: self.truncate] for value in values]\n return values\n\n\n@llm.hookimpl\ndef register_commands(cli):\n @cli.group()\n def gemini():\n \"Commands relating to the llm-gemini plugin\"\n\n @gemini.command()\n @click.option(\"--key\", help=\"API key to use\")\n @click.option(\n \"methods\",\n \"--method\",\n multiple=True,\n help=\"Filter by supported generation methods\",\n )\n def models(key, methods):\n \"\"\"\n List of Gemini models pulled from their API\n\n Use --method to filter by supported generation methods for example:\n\n llm gemini models --method generateContent --method embedContent\n \"\"\"\n key = llm.get_key(key, \"gemini\", \"LLM_GEMINI_KEY\")\n if not key:\n raise click.ClickException(\n \"You must set the LLM_GEMINI_KEY environment variable or use --key\"\n )\n url = f\"https://generativelanguage.googleapis.com/v1beta/models\"\n response = httpx.get(url, headers={\"x-goog-api-key\": key})\n response.raise_for_status()\n models = response.json()[\"models\"]\n if methods:\n models = [\n model\n for model in models\n if any(\n method in model[\"supportedGenerationMethods\"] for method in methods\n )\n ]\n click.echo(json.dumps(models, indent=2))\n\n @gemini.command()\n @click.option(\"--key\", help=\"API key to use\")\n def files(key):\n \"List of files uploaded to the Gemini API\"\n key = llm.get_key(key, \"gemini\", \"LLM_GEMINI_KEY\")\n response = httpx.get(\n f\"https://generativelanguage.googleapis.com/v1beta/files?key={key}\",\n )\n response.raise_for_status()\n if \"files\" in response.json():\n click.echo(json.dumps(response.json()[\"files\"], indent=2))\n else:\n click.echo(\"No files uploaded to the Gemini API.\", err=True)\n","structuredPatch":[{"oldStart":476,"oldLines":7,"newStart":476,"newLines":7,"lines":[" \"Media resolution for the input media (esp. YouTube) \""," \"- default is low, other values are medium, high, or unspecified\""," ),","- default=MediaResolution.LOW,","+ default=None,"," ),"," )"," "]}],"userModified":false,"replaceAll":false}}
{"parentUuid":"cbd53a4f-d4c3-4d30-953f-0ee5bf5e2314","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_019K1h8EvtmMAEAjyPGMsVuG","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Good, the first change is done. Now I need to mark that as completed and work on the second task. I need to update the code that uses `media_resolution` to swap `None` with `MediaResolution.LOW` when building the request. Looking at lines 665-672:\n\n```python\nif self.can_media_resolution:\n media_resolution = getattr(prompt.options, \"media_resolution\", None)\n if media_resolution:\n generation_config[\"mediaResolution\"] = (\n f\"MEDIA_RESOLUTION_{media_resolution.value.upper()}\"\n )\n elif has_youtube: # support longer videos even if no option set\n generation_config[\"mediaResolution\"] = \"MEDIA_RESOLUTION_LOW\"\n```\n\nThis code currently checks if `media_resolution` is truthy. Since `None` will be falsy, it will fall through to the `elif has_youtube` check. But we need to swap `None` with `MediaResolution.LOW` when the user didn't specify it but we still need to use something for the API.\n\nActually, looking at this more carefully, I think the current logic might be okay if we interpret it like this:\n- If the user explicitly specifies a media_resolution, use it\n- Otherwise, if there's YouTube, use LOW\n- Otherwise, don't set mediaResolution at all\n\nBut the user said they want to \"swap in MediaResolution.LOW if None is found\". So I think what they want is:\n- If media_resolution is None, treat it as MediaResolution.LOW for API purposes\n- But don't log it as explicitly using that resolution\n\nActually, re-reading the user's request: \"the code that uses it swaps in MediaResolution.LOW if None is found - this is so we do not log a bunch of records as having deliberately used that media resolution when actually the user didn't specify one\"\n\nSo I think the issue is about logging/recording. When the code currently defaults to `MediaResolution.LOW`, it gets stored/logged somewhere that the user \"used\" that resolution. By making it `None` by default, and only swapping it in the API request building code, the logging won't record that the user deliberately chose it.\n\n\nI'll modify the code to explicitly handle the None case, ensuring we use LOW for the API request without implying user intent.","signature":"EvISCkYIChgCKkCuumIvtnAK/7PBjVwAdGKvgSCVi8dSwh4YuamWzS3C+FZvdGZ/IBRu85KcK8UC0iZkTc+Gi7n2GevQ/bTlIo2sEgxX/pmkfoTvijAqbkgaDLVjqm7fKqfzjtCxPSIwMsU7S9iQG/ka254YpahcwH0BL35u+mIgBtTAO4o8lTM0kc/97nsrC5I3EOKhVabyKtkRyRFXyEnk6vRd649w12Ys9+rMoKxB533FDDKu1JzEnBoKL855VaMVQwRM3tR4S7rKAuuEnVnjLD488mnVPES1y3l2TtEd+/VTHePQdrcTYlJzw5ckdfSIHsleYFTbaVSHx/kGIcEJP09FOpX/8Mxf79SLckpc+UIeqa2YTwtQ85heFWc81zFFC9mECFq6FpxemsJRg182j2dt/YgsHoJAR9kq6H+KPY3doh/ZE6hvEBx4RbT553A5DfeCvvsbRWnhsbO2qYQIM6OZ03rILN91RvBVtbBnHk/LuzqmaK2EOl3aBOYnJefqKPkzFeDlBTei7labYZbWB90OMt2+SZ3AlNS/dRsBJtdOlmUG+91QSZ/fN6cfdjfdi44Ayrf15OOdpzmEU1OdgPXDL0LUHFRSM1VK09s85N97UWcYo91vRd6ZwZJXr0MsxQHe3801BgGJ3oFWbtrmrY0tZj72mBth+uarKSpJRkVTI6fjeu5+YCgdWKTtxiEPH+0kWEmnC1Kw7gQZjuRYloRYsXGPxwM0p5o9wxTLkcWBaMladyAcENlVu5nZcGa6dy8WRQQEgimllWH4WQcziWfLc0kFfckDitPTyBmlA+chi1p6ohoGjPhQEMWLDf4Q0NAbDyz7Lm7AFBucMLZbfZRpyOSFAv4Hcp0Q4woVZycVUEzzOWJ5zVn8aK+YiIe57bKRQ12KdB3mEyKSkRrBqHBIoPbLbaJ9Wyh+npPpymh6B6fq34aRtS0u29C6FJ+sTJBC4XSfkwXYmPJ6iM/AkfiYzaMhLDEzGL3Pdx/pbxD3cXT/8HjW50q6ROQHBGjVOy58tqvRorAUAWdcVpNLJDudnxpWZhP8XriZ9kN7FrvPO0WskXZR1umNxMbwwXAW0sQmNGnSEMntzGO95DO5Z9dvOTW5M0DpOMrHPTKph1RPkuSZYDiDdiJ+RQNGRvsUcz3+hytl1u0CyTLqH7SE4vu2w8BD+PAEr/MmvaXkjCxQIv2y75Q+C8JBA+acVSgt9Pt2J4NdUK5u9tcpYQi2Dqgw2z+CXaes7TA+xGqxXk7z4siLRrP7lZwUPa6V2trBtDgXQz+2/iYRNpmuX1b0bkuvoEcg1ErYFYM4bRiZ3F+2jUSbH5ClKJ0Yo2rrM5UAJpqTUObkNJOnq91SUC6gmdVWNUoV4y8YbXQrx4NPrRqtpic21Yk8BFxh+O+tis5Wy3+yR4eBzxqwYbFHhsmH4WyjrUpe02v3wjo/A8ysQi57aLRz7PMuAMO2KQTnWSN9jBtPztKA6I7k742Tvex3KM+tkaoO56DmGfekGZb+XU+STRJFDO4wb6k1gHOSe19tQBNemiWtEjt9N8BdCJL2TCiA9oL0sdVmbh3iXsMyQuFJuzxdQPscW0FyV4qy8u7+AFgh3bhdMECTcV7GhKefR1Tvh/WCqtnPljJXd7DtnIEbvfqk8gqOPadbD7KTyitezp8PawYC2LkAP29SaCEkZAyX21XPlf7IVMtQetBI60zCrQ8ObN/LZ8OgZUSC3U9pyO3xaWJegOLAuUlqYs/o/cxNIiklpXt99ZvENxvD89PMVMl2qqKp+72iq3VF+KaID6U5iC47qHnYbINho1jwIkH3SUuNGgQ3Xtbum8+ULE8Y6DZ8/2MO9RujmoEeYXr0V/nucbLB0/m1kVS5DceXTFGmkJ6hD4jqYqRKLXVIGk/Yy4+FuM9OYLXns17HumoCG0Cp3q/gNhULJtFWu9Sh4OonlMqkn0MSogjQ2ALjSBgjPh8oSdOzPsGzt+kPV0zz9Wh2TxME/GKykwVMVwdXYtk5Du6mnXsC6+J+mT2MKT3qdjrtvRrdCJziclxkWW9xt3EMRHgybI8rE1TtBG0Ji4pK0ReieHRDLlvcBC+fEWXKL5714T7lVFio6N6sTi/P0O4IW9do8iK3bfwsby1wbOmdlldx8/F9OxBsM4sSCIslVGqNjpCxgSf4H+yirc1LFpCo0GdH0PQlpcIzCzI2sg0Qh8IP4Y39q2cxPBjdTLcmk5C6UIaLx5EHCjP/quDWn44P9Z8IlU4FAljLmJ7yWGjlxBf6uv366zmghPNaO4SiqFWHNvr9VOzSRX6LHHOJbAzGpMbd6irpFARwHNADwPZMl0LI5NU7zRNxYZmOAuGjzLRa0xkz80TgtXD359tcextEKxAJ1cjeVimVOsSviJwPNHzOHESaHwSHDU8yTjH+fb/9MZgbff4jwLgX9bzlFP9WfGhAh+DyvAqQjfLtbGqmti+Q5V0/jl0vsqqfG8Jyv/sbRAUhm0DmqNmKYOX2Fz6cQ/lo2eZwLbyZCSdRLGm9HO+0AYPdqEixSc5w38bYRQJ4ODUjVN6QATC1bRUYYaVdCFz8KmLdbwL5Pr9Lu+xSM8JsjP+j5BzlPuxu97V90jygVS8SO0r5mDGRFcmYO1lXyAWmmWsku9U9mngvcEy7N5Orqlbc0G0etTDN7avOioAXhX3I1ZKJog4pwzz55U+7EXR3H1EZqRL+WBBzXJ08a7/YxVn7ks5aUX3NLVVXMZfdj5K5cmUJlQUfy4QzVEkcnWmI1hxqXtC6GEOrS2LZG0lupq5YPF5SK9/F4v2zmetc7KyLZ1S+fWRx9joycm60SmfWzdBl/EitrxZnkp7Y7AAzFSx/NeX8AE9qVHGgW+UK3nzsqtKOZmVajWJL34rFOkI5zhlF3lBHoeR1TxGcOHxENQafqX9OmusZbHbNi+9BVcCQ7mtC85PRItCIwLIWMtgw7RV/7TVXNqd/cdzSCsnOOjnATdpalpwQHjP71uBEcvA6IDEAcS/9hLDPIvpbswwy25XIajR1X1+wCeSe7cryZY8t0xb9Jk0YLuGC773Wz4w/okkwZSJMNfGBoCin2oqemv5hw7bzXMYYhWqxM/yb5OE6c+000qlF38fc39zLFq2IpAYW71pvJ2xx/4xBOOAga9bIB7e7mKUE2WfGGzRcYib94fLeORau/rLa/yPpCQF+yjBUu1wGD9TAeUhS00yUvJbGp2aMpYOD5d/kGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":13,"cache_creation_input_tokens":596,"cache_read_input_tokens":31429,"cache_creation":{"ephemeral_5m_input_tokens":596,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWEZC6sgmTnm79Tg8a3jX","type":"assistant","uuid":"fb8f1ab8-8010-48d2-87da-939348e1b78e","timestamp":"2025-12-18T17:35:17.542Z"}
{"parentUuid":"fb8f1ab8-8010-48d2-87da-939348e1b78e","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_019K1h8EvtmMAEAjyPGMsVuG","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01GtKKSyVg3zjx7xvuveC8r9","name":"TodoWrite","input":{"todos":[{"content":"Change media_resolution default from MediaResolution.LOW to None","status":"completed","activeForm":"Changed media_resolution default"},{"content":"Update request building code to swap None with MediaResolution.LOW","status":"in_progress","activeForm":"Updating request building code"}]}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":13,"cache_creation_input_tokens":596,"cache_read_input_tokens":31429,"cache_creation":{"ephemeral_5m_input_tokens":596,"ephemeral_1h_input_tokens":0},"output_tokens":691,"service_tier":"standard"}},"requestId":"req_011CWEZC6sgmTnm79Tg8a3jX","type":"assistant","uuid":"7497b548-436c-4bf7-b1b9-4d9f2a313b90","timestamp":"2025-12-18T17:35:17.683Z"}
{"parentUuid":"7497b548-436c-4bf7-b1b9-4d9f2a313b90","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01GtKKSyVg3zjx7xvuveC8r9","type":"tool_result","content":"Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable"}]},"uuid":"696ee146-c032-4de2-89d0-981f2879153d","timestamp":"2025-12-18T17:35:17.707Z","toolUseResult":{"oldTodos":[{"content":"Change media_resolution default from MediaResolution.LOW to None","status":"in_progress","activeForm":"Changing media_resolution default"},{"content":"Update request building code to swap None with MediaResolution.LOW","status":"pending","activeForm":"Updating request building code"}],"newTodos":[{"content":"Change media_resolution default from MediaResolution.LOW to None","status":"completed","activeForm":"Changed media_resolution default"},{"content":"Update request building code to swap None with MediaResolution.LOW","status":"in_progress","activeForm":"Updating request building code"}]}}
{"parentUuid":"696ee146-c032-4de2-89d0-981f2879153d","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_012jvJm3chjh21HYbktaEGhA","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now let me update the request building code. I need to modify the code that builds the mediaResolution in the request to swap None with MediaResolution.LOW:","signature":"EsUCCkYIChgCKkDuxb+C7tnmLMRVaxiS5C4jQwqlVlTGh2VwCAKRGKsXUTBmk/U/lYdk5VKEUE+RCajwNwVTk7GCv51PAkBggmKIEgzOffWVA1/YqBOxYqIaDCzGndEwn5/dncUy8yIwAeTZut9cDUkyA3MIXKBPpgBttKXF/aZ6gbmEvDiy9Dn3Nwml7NSGssiT1jblrJDiKqwBCYMV0YdADOGbv/4CMkil/FIKy/iYcrgyRUpphPa2TRWhLPA17AEj4GQKF8QS08xp52ydKL6GMytpSr12A8I0hLymiH8oZOm38OadXfRenJRSEsGOTAbofSYdcAzn29b0H+wido2CcHNBlyEBqjllRSrLZtmLK5n8CgcQqi4wYYWfeyAsh0uVgJqn+GaEibPwAmUIO8PUBkjX4WU3D7IbzIsvNvgk7JVX6glp5RgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":13,"cache_creation_input_tokens":729,"cache_read_input_tokens":32025,"cache_creation":{"ephemeral_5m_input_tokens":729,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard"}},"requestId":"req_011CWEZCkwAVGb2BXhj8Ta9o","type":"assistant","uuid":"849c2502-7bcb-416e-9ded-16ff63f338bd","timestamp":"2025-12-18T17:35:19.577Z"}
{"parentUuid":"849c2502-7bcb-416e-9ded-16ff63f338bd","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_012jvJm3chjh21HYbktaEGhA","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0151ipsEdtuZT8jhZJRHt5Dq","name":"Edit","input":{"file_path":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini/llm_gemini.py","old_string":" # See https://ai.google.dev/api/generate-content#MediaResolution for mediaResolution token counts\n if self.can_media_resolution:\n media_resolution = getattr(prompt.options, \"media_resolution\", None)\n if media_resolution:\n generation_config[\"mediaResolution\"] = (\n f\"MEDIA_RESOLUTION_{media_resolution.value.upper()}\"\n )\n elif has_youtube: # support longer videos even if no option set\n generation_config[\"mediaResolution\"] = \"MEDIA_RESOLUTION_LOW\"","new_string":" # See https://ai.google.dev/api/generate-content#MediaResolution for mediaResolution token counts\n if self.can_media_resolution:\n media_resolution = getattr(prompt.options, \"media_resolution\", None)\n if media_resolution is None and has_youtube:\n media_resolution = MediaResolution.LOW\n if media_resolution:\n generation_config[\"mediaResolution\"] = (\n f\"MEDIA_RESOLUTION_{media_resolution.value.upper()}\"\n )"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":13,"cache_creation_input_tokens":729,"cache_read_input_tokens":32025,"cache_creation":{"ephemeral_5m_input_tokens":729,"ephemeral_1h_input_tokens":0},"output_tokens":415,"service_tier":"standard"}},"requestId":"req_011CWEZCkwAVGb2BXhj8Ta9o","type":"assistant","uuid":"1a136a52-376c-42d9-b638-e2acfb7b033d","timestamp":"2025-12-18T17:35:22.386Z"}
{"parentUuid":"1a136a52-376c-42d9-b638-e2acfb7b033d","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0151ipsEdtuZT8jhZJRHt5Dq","type":"tool_result","content":"The file /Users/simon/Dropbox/dev/ecosystem/llm-gemini/llm_gemini.py has been updated. Here's the result of running `cat -n` on a snippet of the edited file:\n 660→ for attachment in response.attachments\n 661→ )\n 662→ )\n 663→\n 664→ # See https://ai.google.dev/api/generate-content#MediaResolution for mediaResolution token counts\n 665→ if self.can_media_resolution:\n 666→ media_resolution = getattr(prompt.options, \"media_resolution\", None)\n 667→ if media_resolution is None and has_youtube:\n 668→ media_resolution = MediaResolution.LOW\n 669→ if media_resolution:\n 670→ generation_config[\"mediaResolution\"] = (\n 671→ f\"MEDIA_RESOLUTION_{media_resolution.value.upper()}\"\n 672→ )\n 673→\n 674→ if generation_config:\n 675→ body[\"generationConfig\"] = generation_config\n 676→"}]},"uuid":"ecb3a3ce-b3c2-452c-9573-918c9e10e1cc","timestamp":"2025-12-18T17:35:22.424Z","toolUseResult":{"filePath":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini/llm_gemini.py","oldString":" # See https://ai.google.dev/api/generate-content#MediaResolution for mediaResolution token counts\n if self.can_media_resolution:\n media_resolution = getattr(prompt.options, \"media_resolution\", None)\n if media_resolution:\n generation_config[\"mediaResolution\"] = (\n f\"MEDIA_RESOLUTION_{media_resolution.value.upper()}\"\n )\n elif has_youtube: # support longer videos even if no option set\n generation_config[\"mediaResolution\"] = \"MEDIA_RESOLUTION_LOW\"","newString":" # See https://ai.google.dev/api/generate-content#MediaResolution for mediaResolution token counts\n if self.can_media_resolution:\n media_resolution = getattr(prompt.options, \"media_resolution\", None)\n if media_resolution is None and has_youtube:\n media_resolution = MediaResolution.LOW\n if media_resolution:\n generation_config[\"mediaResolution\"] = (\n f\"MEDIA_RESOLUTION_{media_resolution.value.upper()}\"\n )","originalFile":"import click\nimport copy\nfrom enum import Enum\nimport httpx\nimport ijson\nimport json\nimport llm\nimport re\nfrom pydantic import Field, create_model\nfrom typing import Optional\n\nSAFETY_SETTINGS = [\n {\n \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n \"threshold\": \"BLOCK_NONE\",\n },\n {\n \"category\": \"HARM_CATEGORY_SEXUALLY_EXPLICIT\",\n \"threshold\": \"BLOCK_NONE\",\n },\n {\n \"category\": \"HARM_CATEGORY_HATE_SPEECH\",\n \"threshold\": \"BLOCK_NONE\",\n },\n {\n \"category\": \"HARM_CATEGORY_HARASSMENT\",\n \"threshold\": \"BLOCK_NONE\",\n },\n]\n\n# https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/ground-gemini#supported_models_2\nGOOGLE_SEARCH_MODELS = {\n \"gemini-1.5-pro-latest\",\n \"gemini-1.5-flash-latest\",\n \"gemini-1.5-pro-001\",\n \"gemini-1.5-flash-001\",\n \"gemini-1.5-pro-002\",\n \"gemini-1.5-flash-002\",\n \"gemini-2.0-flash-exp\",\n \"gemini-2.0-flash\",\n \"gemini-2.5-pro-preview-03-25\",\n \"gemini-2.5-pro-exp-03-25\",\n \"gemini-2.5-flash-preview-04-17\",\n \"gemini-2.5-pro-preview-05-06\",\n \"gemini-2.5-flash-preview-05-20\",\n \"gemini-2.5-pro-preview-06-05\",\n \"gemini-2.5-pro\",\n \"gemini-2.5-flash\",\n \"gemini-2.5-flash-lite\",\n \"gemini-flash-latest\",\n \"gemini-flash-lite-latest\",\n \"gemini-2.5-flash-preview-09-2025\",\n \"gemini-2.5-flash-lite-preview-09-2025\",\n \"gemini-3-pro-preview\",\n \"gemini-3-flash-preview\",\n}\n\n# Older Google models used google_search_retrieval instead of google_search\nGOOGLE_SEARCH_MODELS_USING_SEARCH_RETRIEVAL = {\n \"gemini-1.5-pro-latest\",\n \"gemini-1.5-flash-latest\",\n \"gemini-1.5-pro-001\",\n \"gemini-1.5-flash-001\",\n \"gemini-1.5-pro-002\",\n \"gemini-1.5-flash-002\",\n \"gemini-2.0-flash-exp\",\n}\n\nTHINKING_BUDGET_MODELS = {\n \"gemini-2.0-flash-thinking-exp-01-21\",\n \"gemini-2.0-flash-thinking-exp-1219\",\n \"gemini-2.5-flash-preview-04-17\",\n \"gemini-2.5-pro-exp-03-25\",\n \"gemini-2.5-pro-preview-03-25\",\n \"gemini-2.5-pro-preview-05-06\",\n \"gemini-2.5-flash-preview-05-20\",\n \"gemini-2.5-pro-preview-06-05\",\n \"gemini-2.5-pro\",\n \"gemini-2.5-flash\",\n \"gemini-2.5-flash-lite\",\n \"gemini-flash-latest\",\n \"gemini-flash-lite-latest\",\n \"gemini-2.5-flash-preview-09-2025\",\n \"gemini-2.5-flash-lite-preview-09-2025\",\n}\n\n# Model-specific thinking levels - models not listed here don't support thinking_level\nMODEL_THINKING_LEVELS = {\n \"gemini-3-flash-preview\": [\"minimal\", \"low\", \"medium\", \"high\"],\n \"gemini-3-pro-preview\": [\"low\", \"high\"],\n}\n\nNO_VISION_MODELS = {\"gemma-3-1b-it\", \"gemma-3n-e4b-it\"}\n\nNO_MEDIA_RESOLUTION_MODELS = {\n \"gemma-3-1b-it\",\n \"gemma-3-4b-it\",\n \"gemma-3-12b-it\",\n \"gemma-3-27b-it\",\n \"gemma-3n-e4b-it\",\n}\n\n\nclass MediaResolution(str, Enum):\n \"\"\"Allowed media resolution values for Gemini models.\"\"\"\n\n LOW = \"low\"\n MEDIUM = \"medium\"\n HIGH = \"high\"\n UNSPECIFIED = \"unspecified\"\n\n\nATTACHMENT_TYPES = {\n # Text\n \"text/plain\",\n \"text/csv\",\n \"text/html; charset=utf-8\",\n # PDF\n \"application/pdf\",\n # Images\n \"image/png\",\n \"image/jpeg\",\n \"image/webp\",\n \"image/heic\",\n \"image/heif\",\n # Audio\n \"audio/wav\",\n \"audio/mp3\",\n \"audio/aiff\",\n \"audio/aac\",\n \"audio/ogg\",\n \"application/ogg\",\n \"audio/flac\",\n \"audio/mpeg\", # Treated as audio/mp3\n # Video\n \"video/mp4\",\n \"video/mpeg\",\n \"video/mov\",\n \"video/avi\",\n \"video/x-flv\",\n \"video/mpg\",\n \"video/webm\",\n \"video/wmv\",\n \"video/3gpp\",\n \"video/quicktime\",\n \"video/youtube\",\n}\n\n\n@llm.hookimpl\ndef register_models(register):\n # Register both sync and async versions of each model\n for model_id in (\n \"gemini-pro\",\n \"gemini-1.5-pro-latest\",\n \"gemini-1.5-flash-latest\",\n \"gemini-1.5-pro-001\",\n \"gemini-1.5-flash-001\",\n \"gemini-1.5-pro-002\",\n \"gemini-1.5-flash-002\",\n \"gemini-1.5-flash-8b-latest\",\n \"gemini-1.5-flash-8b-001\",\n \"gemini-exp-1114\",\n \"gemini-exp-1121\",\n \"gemini-exp-1206\",\n \"gemini-2.0-flash-exp\",\n \"learnlm-1.5-pro-experimental\",\n # Gemma 3 models:\n \"gemma-3-1b-it\",\n \"gemma-3-4b-it\",\n \"gemma-3-12b-it\", # 12th March 2025\n \"gemma-3-27b-it\",\n \"gemma-3n-e4b-it\", # 20th May 2025\n \"gemini-2.0-flash-thinking-exp-1219\",\n \"gemini-2.0-flash-thinking-exp-01-21\",\n # Released 5th Feb 2025:\n \"gemini-2.0-flash\",\n \"gemini-2.0-pro-exp-02-05\",\n # Released 25th Feb 2025:\n \"gemini-2.0-flash-lite\",\n # 25th March 2025:\n \"gemini-2.5-pro-exp-03-25\",\n # 4th April 2025 (paid):\n \"gemini-2.5-pro-preview-03-25\",\n # 17th April 2025:\n \"gemini-2.5-flash-preview-04-17\",\n # 6th May 2025:\n \"gemini-2.5-pro-preview-05-06\",\n # 20th May 2025:\n \"gemini-2.5-flash-preview-05-20\",\n # 5th June 2025:\n \"gemini-2.5-pro-preview-06-05\",\n \"gemini-2.5-flash\",\n \"gemini-2.5-pro\",\n # 22nd July 2025:\n \"gemini-2.5-flash-lite\",\n # 25th Spetember 2025:\n \"gemini-flash-latest\",\n \"gemini-flash-lite-latest\",\n \"gemini-2.5-flash-preview-09-2025\",\n \"gemini-2.5-flash-lite-preview-09-2025\",\n # 18th November 2025:\n \"gemini-3-pro-preview\",\n # 17th December 2025:\n \"gemini-3-flash-preview\",\n ):\n can_google_search = model_id in GOOGLE_SEARCH_MODELS\n can_thinking_budget = model_id in THINKING_BUDGET_MODELS\n thinking_levels = MODEL_THINKING_LEVELS.get(model_id)\n can_vision = model_id not in NO_VISION_MODELS\n can_schema = \"flash-thinking\" not in model_id and \"gemma-3\" not in model_id\n can_media_resolution = model_id not in NO_MEDIA_RESOLUTION_MODELS\n register(\n GeminiPro(\n model_id,\n can_vision=can_vision,\n can_google_search=can_google_search,\n can_thinking_budget=can_thinking_budget,\n thinking_levels=thinking_levels,\n can_schema=can_schema,\n can_media_resolution=can_media_resolution,\n ),\n AsyncGeminiPro(\n model_id,\n can_vision=can_vision,\n can_google_search=can_google_search,\n can_thinking_budget=can_thinking_budget,\n thinking_levels=thinking_levels,\n can_schema=can_schema,\n can_media_resolution=can_media_resolution,\n ),\n aliases=(model_id,),\n )\n\n\ndef resolve_type(attachment):\n mime_type = attachment.resolve_type()\n # https://github.com/simonw/llm/issues/587#issuecomment-2439785140\n if mime_type == \"audio/mpeg\":\n mime_type = \"audio/mp3\"\n if mime_type == \"application/ogg\":\n mime_type = \"audio/ogg\"\n # Check if this is a YouTube URL\n if attachment.url and is_youtube_url(attachment.url):\n return \"video/youtube\"\n return mime_type\n\n\ndef is_youtube_url(url):\n \"\"\"Check if a URL is a YouTube video URL\"\"\"\n if not url:\n return False\n youtube_patterns = [\n r\"^https?://(www\\.)?youtube\\.com/watch\\?v=\",\n r\"^https?://youtu\\.be/\",\n r\"^https?://(www\\.)?youtube\\.com/embed/\",\n ]\n return any(re.match(pattern, url) for pattern in youtube_patterns)\n\n\ndef cleanup_schema(schema, in_properties=False):\n \"Gemini supports only a subset of JSON schema\"\n keys_to_remove = (\"$schema\", \"additionalProperties\", \"title\")\n\n # First pass: resolve $ref references using $defs\n if isinstance(schema, dict) and \"$defs\" in schema:\n defs = schema.pop(\"$defs\")\n _resolve_refs(schema, defs)\n\n if isinstance(schema, dict):\n # Only remove keys if we're not inside a 'properties' block.\n if not in_properties:\n for key in keys_to_remove:\n schema.pop(key, None)\n for key, value in list(schema.items()):\n # If the key is 'properties', set the flag for its value.\n if key == \"properties\" and isinstance(value, dict):\n cleanup_schema(value, in_properties=True)\n else:\n cleanup_schema(value, in_properties=in_properties)\n elif isinstance(schema, list):\n for item in schema:\n cleanup_schema(item, in_properties=in_properties)\n return schema\n\n\ndef _resolve_refs(schema, defs, expansion_stack=None):\n \"\"\"Recursively resolve $ref references in schema using definitions.\n\n Args:\n schema: The schema to resolve references in\n defs: Dictionary of definitions from $defs\n expansion_stack: List tracking the order of definition expansions (for cycle detection)\n\n Raises:\n ValueError: If a recursive self-reference is detected\n \"\"\"\n if expansion_stack is None:\n expansion_stack = []\n\n if isinstance(schema, dict):\n if \"$ref\" in schema:\n # Extract the reference path (e.g., \"#/$defs/Dog\" -> \"Dog\")\n ref_path = schema.pop(\"$ref\")\n if ref_path.startswith(\"#/$defs/\"):\n def_name = ref_path.split(\"/\")[-1]\n if def_name in defs:\n # Check for recursive reference\n if def_name in expansion_stack:\n # Determine if this is direct or indirect recursion\n if expansion_stack[-1] == def_name:\n # Direct recursion: the definition references itself\n raise ValueError(\n f\"Recursive schema detected: '{def_name}' directly references itself. \"\n f\"The Gemini API does not support recursive Pydantic models. \"\n f\"Please use a non-recursive schema structure.\"\n )\n else:\n # Indirect recursion: the class being referenced is earlier in the stack\n # The last item in expansion_stack is the immediate class that references def_name\n intermediate = expansion_stack[-1]\n raise ValueError(\n f\"Recursive schema detected: '{def_name}' indirectly references itself through '{intermediate}'. \"\n f\"The Gemini API does not support recursive Pydantic models. \"\n f\"Please use a non-recursive schema structure.\"\n )\n\n # Add this definition to the expansion stack\n expansion_stack.append(def_name)\n # Replace the $ref with the actual definition\n resolved = copy.deepcopy(defs[def_name])\n schema.update(resolved)\n # Resolve any nested refs in the newly added definition\n _resolve_refs(schema, defs, expansion_stack)\n # Remove from stack after processing\n expansion_stack.pop()\n return\n\n # Recursively resolve refs in nested structures\n for value in schema.values():\n _resolve_refs(value, defs, expansion_stack)\n elif isinstance(schema, list):\n for item in schema:\n _resolve_refs(item, defs, expansion_stack)\n\n\nclass _SharedGemini:\n needs_key = \"gemini\"\n key_env_var = \"LLM_GEMINI_KEY\"\n can_stream = True\n supports_schema = True\n supports_tools = True\n\n attachment_types = set()\n\n class Options(llm.Options):\n code_execution: Optional[bool] = Field(\n description=\"Enables the model to generate and run Python code\",\n default=None,\n )\n temperature: Optional[float] = Field(\n description=(\n \"Controls the randomness of the output. Use higher values for \"\n \"more creative responses, and lower values for more \"\n \"deterministic responses.\"\n ),\n default=None,\n ge=0.0,\n le=2.0,\n )\n max_output_tokens: Optional[int] = Field(\n description=\"Sets the maximum number of tokens to include in a candidate.\",\n default=None,\n )\n top_p: Optional[float] = Field(\n description=(\n \"Changes how the model selects tokens for output. Tokens are \"\n \"selected from the most to least probable until the sum of \"\n \"their probabilities equals the topP value.\"\n ),\n default=None,\n ge=0.0,\n le=1.0,\n )\n top_k: Optional[int] = Field(\n description=(\n \"Changes how the model selects tokens for output. A topK of 1 \"\n \"means the selected token is the most probable among all the \"\n \"tokens in the model's vocabulary, while a topK of 3 means \"\n \"that the next token is selected from among the 3 most \"\n \"probable using the temperature.\"\n ),\n default=None,\n ge=1,\n )\n json_object: Optional[bool] = Field(\n description=\"Output a valid JSON object {...}\",\n default=None,\n )\n timeout: Optional[float] = Field(\n description=(\n \"The maximum time in seconds to wait for a response. \"\n \"If the model does not respond within this time, \"\n \"the request will be aborted.\"\n ),\n default=None,\n )\n url_context: Optional[bool] = Field(\n description=(\n \"Enable the URL context tool so the model can fetch content \"\n \"from URLs mentioned in the prompt\"\n ),\n default=None,\n )\n\n def __init__(\n self,\n gemini_model_id,\n can_vision=True,\n can_google_search=False,\n can_thinking_budget=False,\n thinking_levels=None,\n can_schema=False,\n can_media_resolution=True,\n ):\n self.model_id = \"gemini/{}\".format(gemini_model_id)\n self.gemini_model_id = gemini_model_id\n self.can_google_search = can_google_search\n self.supports_schema = can_schema\n self.can_thinking_budget = can_thinking_budget\n self.thinking_levels = thinking_levels\n self.can_media_resolution = can_media_resolution\n\n # Build Options class dynamically based on capabilities\n extra_fields = {}\n\n if can_google_search:\n extra_fields[\"google_search\"] = (\n Optional[bool],\n Field(\n description=\"Enables the model to use Google Search to improve the accuracy and recency of responses from the model\",\n default=None,\n ),\n )\n\n if can_thinking_budget:\n extra_fields[\"thinking_budget\"] = (\n Optional[int],\n Field(\n description=\"Indicates the thinking budget in tokens. Set to 0 to disable.\",\n default=None,\n ),\n )\n\n if thinking_levels:\n # Create a dynamic enum with only the supported levels\n ThinkingLevelEnum = Enum(\n \"ThinkingLevel\",\n {level.upper(): level for level in thinking_levels},\n type=str,\n )\n level_choices = \", \".join(f\"'{level}'\" for level in thinking_levels)\n extra_fields[\"thinking_level\"] = (\n Optional[ThinkingLevelEnum],\n Field(\n description=f\"Indicates the thinking level. Can be {level_choices}.\",\n default=None,\n ),\n )\n\n if can_media_resolution:\n extra_fields[\"media_resolution\"] = (\n Optional[MediaResolution],\n Field(\n description=(\n \"Media resolution for the input media (esp. YouTube) \"\n \"- default is low, other values are medium, high, or unspecified\"\n ),\n default=None,\n ),\n )\n\n if extra_fields:\n self.Options = create_model(\n \"Options\",\n __base__=self.Options,\n **extra_fields,\n )\n\n if can_vision:\n self.attachment_types = ATTACHMENT_TYPES\n\n def build_messages(self, prompt, conversation):\n messages = []\n if conversation:\n for response in conversation.responses:\n parts = []\n for attachment in response.attachments:\n mime_type = resolve_type(attachment)\n parts.append(self._build_attachment_part(attachment, mime_type))\n if response.prompt.prompt:\n parts.append({\"text\": response.prompt.prompt})\n if response.prompt.tool_results:\n parts.extend(\n [\n {\n \"function_response\": {\n \"name\": tool_result.name,\n \"response\": {\n \"output\": tool_result.output,\n },\n }\n }\n for tool_result in response.prompt.tool_results\n ]\n )\n messages.append({\"role\": \"user\", \"parts\": parts})\n model_parts = []\n response_text = response.text_or_raise()\n model_parts.append({\"text\": response_text})\n tool_calls = response.tool_calls_or_raise()\n if tool_calls:\n model_parts.extend(\n [\n {\n \"function_call\": {\n \"name\": tool_call.name,\n \"args\": tool_call.arguments,\n }\n }\n for tool_call in tool_calls\n ]\n )\n messages.append({\"role\": \"model\", \"parts\": model_parts})\n\n parts = []\n if prompt.prompt:\n parts.append({\"text\": prompt.prompt})\n if prompt.tool_results:\n parts.extend(\n [\n {\n \"function_response\": {\n \"name\": tool_result.name,\n \"response\": {\n \"output\": tool_result.output,\n },\n }\n }\n for tool_result in prompt.tool_results\n ]\n )\n for attachment in prompt.attachments:\n mime_type = resolve_type(attachment)\n parts.append(self._build_attachment_part(attachment, mime_type))\n\n messages.append({\"role\": \"user\", \"parts\": parts})\n return messages\n\n def _build_attachment_part(self, attachment, mime_type):\n \"\"\"Build the appropriate part for an attachment based on its type\"\"\"\n if mime_type == \"video/youtube\":\n return {\"fileData\": {\"mimeType\": mime_type, \"fileUri\": attachment.url}}\n else:\n return {\n \"inlineData\": {\n \"data\": attachment.base64_content(),\n \"mimeType\": mime_type,\n }\n }\n\n def build_request_body(self, prompt, conversation):\n body = {\n \"contents\": self.build_messages(prompt, conversation),\n \"safetySettings\": SAFETY_SETTINGS,\n }\n if prompt.system:\n body[\"systemInstruction\"] = {\"parts\": [{\"text\": prompt.system}]}\n\n tools = []\n if prompt.options and prompt.options.code_execution:\n tools.append({\"codeExecution\": {}})\n if prompt.options and self.can_google_search and prompt.options.google_search:\n tool_name = (\n \"google_search_retrieval\"\n if self.model_id in GOOGLE_SEARCH_MODELS_USING_SEARCH_RETRIEVAL\n else \"google_search\"\n )\n tools.append({tool_name: {}})\n if prompt.options and prompt.options.url_context:\n tools.append({\"url_context\": {}})\n if prompt.tools:\n tools.append(\n {\n \"functionDeclarations\": [\n {\n \"name\": tool.name,\n \"description\": tool.description,\n \"parameters\": cleanup_schema(\n copy.deepcopy(tool.input_schema)\n ),\n }\n for tool in prompt.tools\n ]\n }\n )\n if tools:\n body[\"tools\"] = tools\n\n generation_config = {}\n\n if prompt.schema:\n generation_config.update(\n {\n \"response_mime_type\": \"application/json\",\n \"response_schema\": cleanup_schema(copy.deepcopy(prompt.schema)),\n }\n )\n\n if self.can_thinking_budget and prompt.options.thinking_budget is not None:\n generation_config[\"thinking_config\"] = {\n \"thinking_budget\": prompt.options.thinking_budget\n }\n\n if (\n self.thinking_levels\n and getattr(prompt.options, \"thinking_level\", None) is not None\n ):\n # Get the string value from the enum\n thinking_level = prompt.options.thinking_level\n if hasattr(thinking_level, \"value\"):\n thinking_level = thinking_level.value\n generation_config[\"thinkingConfig\"] = {\"thinkingLevel\": thinking_level}\n\n config_map = {\n \"temperature\": \"temperature\",\n \"max_output_tokens\": \"maxOutputTokens\",\n \"top_p\": \"topP\",\n \"top_k\": \"topK\",\n }\n if prompt.options and prompt.options.json_object:\n generation_config[\"response_mime_type\"] = \"application/json\"\n\n if any(\n getattr(prompt.options, key, None) is not None for key in config_map.keys()\n ):\n for key, other_key in config_map.items():\n config_value = getattr(prompt.options, key, None)\n if config_value is not None:\n generation_config[other_key] = config_value\n\n has_youtube = any(\n attachment.url and is_youtube_url(attachment.url)\n for attachment in prompt.attachments\n ) or (\n conversation\n and any(\n attachment.url and is_youtube_url(attachment.url)\n for response in conversation.responses\n for attachment in response.attachments\n )\n )\n\n # See https://ai.google.dev/api/generate-content#MediaResolution for mediaResolution token counts\n if self.can_media_resolution:\n media_resolution = getattr(prompt.options, \"media_resolution\", None)\n if media_resolution:\n generation_config[\"mediaResolution\"] = (\n f\"MEDIA_RESOLUTION_{media_resolution.value.upper()}\"\n )\n elif has_youtube: # support longer videos even if no option set\n generation_config[\"mediaResolution\"] = \"MEDIA_RESOLUTION_LOW\"\n\n if generation_config:\n body[\"generationConfig\"] = generation_config\n\n return body\n\n def process_part(self, part, response):\n if \"functionCall\" in part:\n response.add_tool_call(\n llm.ToolCall(\n name=part[\"functionCall\"][\"name\"],\n arguments=part[\"functionCall\"][\"args\"],\n )\n )\n if \"text\" in part:\n return part[\"text\"]\n elif \"executableCode\" in part:\n return f'```{part[\"executableCode\"][\"language\"].lower()}\\n{part[\"executableCode\"][\"code\"].strip()}\\n```\\n'\n elif \"codeExecutionResult\" in part:\n return f'```\\n{part[\"codeExecutionResult\"][\"output\"].strip()}\\n```\\n'\n return \"\"\n\n def process_candidates(self, candidates, response):\n # We only use the first candidate\n for part in candidates[0][\"content\"][\"parts\"]:\n yield self.process_part(part, response)\n\n def set_usage(self, response):\n try:\n # Don't record the \"content\" key from that last candidate\n for candidate in response.response_json[\"candidates\"]:\n candidate.pop(\"content\", None)\n usage = response.response_json.pop(\"usageMetadata\")\n input_tokens = usage.pop(\"promptTokenCount\", None)\n # See https://github.com/simonw/llm-gemini/issues/75#issuecomment-2861827509\n candidates_token_count = usage.get(\"candidatesTokenCount\") or 0\n thoughts_token_count = usage.get(\"thoughtsTokenCount\") or 0\n output_tokens = candidates_token_count + thoughts_token_count\n tool_token_count = usage.get(\"toolUsePromptTokenCount\") or 0\n if tool_token_count:\n if input_tokens is None:\n input_tokens = tool_token_count\n else:\n input_tokens += tool_token_count\n usage.pop(\"totalTokenCount\", None)\n if input_tokens is not None:\n response.set_usage(\n input=input_tokens, output=output_tokens, details=usage or None\n )\n except (IndexError, KeyError):\n pass\n\n\nclass GeminiPro(_SharedGemini, llm.KeyModel):\n def execute(self, prompt, stream, response, conversation, key):\n url = f\"https://generativelanguage.googleapis.com/v1beta/models/{self.gemini_model_id}:streamGenerateContent\"\n gathered = []\n body = self.build_request_body(prompt, conversation)\n\n with httpx.stream(\n \"POST\",\n url,\n timeout=prompt.options.timeout,\n headers={\"x-goog-api-key\": self.get_key(key)},\n json=body,\n ) as http_response:\n events = ijson.sendable_list()\n coro = ijson.items_coro(events, \"item\")\n for chunk in http_response.iter_bytes():\n coro.send(chunk)\n if events:\n for event in events:\n if isinstance(event, dict) and \"error\" in event:\n raise llm.ModelError(event[\"error\"][\"message\"])\n try:\n yield from self.process_candidates(\n event[\"candidates\"], response\n )\n except KeyError:\n yield \"\"\n gathered.append(event)\n events.clear()\n response.response_json = gathered[-1]\n resolved_model = gathered[-1][\"modelVersion\"]\n response.set_resolved_model(resolved_model)\n self.set_usage(response)\n\n\nclass AsyncGeminiPro(_SharedGemini, llm.AsyncKeyModel):\n async def execute(self, prompt, stream, response, conversation, key):\n url = f\"https://generativelanguage.googleapis.com/v1beta/models/{self.gemini_model_id}:streamGenerateContent\"\n gathered = []\n body = self.build_request_body(prompt, conversation)\n\n async with httpx.AsyncClient() as client:\n async with client.stream(\n \"POST\",\n url,\n timeout=prompt.options.timeout,\n headers={\"x-goog-api-key\": self.get_key(key)},\n json=body,\n ) as http_response:\n events = ijson.sendable_list()\n coro = ijson.items_coro(events, \"item\")\n async for chunk in http_response.aiter_bytes():\n coro.send(chunk)\n if events:\n for event in events:\n if isinstance(event, dict) and \"error\" in event:\n raise llm.ModelError(event[\"error\"][\"message\"])\n try:\n for chunk in self.process_candidates(\n event[\"candidates\"], response\n ):\n yield chunk\n except KeyError:\n yield \"\"\n gathered.append(event)\n events.clear()\n response.response_json = gathered[-1]\n self.set_usage(response)\n\n\n@llm.hookimpl\ndef register_embedding_models(register):\n register(GeminiEmbeddingModel(\"text-embedding-004\", \"text-embedding-004\"))\n # gemini-embedding-exp-03-07 in different truncation sizes\n register(\n GeminiEmbeddingModel(\n \"gemini-embedding-exp-03-07\", \"gemini-embedding-exp-03-07\"\n ),\n )\n for i in (128, 256, 512, 1024, 2048):\n register(\n GeminiEmbeddingModel(\n f\"gemini-embedding-exp-03-07-{i}\", f\"gemini-embedding-exp-03-07\", i\n ),\n )\n\n\nclass GeminiEmbeddingModel(llm.EmbeddingModel):\n needs_key = \"gemini\"\n key_env_var = \"LLM_GEMINI_KEY\"\n batch_size = 20\n\n def __init__(self, model_id, gemini_model_id, truncate=None):\n self.model_id = model_id\n self.gemini_model_id = gemini_model_id\n self.truncate = truncate\n\n def embed_batch(self, items):\n headers = {\n \"Content-Type\": \"application/json\",\n \"x-goog-api-key\": self.get_key(),\n }\n data = {\n \"requests\": [\n {\n \"model\": \"models/\" + self.gemini_model_id,\n \"content\": {\"parts\": [{\"text\": item}]},\n }\n for item in items\n ]\n }\n\n with httpx.Client() as client:\n response = client.post(\n f\"https://generativelanguage.googleapis.com/v1beta/models/{self.gemini_model_id}:batchEmbedContents\",\n headers=headers,\n json=data,\n timeout=None,\n )\n\n response.raise_for_status()\n values = [item[\"values\"] for item in response.json()[\"embeddings\"]]\n if self.truncate:\n values = [value[: self.truncate] for value in values]\n return values\n\n\n@llm.hookimpl\ndef register_commands(cli):\n @cli.group()\n def gemini():\n \"Commands relating to the llm-gemini plugin\"\n\n @gemini.command()\n @click.option(\"--key\", help=\"API key to use\")\n @click.option(\n \"methods\",\n \"--method\",\n multiple=True,\n help=\"Filter by supported generation methods\",\n )\n def models(key, methods):\n \"\"\"\n List of Gemini models pulled from their API\n\n Use --method to filter by supported generation methods for example:\n\n llm gemini models --method generateContent --method embedContent\n \"\"\"\n key = llm.get_key(key, \"gemini\", \"LLM_GEMINI_KEY\")\n if not key:\n raise click.ClickException(\n \"You must set the LLM_GEMINI_KEY environment variable or use --key\"\n )\n url = f\"https://generativelanguage.googleapis.com/v1beta/models\"\n response = httpx.get(url, headers={\"x-goog-api-key\": key})\n response.raise_for_status()\n models = response.json()[\"models\"]\n if methods:\n models = [\n model\n for model in models\n if any(\n method in model[\"supportedGenerationMethods\"] for method in methods\n )\n ]\n click.echo(json.dumps(models, indent=2))\n\n @gemini.command()\n @click.option(\"--key\", help=\"API key to use\")\n def files(key):\n \"List of files uploaded to the Gemini API\"\n key = llm.get_key(key, \"gemini\", \"LLM_GEMINI_KEY\")\n response = httpx.get(\n f\"https://generativelanguage.googleapis.com/v1beta/files?key={key}\",\n )\n response.raise_for_status()\n if \"files\" in response.json():\n click.echo(json.dumps(response.json()[\"files\"], indent=2))\n else:\n click.echo(\"No files uploaded to the Gemini API.\", err=True)\n","structuredPatch":[{"oldStart":664,"oldLines":12,"newStart":664,"newLines":12,"lines":[" # See https://ai.google.dev/api/generate-content#MediaResolution for mediaResolution token counts"," if self.can_media_resolution:"," media_resolution = getattr(prompt.options, \"media_resolution\", None)","+ if media_resolution is None and has_youtube:","+ media_resolution = MediaResolution.LOW"," if media_resolution:"," generation_config[\"mediaResolution\"] = ("," f\"MEDIA_RESOLUTION_{media_resolution.value.upper()}\""," )","- elif has_youtube: # support longer videos even if no option set","- generation_config[\"mediaResolution\"] = \"MEDIA_RESOLUTION_LOW\""," "," if generation_config:"," body[\"generationConfig\"] = generation_config"]}],"userModified":false,"replaceAll":false}}
{"parentUuid":"ecb3a3ce-b3c2-452c-9573-918c9e10e1cc","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Grij5xo247ySyJNguzKQGM","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Perfect! Now I've:\n1. Changed the default from `MediaResolution.LOW` to `None` at the field definition\n2. Updated the request building code to swap `None` with `MediaResolution.LOW` only when there's YouTube content and the user didn't specify a resolution\n\nThis way:\n- The `media_resolution` option will default to `None`\n- When building the request, if it's still `None` and there's YouTube content, it gets swapped to `MediaResolution.LOW`\n- This means the logging/recording will show `None` by default instead of `MediaResolution.LOW`, even though the API gets the LOW resolution when needed\n\nLet me mark this as completed and verify the changes look good.","signature":"ErcGCkYIChgCKkBuV/e8Jp8NHUAW/HmSdOSIG86+6+AV8MplkgDyR9FTJQziI9NNt0/qAve8PiX2noER77pqAusMLLEyYYjTPgFyEgzwzbnxdRnwcuwhDiIaDOsELMHxSJBK6NPQgSIwmqJtGUuKhQmqIWL/xrhKu3sZCMMg2Ylbi1FNjNcdC4j7PMiZzV29otU06ff+vIKnKp4FCRCjkrAvUS0Ea9dxlcniUwZ/127XIVvKW4VfdQmlcw26tzvKF55kvJXuGml062hSsCdNSNJwCVO5DeHTvOHTVDLlnDT3qcz8QNGp7FEwTUQB8izcSyTe2YgJ3PCxOso45LMWAguTPMo6upaq7sdpPn1k6bm6/NnB77c04aiNG2hTJn8CeL/u4uEyv5bwUXpNWCGAke5pnrsMvIwwhTyuj2RvA2AYAcnpxbl9HwKb2E/JWn241d7btS8za1bxcUqzJL41fCZ/WNOnDOyd5RynrWnM9IgJHVdY0gny4HQ0RE+C1KtfDU4ZD9iA063TBV/cy0TmwqVjmWCtLGnmYrsNb/JX7l2EVtpPNc7V7bYrMtdTL78r1MM4fIRSm8dylft3xmXVwac1lHCutoXZeMmS++4KDb8SQvXfG1P8f8Fm9W+DP4WLoT7wz4b6escOqapYTSW/UGYt/wOKZ1sv9o2+p4uLGYK57fk12xNyDAlKKd4XS+Ohezba6qe74atImR+uUSdWOrZQUI4galh0heXYDPNH4yH71ihEMnLdXhdRQixf9x7XKD18rwqVkhJhaH4mqJzZ7rPNIg0O78J40V2uo7ukhh07tnweILqEZ0RXy3wUPp74TDKQNfwxJr0f0nFDrKBcDGnDoF8t6DQyKCoyi7dTgcaxT9WizhxSjEf9R3IxH/ZdNeXCUR/IBSr3miZe9zgup7rWham6IbX0Q/+nznUudTayHI//t91r+AGfqeFr/dGw3PFy9AgZ93Im7ZVXWBCYCA6mZm90Usx55XLvRO4e2cBg2DHpm66ckTjg50gP+HqwlWuj15R4lWXv8HuIrah6e/WLeByY32iGb0GXS+NDIBjwOzLjZTwwPgOfvlxk1QT3641wvd4QMz0oVBgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":13,"cache_creation_input_tokens":697,"cache_read_input_tokens":32754,"cache_creation":{"ephemeral_5m_input_tokens":697,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWEZD76MzQyQP2sgxqEVu","type":"assistant","uuid":"dcf8c926-f271-4a60-b2d4-669100a33fad","timestamp":"2025-12-18T17:35:25.677Z"}
{"parentUuid":"dcf8c926-f271-4a60-b2d4-669100a33fad","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Grij5xo247ySyJNguzKQGM","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Uhu98P1kzxCMHwA9nvnr7n","name":"TodoWrite","input":{"todos":[{"content":"Change media_resolution default from MediaResolution.LOW to None","status":"completed","activeForm":"Changed media_resolution default"},{"content":"Update request building code to swap None with MediaResolution.LOW","status":"completed","activeForm":"Updated request building code"}]}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":13,"cache_creation_input_tokens":697,"cache_read_input_tokens":32754,"cache_creation":{"ephemeral_5m_input_tokens":697,"ephemeral_1h_input_tokens":0},"output_tokens":293,"service_tier":"standard"}},"requestId":"req_011CWEZD76MzQyQP2sgxqEVu","type":"assistant","uuid":"04059b30-6de1-42d5-80e6-d1a41a640188","timestamp":"2025-12-18T17:35:26.077Z"}
{"parentUuid":"04059b30-6de1-42d5-80e6-d1a41a640188","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Uhu98P1kzxCMHwA9nvnr7n","type":"tool_result","content":"Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable"}]},"uuid":"194925b0-00d8-429d-937c-421a69126a80","timestamp":"2025-12-18T17:35:26.100Z","toolUseResult":{"oldTodos":[{"content":"Change media_resolution default from MediaResolution.LOW to None","status":"completed","activeForm":"Changed media_resolution default"},{"content":"Update request building code to swap None with MediaResolution.LOW","status":"in_progress","activeForm":"Updating request building code"}],"newTodos":[{"content":"Change media_resolution default from MediaResolution.LOW to None","status":"completed","activeForm":"Changed media_resolution default"},{"content":"Update request building code to swap None with MediaResolution.LOW","status":"completed","activeForm":"Updated request building code"}]}}
{"parentUuid":"194925b0-00d8-429d-937c-421a69126a80","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01UvH7nLC5cnw1XuzXLc67Ds","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Great! Let me verify that the changes are correct by reviewing the modified section:\n\nThe changes I made:\n1. Line 479: Changed `default=MediaResolution.LOW` to `default=None` in the media_resolution field definition\n2. Lines 665-672: Updated the logic to:\n - Get the media_resolution (which will be None by default now)\n - If it's None AND there's YouTube, set it to MediaResolution.LOW\n - Then, if media_resolution is set (either explicitly by user or swapped in by code), add it to the generation config\n\nThis achieves the goal:\n- Users who don't specify media_resolution will have it default to None\n- The logs/records will show None, not MediaResolution.LOW\n- But when there's YouTube content and no explicit resolution is set, the API still gets LOW resolution which is needed for longer videos\n- Users who explicitly set media_resolution will still have it work as expected\n\nThe logic is correct and should work well!","signature":"EsUICkYIChgCKkBcZvGVmkosQtk4HCIYvL3l8/GchhlKV5nKIDRKrdQb17bKfRnHVGhyhNmat6yCQcr4xXrvuuQFPgNO0ZOVgIwIEgwxmu1ZXklkPdRynOAaDMpv+lRmdUk3Yn05kSIwFD6WN5JVYUrIH+Ihn/GLPePH+d48dTdqX2dkqEdFpBjSVw2LstPnbk7roHwtjeh6KqwHVs7oSpPkIjW+feBzhv//HZmHHqh7IFTdWGEDwqdIMcdPMF7Cj9hp9hqPMMowBtH+p8oqkS12H8GGrezHgTcrbanM4xZLrPqZVIgkS/+GtXaUatJ3rNu0zfrJkIkY4gjeldPzKKtX2+UUJqca6uRofI3w2Bpr4TMF3azu3FKbHXokA9+aduf79D/Yt6NrOBdZfRtzp0+vnXYP5FikgJtBXX93YiTHNT86Q+ACO7gMPGfrHsaQQOa8DJUgUACR24ljh6pjU6q11/1QrSaZH/0BJroF0wNYTabcwQNf+F9i8xwTYOU3JYSEyOuFwglS0jUk1Tt5s7saDEyBZe2JiORN9j8yJuNWhhP0OMs1Dj8oENVOE39rxlLGAWVZE+EwiqUg4eBTwHzyqThjUwhxaasXLT/zHjzTmK4WhTLZrrKfEyLdDWchyU7ywdqXizfjxaZ/w4EMorl/rliVsyYHc8YAEWzVeU1Q1YdjBYQ9/1Vf99lEExT9i45l9BoK0kiC92PGDdmLbjFRUztm3oleGIzyP8V68rpRFTS7JvrgHFx8NLAE3407Q7Hy/nluOtfN6sBlwMtoKxzBdvlmyXV/zbfidDcozKCXV9H+n+3mJK17AzuH4kTlvWsGqLcyBBvhsFCAB2Dr7KWxFAk3Te/5M2Eoooic/w4RY1E3MBJV423q8j3XDef+0tgi51xh4EupSrZTKZV8Q+Q6ID+9NhONqtaZsHjQjE59WrkXcSL1XgaCJRyDNONaqmRlMi60zx0hGzNzVBCXDjZdy381PA0SEOJFgAWa8MpefGY9F4wqYH9I2VQWYa/z/7CGZUfW25O4ma5ylnvQmkO3geLXjWnwlvcHSFlz5aGTxfQ0S+AuRJW/lXb1lKGWD44v/3AWMi5kEYL1UxebIBQWFaVMU8xGrzRopUPv2glC8RPqHhZrNxF4Okutrl8PuUIicioI8OBOv9HjrULDcmTWct17M4VR/wu6zVv+ysaiEqiHZ+uA2LLzUrCvTChwuTf8SzGr/IimEJzDFX7ApKje0DtjqqJAYOQiG/mV7pYYm9uuA/33dUlvq89Bz0ww2a4f+SWKN8rhLdsX3DUsKdDAVNQ2vCL0MVH+yIn2SAWFdBGD7ey/RY02Rgb6ivhVauaT0LVLf8Ep0EcKEIhD71Cx3KJ5y9VyBxLVMQWgfxPjV020CjQ4rVt8vhcLrXbbk5wKFjEPCTD2qdy7feegMc3NHcyaX+5WlsR5uPaJOLSJ7lknNL0kvxgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":13,"cache_creation_input_tokens":339,"cache_read_input_tokens":33451,"cache_creation":{"ephemeral_5m_input_tokens":339,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWEZDNqQZuosESqVTbEET","type":"assistant","uuid":"07892ea4-c283-4a74-8a73-4faf152824f4","timestamp":"2025-12-18T17:35:30.403Z"}
{"parentUuid":"07892ea4-c283-4a74-8a73-4faf152824f4","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01UvH7nLC5cnw1XuzXLc67Ds","type":"message","role":"assistant","content":[{"type":"text","text":"Done! I've made the following changes:\n\n1. **Changed the default value** (line 479): The `media_resolution` field now defaults to `None` instead of `MediaResolution.LOW`\n\n2. **Updated the request building logic** (lines 667-672): When building the request, if `media_resolution` is `None` and there's YouTube content, it gets swapped in with `MediaResolution.LOW`. Otherwise, if the user explicitly specified a resolution, that's what gets used.\n\nThis way, the logs and records will show `None` by default for users who didn't specify a resolution, even though the API still receives `MEDIA_RESOLUTION_LOW` when needed for YouTube videos."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":13,"cache_creation_input_tokens":339,"cache_read_input_tokens":33451,"cache_creation":{"ephemeral_5m_input_tokens":339,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWEZDNqQZuosESqVTbEET","type":"assistant","uuid":"aa8a8fbf-5e8a-4b85-9a3c-6643e25c622a","timestamp":"2025-12-18T17:35:31.370Z"}
{"type":"file-history-snapshot","messageId":"6cbb2792-5619-4a42-ae44-f549cffa2400","snapshot":{"messageId":"6cbb2792-5619-4a42-ae44-f549cffa2400","trackedFileBackups":{"llm_gemini.py":{"backupFileName":"c970ac0709d7b8fa@v2","version":2,"backupTime":"2025-12-18T17:35:52.075Z"}},"timestamp":"2025-12-18T17:35:52.067Z"},"isSnapshotUpdate":false}
{"parentUuid":"aa8a8fbf-5e8a-4b85-9a3c-6643e25c622a","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","type":"user","message":{"role":"user","content":"actually let us try it with not setting a media resolution at all if none was set, it could be that the API automatically does this if it is missing, test that yourself by running 'uv run llm -m gemini-3-pro-preview describe -a https://static.simonwillison.net/static/2025/gemini-3-flash.jpg'"},"uuid":"6cbb2792-5619-4a42-ae44-f549cffa2400","timestamp":"2025-12-18T17:35:52.061Z","thinkingMetadata":{"level":"high","disabled":false,"triggers":[]},"todos":[]}
{"parentUuid":"6cbb2792-5619-4a42-ae44-f549cffa2400","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01BFeS73UaWkqEWHGcskBycm","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to:\n1. Test what happens if we don't set media_resolution at all when none was set\n2. Run a test command using the uv tool with llm to see if the API handles it automatically\n\nFirst, let me update the code to not set mediaResolution at all if none was provided, then run the test command.\n\nLet me change the code so that if media_resolution is None, we don't add it to the generation_config at all (unless there's YouTube content, in which case we use LOW).","signature":"Ev8ECkYIChgCKkDcie5KyixAZ3pQfhBqzTEPwB6QnJ30PiJ0x5QC0MORdbxhfu6m05f50tpI4l5jUu6mQ4dm1xzXFnEYK+yQal+PEgxYcDOXY2qayu3hWTwaDPzko7YnCDjAzzDxZSIwGGHrNHwU4IswSkBuzGqugWWOLF7Bj96G4oyyeU4aTnUpeoeHkB3mtGYBOlichzDJKuYDM5OzoZmkCA0koOksjd1Xspfjl8acL5vb1tf2khKhWfjPdtfc85Y4BJRiqXoBKvqrfisMxfsyAr4eWldQR6UHY4A9qGbhWtzWI9KuBiztCRGdyCgk5lW0v0Nf9HDZfDgfYWDb00L2jtXtmkw8sQfHo/iztJyw5C1XAXx17OX6AZXJAM0U5ZHa+UP/ZWqMW4eS204d9rLj4gho5vLOjq5eIt5N8PtqF++oM88tQZypfINbhGiE2z2Eiveew3i8+3K2Iog6yzI+fVFFjOUgmP8SHXgzzSf9JptK4n/7Cci87Go12nwMlUXN4wkAapI++Xzjtu7Kg07/bEDO7Vqztvq4YX2IHW13mrXy55djquJJXpGXwKONoaDRO0XdTSL9JSSx6FA+TVNjCcwOiJDeInw38nRSmhWPf9Fl5YTtWShGHZzC0MNjC7hmgOvkgtMMz9PaA+e4B/4oLyJF1rL6Ru1eUPkxnp4GPmbdTw1QEDkBbAa2H1Htu/yVcgON0VEGyVs2Yt9/cwJK/4rfEVJ9Kjzh6P/KyUm0F2uJ+BdtT+snJ2IuR+pJHAsM9Pc8qUIJaePji14mbeNPApx5VP61tQaHh/csCc+nAsECnG1XNJ/UGMkkIPPM2gSqj3y/wCZjJsGd2GV+QY6aGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":14075,"cache_read_input_tokens":18677,"cache_creation":{"ephemeral_5m_input_tokens":14075,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard"}},"requestId":"req_011CWEZFJNppnqT1bXT4dykj","type":"assistant","uuid":"96414536-7636-42e8-ad7c-46a77cd38e8b","timestamp":"2025-12-18T17:35:55.385Z"}
{"parentUuid":"96414536-7636-42e8-ad7c-46a77cd38e8b","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01BFeS73UaWkqEWHGcskBycm","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Lw37TB32F2hFKVUmjTa3aB","name":"Edit","input":{"file_path":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini/llm_gemini.py","old_string":" # See https://ai.google.dev/api/generate-content#MediaResolution for mediaResolution token counts\n if self.can_media_resolution:\n media_resolution = getattr(prompt.options, \"media_resolution\", None)\n if media_resolution is None and has_youtube:\n media_resolution = MediaResolution.LOW\n if media_resolution:\n generation_config[\"mediaResolution\"] = (\n f\"MEDIA_RESOLUTION_{media_resolution.value.upper()}\"\n )","new_string":" # See https://ai.google.dev/api/generate-content#MediaResolution for mediaResolution token counts\n if self.can_media_resolution:\n media_resolution = getattr(prompt.options, \"media_resolution\", None)\n # Only set media resolution if explicitly provided or if there's YouTube content\n if media_resolution is not None:\n generation_config[\"mediaResolution\"] = (\n f\"MEDIA_RESOLUTION_{media_resolution.value.upper()}\"\n )\n elif has_youtube:\n generation_config[\"mediaResolution\"] = \"MEDIA_RESOLUTION_LOW\""}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":14075,"cache_read_input_tokens":18677,"cache_creation":{"ephemeral_5m_input_tokens":14075,"ephemeral_1h_input_tokens":0},"output_tokens":507,"service_tier":"standard"}},"requestId":"req_011CWEZFJNppnqT1bXT4dykj","type":"assistant","uuid":"05b23209-beaf-49c4-b3f2-1d87e27f6793","timestamp":"2025-12-18T17:35:57.793Z"}
{"parentUuid":"05b23209-beaf-49c4-b3f2-1d87e27f6793","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Lw37TB32F2hFKVUmjTa3aB","type":"tool_result","content":"The file /Users/simon/Dropbox/dev/ecosystem/llm-gemini/llm_gemini.py has been updated. Here's the result of running `cat -n` on a snippet of the edited file:\n 660→ for attachment in response.attachments\n 661→ )\n 662→ )\n 663→\n 664→ # See https://ai.google.dev/api/generate-content#MediaResolution for mediaResolution token counts\n 665→ if self.can_media_resolution:\n 666→ media_resolution = getattr(prompt.options, \"media_resolution\", None)\n 667→ # Only set media resolution if explicitly provided or if there's YouTube content\n 668→ if media_resolution is not None:\n 669→ generation_config[\"mediaResolution\"] = (\n 670→ f\"MEDIA_RESOLUTION_{media_resolution.value.upper()}\"\n 671→ )\n 672→ elif has_youtube:\n 673→ generation_config[\"mediaResolution\"] = \"MEDIA_RESOLUTION_LOW\"\n 674→\n 675→ if generation_config:\n 676→ body[\"generationConfig\"] = generation_config\n 677→"}]},"uuid":"ba471fee-a27a-40a4-a54f-d19723405cb2","timestamp":"2025-12-18T17:35:57.828Z","toolUseResult":{"filePath":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini/llm_gemini.py","oldString":" # See https://ai.google.dev/api/generate-content#MediaResolution for mediaResolution token counts\n if self.can_media_resolution:\n media_resolution = getattr(prompt.options, \"media_resolution\", None)\n if media_resolution is None and has_youtube:\n media_resolution = MediaResolution.LOW\n if media_resolution:\n generation_config[\"mediaResolution\"] = (\n f\"MEDIA_RESOLUTION_{media_resolution.value.upper()}\"\n )","newString":" # See https://ai.google.dev/api/generate-content#MediaResolution for mediaResolution token counts\n if self.can_media_resolution:\n media_resolution = getattr(prompt.options, \"media_resolution\", None)\n # Only set media resolution if explicitly provided or if there's YouTube content\n if media_resolution is not None:\n generation_config[\"mediaResolution\"] = (\n f\"MEDIA_RESOLUTION_{media_resolution.value.upper()}\"\n )\n elif has_youtube:\n generation_config[\"mediaResolution\"] = \"MEDIA_RESOLUTION_LOW\"","originalFile":"import click\nimport copy\nfrom enum import Enum\nimport httpx\nimport ijson\nimport json\nimport llm\nimport re\nfrom pydantic import Field, create_model\nfrom typing import Optional\n\nSAFETY_SETTINGS = [\n {\n \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n \"threshold\": \"BLOCK_NONE\",\n },\n {\n \"category\": \"HARM_CATEGORY_SEXUALLY_EXPLICIT\",\n \"threshold\": \"BLOCK_NONE\",\n },\n {\n \"category\": \"HARM_CATEGORY_HATE_SPEECH\",\n \"threshold\": \"BLOCK_NONE\",\n },\n {\n \"category\": \"HARM_CATEGORY_HARASSMENT\",\n \"threshold\": \"BLOCK_NONE\",\n },\n]\n\n# https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/ground-gemini#supported_models_2\nGOOGLE_SEARCH_MODELS = {\n \"gemini-1.5-pro-latest\",\n \"gemini-1.5-flash-latest\",\n \"gemini-1.5-pro-001\",\n \"gemini-1.5-flash-001\",\n \"gemini-1.5-pro-002\",\n \"gemini-1.5-flash-002\",\n \"gemini-2.0-flash-exp\",\n \"gemini-2.0-flash\",\n \"gemini-2.5-pro-preview-03-25\",\n \"gemini-2.5-pro-exp-03-25\",\n \"gemini-2.5-flash-preview-04-17\",\n \"gemini-2.5-pro-preview-05-06\",\n \"gemini-2.5-flash-preview-05-20\",\n \"gemini-2.5-pro-preview-06-05\",\n \"gemini-2.5-pro\",\n \"gemini-2.5-flash\",\n \"gemini-2.5-flash-lite\",\n \"gemini-flash-latest\",\n \"gemini-flash-lite-latest\",\n \"gemini-2.5-flash-preview-09-2025\",\n \"gemini-2.5-flash-lite-preview-09-2025\",\n \"gemini-3-pro-preview\",\n \"gemini-3-flash-preview\",\n}\n\n# Older Google models used google_search_retrieval instead of google_search\nGOOGLE_SEARCH_MODELS_USING_SEARCH_RETRIEVAL = {\n \"gemini-1.5-pro-latest\",\n \"gemini-1.5-flash-latest\",\n \"gemini-1.5-pro-001\",\n \"gemini-1.5-flash-001\",\n \"gemini-1.5-pro-002\",\n \"gemini-1.5-flash-002\",\n \"gemini-2.0-flash-exp\",\n}\n\nTHINKING_BUDGET_MODELS = {\n \"gemini-2.0-flash-thinking-exp-01-21\",\n \"gemini-2.0-flash-thinking-exp-1219\",\n \"gemini-2.5-flash-preview-04-17\",\n \"gemini-2.5-pro-exp-03-25\",\n \"gemini-2.5-pro-preview-03-25\",\n \"gemini-2.5-pro-preview-05-06\",\n \"gemini-2.5-flash-preview-05-20\",\n \"gemini-2.5-pro-preview-06-05\",\n \"gemini-2.5-pro\",\n \"gemini-2.5-flash\",\n \"gemini-2.5-flash-lite\",\n \"gemini-flash-latest\",\n \"gemini-flash-lite-latest\",\n \"gemini-2.5-flash-preview-09-2025\",\n \"gemini-2.5-flash-lite-preview-09-2025\",\n}\n\n# Model-specific thinking levels - models not listed here don't support thinking_level\nMODEL_THINKING_LEVELS = {\n \"gemini-3-flash-preview\": [\"minimal\", \"low\", \"medium\", \"high\"],\n \"gemini-3-pro-preview\": [\"low\", \"high\"],\n}\n\nNO_VISION_MODELS = {\"gemma-3-1b-it\", \"gemma-3n-e4b-it\"}\n\nNO_MEDIA_RESOLUTION_MODELS = {\n \"gemma-3-1b-it\",\n \"gemma-3-4b-it\",\n \"gemma-3-12b-it\",\n \"gemma-3-27b-it\",\n \"gemma-3n-e4b-it\",\n}\n\n\nclass MediaResolution(str, Enum):\n \"\"\"Allowed media resolution values for Gemini models.\"\"\"\n\n LOW = \"low\"\n MEDIUM = \"medium\"\n HIGH = \"high\"\n UNSPECIFIED = \"unspecified\"\n\n\nATTACHMENT_TYPES = {\n # Text\n \"text/plain\",\n \"text/csv\",\n \"text/html; charset=utf-8\",\n # PDF\n \"application/pdf\",\n # Images\n \"image/png\",\n \"image/jpeg\",\n \"image/webp\",\n \"image/heic\",\n \"image/heif\",\n # Audio\n \"audio/wav\",\n \"audio/mp3\",\n \"audio/aiff\",\n \"audio/aac\",\n \"audio/ogg\",\n \"application/ogg\",\n \"audio/flac\",\n \"audio/mpeg\", # Treated as audio/mp3\n # Video\n \"video/mp4\",\n \"video/mpeg\",\n \"video/mov\",\n \"video/avi\",\n \"video/x-flv\",\n \"video/mpg\",\n \"video/webm\",\n \"video/wmv\",\n \"video/3gpp\",\n \"video/quicktime\",\n \"video/youtube\",\n}\n\n\n@llm.hookimpl\ndef register_models(register):\n # Register both sync and async versions of each model\n for model_id in (\n \"gemini-pro\",\n \"gemini-1.5-pro-latest\",\n \"gemini-1.5-flash-latest\",\n \"gemini-1.5-pro-001\",\n \"gemini-1.5-flash-001\",\n \"gemini-1.5-pro-002\",\n \"gemini-1.5-flash-002\",\n \"gemini-1.5-flash-8b-latest\",\n \"gemini-1.5-flash-8b-001\",\n \"gemini-exp-1114\",\n \"gemini-exp-1121\",\n \"gemini-exp-1206\",\n \"gemini-2.0-flash-exp\",\n \"learnlm-1.5-pro-experimental\",\n # Gemma 3 models:\n \"gemma-3-1b-it\",\n \"gemma-3-4b-it\",\n \"gemma-3-12b-it\", # 12th March 2025\n \"gemma-3-27b-it\",\n \"gemma-3n-e4b-it\", # 20th May 2025\n \"gemini-2.0-flash-thinking-exp-1219\",\n \"gemini-2.0-flash-thinking-exp-01-21\",\n # Released 5th Feb 2025:\n \"gemini-2.0-flash\",\n \"gemini-2.0-pro-exp-02-05\",\n # Released 25th Feb 2025:\n \"gemini-2.0-flash-lite\",\n # 25th March 2025:\n \"gemini-2.5-pro-exp-03-25\",\n # 4th April 2025 (paid):\n \"gemini-2.5-pro-preview-03-25\",\n # 17th April 2025:\n \"gemini-2.5-flash-preview-04-17\",\n # 6th May 2025:\n \"gemini-2.5-pro-preview-05-06\",\n # 20th May 2025:\n \"gemini-2.5-flash-preview-05-20\",\n # 5th June 2025:\n \"gemini-2.5-pro-preview-06-05\",\n \"gemini-2.5-flash\",\n \"gemini-2.5-pro\",\n # 22nd July 2025:\n \"gemini-2.5-flash-lite\",\n # 25th Spetember 2025:\n \"gemini-flash-latest\",\n \"gemini-flash-lite-latest\",\n \"gemini-2.5-flash-preview-09-2025\",\n \"gemini-2.5-flash-lite-preview-09-2025\",\n # 18th November 2025:\n \"gemini-3-pro-preview\",\n # 17th December 2025:\n \"gemini-3-flash-preview\",\n ):\n can_google_search = model_id in GOOGLE_SEARCH_MODELS\n can_thinking_budget = model_id in THINKING_BUDGET_MODELS\n thinking_levels = MODEL_THINKING_LEVELS.get(model_id)\n can_vision = model_id not in NO_VISION_MODELS\n can_schema = \"flash-thinking\" not in model_id and \"gemma-3\" not in model_id\n can_media_resolution = model_id not in NO_MEDIA_RESOLUTION_MODELS\n register(\n GeminiPro(\n model_id,\n can_vision=can_vision,\n can_google_search=can_google_search,\n can_thinking_budget=can_thinking_budget,\n thinking_levels=thinking_levels,\n can_schema=can_schema,\n can_media_resolution=can_media_resolution,\n ),\n AsyncGeminiPro(\n model_id,\n can_vision=can_vision,\n can_google_search=can_google_search,\n can_thinking_budget=can_thinking_budget,\n thinking_levels=thinking_levels,\n can_schema=can_schema,\n can_media_resolution=can_media_resolution,\n ),\n aliases=(model_id,),\n )\n\n\ndef resolve_type(attachment):\n mime_type = attachment.resolve_type()\n # https://github.com/simonw/llm/issues/587#issuecomment-2439785140\n if mime_type == \"audio/mpeg\":\n mime_type = \"audio/mp3\"\n if mime_type == \"application/ogg\":\n mime_type = \"audio/ogg\"\n # Check if this is a YouTube URL\n if attachment.url and is_youtube_url(attachment.url):\n return \"video/youtube\"\n return mime_type\n\n\ndef is_youtube_url(url):\n \"\"\"Check if a URL is a YouTube video URL\"\"\"\n if not url:\n return False\n youtube_patterns = [\n r\"^https?://(www\\.)?youtube\\.com/watch\\?v=\",\n r\"^https?://youtu\\.be/\",\n r\"^https?://(www\\.)?youtube\\.com/embed/\",\n ]\n return any(re.match(pattern, url) for pattern in youtube_patterns)\n\n\ndef cleanup_schema(schema, in_properties=False):\n \"Gemini supports only a subset of JSON schema\"\n keys_to_remove = (\"$schema\", \"additionalProperties\", \"title\")\n\n # First pass: resolve $ref references using $defs\n if isinstance(schema, dict) and \"$defs\" in schema:\n defs = schema.pop(\"$defs\")\n _resolve_refs(schema, defs)\n\n if isinstance(schema, dict):\n # Only remove keys if we're not inside a 'properties' block.\n if not in_properties:\n for key in keys_to_remove:\n schema.pop(key, None)\n for key, value in list(schema.items()):\n # If the key is 'properties', set the flag for its value.\n if key == \"properties\" and isinstance(value, dict):\n cleanup_schema(value, in_properties=True)\n else:\n cleanup_schema(value, in_properties=in_properties)\n elif isinstance(schema, list):\n for item in schema:\n cleanup_schema(item, in_properties=in_properties)\n return schema\n\n\ndef _resolve_refs(schema, defs, expansion_stack=None):\n \"\"\"Recursively resolve $ref references in schema using definitions.\n\n Args:\n schema: The schema to resolve references in\n defs: Dictionary of definitions from $defs\n expansion_stack: List tracking the order of definition expansions (for cycle detection)\n\n Raises:\n ValueError: If a recursive self-reference is detected\n \"\"\"\n if expansion_stack is None:\n expansion_stack = []\n\n if isinstance(schema, dict):\n if \"$ref\" in schema:\n # Extract the reference path (e.g., \"#/$defs/Dog\" -> \"Dog\")\n ref_path = schema.pop(\"$ref\")\n if ref_path.startswith(\"#/$defs/\"):\n def_name = ref_path.split(\"/\")[-1]\n if def_name in defs:\n # Check for recursive reference\n if def_name in expansion_stack:\n # Determine if this is direct or indirect recursion\n if expansion_stack[-1] == def_name:\n # Direct recursion: the definition references itself\n raise ValueError(\n f\"Recursive schema detected: '{def_name}' directly references itself. \"\n f\"The Gemini API does not support recursive Pydantic models. \"\n f\"Please use a non-recursive schema structure.\"\n )\n else:\n # Indirect recursion: the class being referenced is earlier in the stack\n # The last item in expansion_stack is the immediate class that references def_name\n intermediate = expansion_stack[-1]\n raise ValueError(\n f\"Recursive schema detected: '{def_name}' indirectly references itself through '{intermediate}'. \"\n f\"The Gemini API does not support recursive Pydantic models. \"\n f\"Please use a non-recursive schema structure.\"\n )\n\n # Add this definition to the expansion stack\n expansion_stack.append(def_name)\n # Replace the $ref with the actual definition\n resolved = copy.deepcopy(defs[def_name])\n schema.update(resolved)\n # Resolve any nested refs in the newly added definition\n _resolve_refs(schema, defs, expansion_stack)\n # Remove from stack after processing\n expansion_stack.pop()\n return\n\n # Recursively resolve refs in nested structures\n for value in schema.values():\n _resolve_refs(value, defs, expansion_stack)\n elif isinstance(schema, list):\n for item in schema:\n _resolve_refs(item, defs, expansion_stack)\n\n\nclass _SharedGemini:\n needs_key = \"gemini\"\n key_env_var = \"LLM_GEMINI_KEY\"\n can_stream = True\n supports_schema = True\n supports_tools = True\n\n attachment_types = set()\n\n class Options(llm.Options):\n code_execution: Optional[bool] = Field(\n description=\"Enables the model to generate and run Python code\",\n default=None,\n )\n temperature: Optional[float] = Field(\n description=(\n \"Controls the randomness of the output. Use higher values for \"\n \"more creative responses, and lower values for more \"\n \"deterministic responses.\"\n ),\n default=None,\n ge=0.0,\n le=2.0,\n )\n max_output_tokens: Optional[int] = Field(\n description=\"Sets the maximum number of tokens to include in a candidate.\",\n default=None,\n )\n top_p: Optional[float] = Field(\n description=(\n \"Changes how the model selects tokens for output. Tokens are \"\n \"selected from the most to least probable until the sum of \"\n \"their probabilities equals the topP value.\"\n ),\n default=None,\n ge=0.0,\n le=1.0,\n )\n top_k: Optional[int] = Field(\n description=(\n \"Changes how the model selects tokens for output. A topK of 1 \"\n \"means the selected token is the most probable among all the \"\n \"tokens in the model's vocabulary, while a topK of 3 means \"\n \"that the next token is selected from among the 3 most \"\n \"probable using the temperature.\"\n ),\n default=None,\n ge=1,\n )\n json_object: Optional[bool] = Field(\n description=\"Output a valid JSON object {...}\",\n default=None,\n )\n timeout: Optional[float] = Field(\n description=(\n \"The maximum time in seconds to wait for a response. \"\n \"If the model does not respond within this time, \"\n \"the request will be aborted.\"\n ),\n default=None,\n )\n url_context: Optional[bool] = Field(\n description=(\n \"Enable the URL context tool so the model can fetch content \"\n \"from URLs mentioned in the prompt\"\n ),\n default=None,\n )\n\n def __init__(\n self,\n gemini_model_id,\n can_vision=True,\n can_google_search=False,\n can_thinking_budget=False,\n thinking_levels=None,\n can_schema=False,\n can_media_resolution=True,\n ):\n self.model_id = \"gemini/{}\".format(gemini_model_id)\n self.gemini_model_id = gemini_model_id\n self.can_google_search = can_google_search\n self.supports_schema = can_schema\n self.can_thinking_budget = can_thinking_budget\n self.thinking_levels = thinking_levels\n self.can_media_resolution = can_media_resolution\n\n # Build Options class dynamically based on capabilities\n extra_fields = {}\n\n if can_google_search:\n extra_fields[\"google_search\"] = (\n Optional[bool],\n Field(\n description=\"Enables the model to use Google Search to improve the accuracy and recency of responses from the model\",\n default=None,\n ),\n )\n\n if can_thinking_budget:\n extra_fields[\"thinking_budget\"] = (\n Optional[int],\n Field(\n description=\"Indicates the thinking budget in tokens. Set to 0 to disable.\",\n default=None,\n ),\n )\n\n if thinking_levels:\n # Create a dynamic enum with only the supported levels\n ThinkingLevelEnum = Enum(\n \"ThinkingLevel\",\n {level.upper(): level for level in thinking_levels},\n type=str,\n )\n level_choices = \", \".join(f\"'{level}'\" for level in thinking_levels)\n extra_fields[\"thinking_level\"] = (\n Optional[ThinkingLevelEnum],\n Field(\n description=f\"Indicates the thinking level. Can be {level_choices}.\",\n default=None,\n ),\n )\n\n if can_media_resolution:\n extra_fields[\"media_resolution\"] = (\n Optional[MediaResolution],\n Field(\n description=(\n \"Media resolution for the input media (esp. YouTube) \"\n \"- default is low, other values are medium, high, or unspecified\"\n ),\n default=None,\n ),\n )\n\n if extra_fields:\n self.Options = create_model(\n \"Options\",\n __base__=self.Options,\n **extra_fields,\n )\n\n if can_vision:\n self.attachment_types = ATTACHMENT_TYPES\n\n def build_messages(self, prompt, conversation):\n messages = []\n if conversation:\n for response in conversation.responses:\n parts = []\n for attachment in response.attachments:\n mime_type = resolve_type(attachment)\n parts.append(self._build_attachment_part(attachment, mime_type))\n if response.prompt.prompt:\n parts.append({\"text\": response.prompt.prompt})\n if response.prompt.tool_results:\n parts.extend(\n [\n {\n \"function_response\": {\n \"name\": tool_result.name,\n \"response\": {\n \"output\": tool_result.output,\n },\n }\n }\n for tool_result in response.prompt.tool_results\n ]\n )\n messages.append({\"role\": \"user\", \"parts\": parts})\n model_parts = []\n response_text = response.text_or_raise()\n model_parts.append({\"text\": response_text})\n tool_calls = response.tool_calls_or_raise()\n if tool_calls:\n model_parts.extend(\n [\n {\n \"function_call\": {\n \"name\": tool_call.name,\n \"args\": tool_call.arguments,\n }\n }\n for tool_call in tool_calls\n ]\n )\n messages.append({\"role\": \"model\", \"parts\": model_parts})\n\n parts = []\n if prompt.prompt:\n parts.append({\"text\": prompt.prompt})\n if prompt.tool_results:\n parts.extend(\n [\n {\n \"function_response\": {\n \"name\": tool_result.name,\n \"response\": {\n \"output\": tool_result.output,\n },\n }\n }\n for tool_result in prompt.tool_results\n ]\n )\n for attachment in prompt.attachments:\n mime_type = resolve_type(attachment)\n parts.append(self._build_attachment_part(attachment, mime_type))\n\n messages.append({\"role\": \"user\", \"parts\": parts})\n return messages\n\n def _build_attachment_part(self, attachment, mime_type):\n \"\"\"Build the appropriate part for an attachment based on its type\"\"\"\n if mime_type == \"video/youtube\":\n return {\"fileData\": {\"mimeType\": mime_type, \"fileUri\": attachment.url}}\n else:\n return {\n \"inlineData\": {\n \"data\": attachment.base64_content(),\n \"mimeType\": mime_type,\n }\n }\n\n def build_request_body(self, prompt, conversation):\n body = {\n \"contents\": self.build_messages(prompt, conversation),\n \"safetySettings\": SAFETY_SETTINGS,\n }\n if prompt.system:\n body[\"systemInstruction\"] = {\"parts\": [{\"text\": prompt.system}]}\n\n tools = []\n if prompt.options and prompt.options.code_execution:\n tools.append({\"codeExecution\": {}})\n if prompt.options and self.can_google_search and prompt.options.google_search:\n tool_name = (\n \"google_search_retrieval\"\n if self.model_id in GOOGLE_SEARCH_MODELS_USING_SEARCH_RETRIEVAL\n else \"google_search\"\n )\n tools.append({tool_name: {}})\n if prompt.options and prompt.options.url_context:\n tools.append({\"url_context\": {}})\n if prompt.tools:\n tools.append(\n {\n \"functionDeclarations\": [\n {\n \"name\": tool.name,\n \"description\": tool.description,\n \"parameters\": cleanup_schema(\n copy.deepcopy(tool.input_schema)\n ),\n }\n for tool in prompt.tools\n ]\n }\n )\n if tools:\n body[\"tools\"] = tools\n\n generation_config = {}\n\n if prompt.schema:\n generation_config.update(\n {\n \"response_mime_type\": \"application/json\",\n \"response_schema\": cleanup_schema(copy.deepcopy(prompt.schema)),\n }\n )\n\n if self.can_thinking_budget and prompt.options.thinking_budget is not None:\n generation_config[\"thinking_config\"] = {\n \"thinking_budget\": prompt.options.thinking_budget\n }\n\n if (\n self.thinking_levels\n and getattr(prompt.options, \"thinking_level\", None) is not None\n ):\n # Get the string value from the enum\n thinking_level = prompt.options.thinking_level\n if hasattr(thinking_level, \"value\"):\n thinking_level = thinking_level.value\n generation_config[\"thinkingConfig\"] = {\"thinkingLevel\": thinking_level}\n\n config_map = {\n \"temperature\": \"temperature\",\n \"max_output_tokens\": \"maxOutputTokens\",\n \"top_p\": \"topP\",\n \"top_k\": \"topK\",\n }\n if prompt.options and prompt.options.json_object:\n generation_config[\"response_mime_type\"] = \"application/json\"\n\n if any(\n getattr(prompt.options, key, None) is not None for key in config_map.keys()\n ):\n for key, other_key in config_map.items():\n config_value = getattr(prompt.options, key, None)\n if config_value is not None:\n generation_config[other_key] = config_value\n\n has_youtube = any(\n attachment.url and is_youtube_url(attachment.url)\n for attachment in prompt.attachments\n ) or (\n conversation\n and any(\n attachment.url and is_youtube_url(attachment.url)\n for response in conversation.responses\n for attachment in response.attachments\n )\n )\n\n # See https://ai.google.dev/api/generate-content#MediaResolution for mediaResolution token counts\n if self.can_media_resolution:\n media_resolution = getattr(prompt.options, \"media_resolution\", None)\n if media_resolution is None and has_youtube:\n media_resolution = MediaResolution.LOW\n if media_resolution:\n generation_config[\"mediaResolution\"] = (\n f\"MEDIA_RESOLUTION_{media_resolution.value.upper()}\"\n )\n\n if generation_config:\n body[\"generationConfig\"] = generation_config\n\n return body\n\n def process_part(self, part, response):\n if \"functionCall\" in part:\n response.add_tool_call(\n llm.ToolCall(\n name=part[\"functionCall\"][\"name\"],\n arguments=part[\"functionCall\"][\"args\"],\n )\n )\n if \"text\" in part:\n return part[\"text\"]\n elif \"executableCode\" in part:\n return f'```{part[\"executableCode\"][\"language\"].lower()}\\n{part[\"executableCode\"][\"code\"].strip()}\\n```\\n'\n elif \"codeExecutionResult\" in part:\n return f'```\\n{part[\"codeExecutionResult\"][\"output\"].strip()}\\n```\\n'\n return \"\"\n\n def process_candidates(self, candidates, response):\n # We only use the first candidate\n for part in candidates[0][\"content\"][\"parts\"]:\n yield self.process_part(part, response)\n\n def set_usage(self, response):\n try:\n # Don't record the \"content\" key from that last candidate\n for candidate in response.response_json[\"candidates\"]:\n candidate.pop(\"content\", None)\n usage = response.response_json.pop(\"usageMetadata\")\n input_tokens = usage.pop(\"promptTokenCount\", None)\n # See https://github.com/simonw/llm-gemini/issues/75#issuecomment-2861827509\n candidates_token_count = usage.get(\"candidatesTokenCount\") or 0\n thoughts_token_count = usage.get(\"thoughtsTokenCount\") or 0\n output_tokens = candidates_token_count + thoughts_token_count\n tool_token_count = usage.get(\"toolUsePromptTokenCount\") or 0\n if tool_token_count:\n if input_tokens is None:\n input_tokens = tool_token_count\n else:\n input_tokens += tool_token_count\n usage.pop(\"totalTokenCount\", None)\n if input_tokens is not None:\n response.set_usage(\n input=input_tokens, output=output_tokens, details=usage or None\n )\n except (IndexError, KeyError):\n pass\n\n\nclass GeminiPro(_SharedGemini, llm.KeyModel):\n def execute(self, prompt, stream, response, conversation, key):\n url = f\"https://generativelanguage.googleapis.com/v1beta/models/{self.gemini_model_id}:streamGenerateContent\"\n gathered = []\n body = self.build_request_body(prompt, conversation)\n\n with httpx.stream(\n \"POST\",\n url,\n timeout=prompt.options.timeout,\n headers={\"x-goog-api-key\": self.get_key(key)},\n json=body,\n ) as http_response:\n events = ijson.sendable_list()\n coro = ijson.items_coro(events, \"item\")\n for chunk in http_response.iter_bytes():\n coro.send(chunk)\n if events:\n for event in events:\n if isinstance(event, dict) and \"error\" in event:\n raise llm.ModelError(event[\"error\"][\"message\"])\n try:\n yield from self.process_candidates(\n event[\"candidates\"], response\n )\n except KeyError:\n yield \"\"\n gathered.append(event)\n events.clear()\n response.response_json = gathered[-1]\n resolved_model = gathered[-1][\"modelVersion\"]\n response.set_resolved_model(resolved_model)\n self.set_usage(response)\n\n\nclass AsyncGeminiPro(_SharedGemini, llm.AsyncKeyModel):\n async def execute(self, prompt, stream, response, conversation, key):\n url = f\"https://generativelanguage.googleapis.com/v1beta/models/{self.gemini_model_id}:streamGenerateContent\"\n gathered = []\n body = self.build_request_body(prompt, conversation)\n\n async with httpx.AsyncClient() as client:\n async with client.stream(\n \"POST\",\n url,\n timeout=prompt.options.timeout,\n headers={\"x-goog-api-key\": self.get_key(key)},\n json=body,\n ) as http_response:\n events = ijson.sendable_list()\n coro = ijson.items_coro(events, \"item\")\n async for chunk in http_response.aiter_bytes():\n coro.send(chunk)\n if events:\n for event in events:\n if isinstance(event, dict) and \"error\" in event:\n raise llm.ModelError(event[\"error\"][\"message\"])\n try:\n for chunk in self.process_candidates(\n event[\"candidates\"], response\n ):\n yield chunk\n except KeyError:\n yield \"\"\n gathered.append(event)\n events.clear()\n response.response_json = gathered[-1]\n self.set_usage(response)\n\n\n@llm.hookimpl\ndef register_embedding_models(register):\n register(GeminiEmbeddingModel(\"text-embedding-004\", \"text-embedding-004\"))\n # gemini-embedding-exp-03-07 in different truncation sizes\n register(\n GeminiEmbeddingModel(\n \"gemini-embedding-exp-03-07\", \"gemini-embedding-exp-03-07\"\n ),\n )\n for i in (128, 256, 512, 1024, 2048):\n register(\n GeminiEmbeddingModel(\n f\"gemini-embedding-exp-03-07-{i}\", f\"gemini-embedding-exp-03-07\", i\n ),\n )\n\n\nclass GeminiEmbeddingModel(llm.EmbeddingModel):\n needs_key = \"gemini\"\n key_env_var = \"LLM_GEMINI_KEY\"\n batch_size = 20\n\n def __init__(self, model_id, gemini_model_id, truncate=None):\n self.model_id = model_id\n self.gemini_model_id = gemini_model_id\n self.truncate = truncate\n\n def embed_batch(self, items):\n headers = {\n \"Content-Type\": \"application/json\",\n \"x-goog-api-key\": self.get_key(),\n }\n data = {\n \"requests\": [\n {\n \"model\": \"models/\" + self.gemini_model_id,\n \"content\": {\"parts\": [{\"text\": item}]},\n }\n for item in items\n ]\n }\n\n with httpx.Client() as client:\n response = client.post(\n f\"https://generativelanguage.googleapis.com/v1beta/models/{self.gemini_model_id}:batchEmbedContents\",\n headers=headers,\n json=data,\n timeout=None,\n )\n\n response.raise_for_status()\n values = [item[\"values\"] for item in response.json()[\"embeddings\"]]\n if self.truncate:\n values = [value[: self.truncate] for value in values]\n return values\n\n\n@llm.hookimpl\ndef register_commands(cli):\n @cli.group()\n def gemini():\n \"Commands relating to the llm-gemini plugin\"\n\n @gemini.command()\n @click.option(\"--key\", help=\"API key to use\")\n @click.option(\n \"methods\",\n \"--method\",\n multiple=True,\n help=\"Filter by supported generation methods\",\n )\n def models(key, methods):\n \"\"\"\n List of Gemini models pulled from their API\n\n Use --method to filter by supported generation methods for example:\n\n llm gemini models --method generateContent --method embedContent\n \"\"\"\n key = llm.get_key(key, \"gemini\", \"LLM_GEMINI_KEY\")\n if not key:\n raise click.ClickException(\n \"You must set the LLM_GEMINI_KEY environment variable or use --key\"\n )\n url = f\"https://generativelanguage.googleapis.com/v1beta/models\"\n response = httpx.get(url, headers={\"x-goog-api-key\": key})\n response.raise_for_status()\n models = response.json()[\"models\"]\n if methods:\n models = [\n model\n for model in models\n if any(\n method in model[\"supportedGenerationMethods\"] for method in methods\n )\n ]\n click.echo(json.dumps(models, indent=2))\n\n @gemini.command()\n @click.option(\"--key\", help=\"API key to use\")\n def files(key):\n \"List of files uploaded to the Gemini API\"\n key = llm.get_key(key, \"gemini\", \"LLM_GEMINI_KEY\")\n response = httpx.get(\n f\"https://generativelanguage.googleapis.com/v1beta/files?key={key}\",\n )\n response.raise_for_status()\n if \"files\" in response.json():\n click.echo(json.dumps(response.json()[\"files\"], indent=2))\n else:\n click.echo(\"No files uploaded to the Gemini API.\", err=True)\n","structuredPatch":[{"oldStart":664,"oldLines":12,"newStart":664,"newLines":13,"lines":[" # See https://ai.google.dev/api/generate-content#MediaResolution for mediaResolution token counts"," if self.can_media_resolution:"," media_resolution = getattr(prompt.options, \"media_resolution\", None)","- if media_resolution is None and has_youtube:","- media_resolution = MediaResolution.LOW","- if media_resolution:","+ # Only set media resolution if explicitly provided or if there's YouTube content","+ if media_resolution is not None:"," generation_config[\"mediaResolution\"] = ("," f\"MEDIA_RESOLUTION_{media_resolution.value.upper()}\""," )","+ elif has_youtube:","+ generation_config[\"mediaResolution\"] = \"MEDIA_RESOLUTION_LOW\""," "," if generation_config:"," body[\"generationConfig\"] = generation_config"]}],"userModified":false,"replaceAll":false}}
{"parentUuid":"ba471fee-a27a-40a4-a54f-d19723405cb2","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Eb24y9Nyixdjay1StC45nJ","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Good, now I need to test this by running the command. Let me run:\n`uv run llm -m gemini-3-pro-preview describe -a https://static.simonwillison.net/static/2025/gemini-3-flash.jpg`\n\nThis will test if the API automatically handles the media resolution when it's not set.","signature":"ErMDCkYIChgCKkCdb5p2s4JRibAaBh+6NaeACFPtwdI8YnQ8Z7afi/D1B8EVYH/gEcDgfDiTtTUFxf3hmjC3sBDK6Qv8/htosgVNEgyjBc4XvCT8t/i2vJ8aDNA7oyEW9RX/8qFm6CIw7fVdWXpnnl3ZbLKCLgNos5n7bQTluV8YLcCDb6MrZvcNeMqZhC8uE03JpBwQtKg3KpoCO/5YjPzVc3XZo1/yFlkv4MaN9W0GmFn5PZiz6VMdPiiB2bWlgZmyEPL7MomqA/shoue0jaQgYyIP9HseTrdPfJ+Gc/UjjE9sFXS9hqniIYfHCS+DjElrLL8uX5CofxrnohGPZpDdCMerxJ5lGjisnl6VNqpGY6AmHMXBbiuBLF+pcvf6acN9b2MJGJOAPiXCeI3wFJN5ycskw4v8HnUHSZ3qZWgnarNKy0VNP+nVE6yLT2YilooNRMPg+qMqfIS4aWDM8HCZVnAku/87lSi4oDypGrjejzmLkKf4cxmwYUEgWEinxTa1E9p3lWPRUWk/Gd7v3Os49tYK+JpJe0xoo449CkjlsdOR3xVHc94gHYLR2omNpu32cv/uGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":13,"cache_creation_input_tokens":810,"cache_read_input_tokens":32752,"cache_creation":{"ephemeral_5m_input_tokens":810,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWEZFik2xxdLQDaJuYGPc","type":"assistant","uuid":"2d9ba502-0290-4bb1-af1e-a7f303f74dde","timestamp":"2025-12-18T17:36:00.424Z"}
{"parentUuid":"2d9ba502-0290-4bb1-af1e-a7f303f74dde","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Eb24y9Nyixdjay1StC45nJ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01X4TkR7pMfYFnBG6jY5Abkg","name":"Bash","input":{"command":"uv run llm -m gemini-3-pro-preview describe -a https://static.simonwillison.net/static/2025/gemini-3-flash.jpg","description":"Test API with no explicit media resolution","timeout":30000}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":13,"cache_creation_input_tokens":810,"cache_read_input_tokens":32752,"cache_creation":{"ephemeral_5m_input_tokens":810,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWEZFik2xxdLQDaJuYGPc","type":"assistant","uuid":"a6716062-e5a4-49bc-8069-3bad6c92415a","timestamp":"2025-12-18T17:36:01.007Z"}
{"parentUuid":"a6716062-e5a4-49bc-8069-3bad6c92415a","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01X4TkR7pMfYFnBG6jY5Abkg","type":"tool_result","content":"This is a minimalist, cartoon-style illustration of a white bird riding a bicycle. Here are the key details:\n\n* **The Subject:** The bird is drawn with simple geometric shapes—a round head, an oval body, and a large, wide yellow beak (resembling that of a duck or pelican). It has a small black dot for an eye and a thin yellow leg extending down to the pedal.\n* **The Bicycle:** The bike is depicted with simple black lines. It has a basic frame, handlebars, and two circular wheels with simple cross-hairs inside them.\n* **Style and Background:** The drawing uses flat line art. The scene rests on a thin grey ground line and is framed by a large, faint blue circle in the background.","is_error":false}]},"uuid":"b3787a37-ffd1-43a0-93e3-dda04c09bdbc","timestamp":"2025-12-18T17:36:17.193Z","toolUseResult":{"stdout":"This is a minimalist, cartoon-style illustration of a white bird riding a bicycle. Here are the key details:\n\n* **The Subject:** The bird is drawn with simple geometric shapes—a round head, an oval body, and a large, wide yellow beak (resembling that of a duck or pelican). It has a small black dot for an eye and a thin yellow leg extending down to the pedal.\n* **The Bicycle:** The bike is depicted with simple black lines. It has a basic frame, handlebars, and two circular wheels with simple cross-hairs inside them.\n* **Style and Background:** The drawing uses flat line art. The scene rests on a thin grey ground line and is framed by a large, faint blue circle in the background.","stderr":"","interrupted":false,"isImage":false}}
{"parentUuid":"b3787a37-ffd1-43a0-93e3-dda04c09bdbc","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01CTBcZ1YgbMLN7XZwW7kY2n","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Great! The API call worked successfully. It seems the API does handle the case where mediaResolution is not set at all. The response came back fine, so the API likely has its own default behavior when the field is omitted.\n\nThis is better because:\n1. It doesn't log a deliberately-chosen media_resolution when the user didn't specify one\n2. The API handles the default automatically\n3. We still explicitly set it to LOW for YouTube videos (since those might need it for longer content)\n\nLet me update the todo list to reflect that we've tested this.","signature":"Es0FCkYIChgCKkD90I+PCfDoQ5u+0N/IM/wuPZVqrhgtQaDlq/GN4VyjnvOKZkNzgJd3pdkTXHCUWdB5qSFzP3X4D6pjLLiLyNn+EgyO4gTKe+o9HuTCzVAaDBr02FJd9M4X7j7SjyIw7LD5QvneAVVo/IfNXyX8vZk2+Bdfrz2KV2WdWtXbh54+UXCw7cZBllGNYSfOdgOnKrQEbhTazn12NMyE5Ej8WLceIkE7586dGnhSMNkj3B/ToqNbqbP1nU7CEv3Vq6GzRSBFJMG2lFeskUDkkQjOHr9UoyxYM+ADJELQlXYYpPBVnuABBj3CPdulQeZIzJpuPWmrCYmjSrzlQRjHEhlT9y6fPlaMP7cYS0GqB0eFEW8RM3ci8ziQUWhQbZ721T60A8uTZ54vepDeLC08+/9CDz0ROLzK6itoNfaEOJ4nB/WdvP5g23NJXRuxhlw75sdGCyymG4eRKyy4cy1zp/ghx7Zb9MVJUm1I5Ng0Xr6BgjrrtOmLP4kpOjHQMwqMirDh9HscLFCpGHJ4uzq7ubZ+4tqLRfdoFc6gYGprcTrq1CoG1bcXVff/t1GheLC00EQ+k2J1XWPYhdl6zTk12nTqPX93Y4vCXNpLQfTjH9AfJ3FBBsUV9k7mH47DWI+YkyAR2+QZvdBCcZjHWOduXBWv/PZBZNy+mIoQfpxebNa9N9T+jJWVuqmQrBhcHBID11ejLLJZS1/gcLLmabeY3lXwD3mZE9Jg/NwGQ/F3Nc4AYqkM1XqHyClus4tlqsSpsQFVOCRlLqjCPP2O49N2IYH+S9Esh5SdlBuWjR0xYrhZ5DS5KmrgVWCljbhuYzLg62JAPsVTaMGWQzWYXiqMkCLdsz46wKcLHOSLkbB/0sO+MBuwXVs3S+X5Q+f3hnjLuyQcg1xYdriVrBmqs+Xa794reheKcjhDeoDyOyC+94Y9SKqb5Q7fXRoHGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":12,"cache_creation_input_tokens":410,"cache_read_input_tokens":33562,"cache_creation":{"ephemeral_5m_input_tokens":410,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWEZH9wd9h8wJRs5AyJUK","type":"assistant","uuid":"19f101f6-5789-4155-b81c-405fbb5cd153","timestamp":"2025-12-18T17:36:20.165Z"}
{"parentUuid":"19f101f6-5789-4155-b81c-405fbb5cd153","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01CTBcZ1YgbMLN7XZwW7kY2n","type":"message","role":"assistant","content":[{"type":"text","text":"Perfect! The API handles it just fine without an explicit media resolution. The request went through successfully and returned the image description without us needing to set a mediaResolution at all.\n\nThe updated code is now cleaner and achieves your goal:\n\n- **Default (None)**: When the user doesn't specify a media resolution, we don't send it to the API at all, so it won't be logged as deliberately chosen\n- **Explicit values**: If the user provides a resolution (low, medium, high, or unspecified), we use it\n- **YouTube fallback**: For YouTube videos, we still use LOW to support longer videos\n\nThis way you avoid polluting the logs with \"LOW\" for users who never specified a resolution preference."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":12,"cache_creation_input_tokens":410,"cache_read_input_tokens":33562,"cache_creation":{"ephemeral_5m_input_tokens":410,"ephemeral_1h_input_tokens":0},"output_tokens":291,"service_tier":"standard"}},"requestId":"req_011CWEZH9wd9h8wJRs5AyJUK","type":"assistant","uuid":"f3a7b6c2-b7db-4ba6-8f39-7972ffbd0f41","timestamp":"2025-12-18T17:36:21.958Z"}
{"type":"file-history-snapshot","messageId":"5eb7c68a-ecc1-4ae0-811a-f078f4625a07","snapshot":{"messageId":"5eb7c68a-ecc1-4ae0-811a-f078f4625a07","trackedFileBackups":{"llm_gemini.py":{"backupFileName":"c970ac0709d7b8fa@v3","version":3,"backupTime":"2025-12-18T17:36:58.968Z"}},"timestamp":"2025-12-18T17:36:58.961Z"},"isSnapshotUpdate":false}
{"parentUuid":"f3a7b6c2-b7db-4ba6-8f39-7972ffbd0f41","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","type":"user","message":{"role":"user","content":"OK try it with this without the YouTube LOW thing setting to see if that is required by the API or not https://www.youtube.com/watch?v=AtlO41RMy9w"},"uuid":"5eb7c68a-ecc1-4ae0-811a-f078f4625a07","timestamp":"2025-12-18T17:36:58.954Z","thinkingMetadata":{"level":"high","disabled":false,"triggers":[]},"todos":[]}
{"parentUuid":"5eb7c68a-ecc1-4ae0-811a-f078f4625a07","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_018GoX6UYcSLuaqMqXfW2E6b","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to test with a YouTube URL to see if the API requires the mediaResolution.LOW setting for YouTube videos, or if it works fine without it. Let me remove the YouTube fallback and test with that YouTube URL.\n\nI should modify the code to completely remove the YouTube fallback, then test it.","signature":"EtoDCkYIChgCKkDwhxs/L/XAjOqSerpc3ZHZEjexVjhB/Jz2V822U+wG5EDCeZfnh8d0+pGNKG+OkfT6u01xtYtvPyqvnqACcPanEgyRC6s1p0IrJyKAJgAaDPxMMHS54ajXssexEiIweC5iZ2PgzoTJFqUHtVSE02WsWv6b5O34J0+XwZeBukYXdx61Vj6E+UI7zv6Tb+FBKsEC2/TjZ+GXxc5yp3Fru/JeYFNbl54UoTJwSWTGRNj4hPbwR+ctwbwjh6amFD32WiYMuakVtTH9zG6vDkBdw54UX0dr+u7LwaxaGYGXRl4GCeNphtyAaaLMoS4EHQ+NJ2GGHQ8i013YAyYTx1JqEEReUeUBb6uGmuIFRPsCHiKsptwlBL1irJXRZjc80R5WK7WwP5/EeMl6g+wAm33IL7rRuHrXTcbzlE8EOlte3SQbohbP9xmFpOp/zFA2kOXjwwYntoe1i2izLisNAc4TLDi9lZOFuHHnA5X60NL54rJw1lqAgcXIDbtHZTdPfL+c4tDbHAsjHHq29qHk14Zzq7byQ/UdW1kZqsbXnC0d+i5VJMG33nz7Vx7qr0xd695aQl70GFxpm7h/I4NU+E4uuhJvtEpGJC/9tsZlmZBWkRpm9wmNGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":46,"cache_read_input_tokens":33911,"cache_creation":{"ephemeral_5m_input_tokens":46,"ephemeral_1h_input_tokens":0},"output_tokens":7,"service_tier":"standard"}},"requestId":"req_011CWEZLERqUGCy5N6Mksw3t","type":"assistant","uuid":"18468b50-bfc0-4b71-bcb1-bb1979849b14","timestamp":"2025-12-18T17:37:01.024Z"}
{"parentUuid":"18468b50-bfc0-4b71-bcb1-bb1979849b14","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_018GoX6UYcSLuaqMqXfW2E6b","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01UEZKuMheAcZAkkkWL6KEEE","name":"Edit","input":{"file_path":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini/llm_gemini.py","old_string":" # See https://ai.google.dev/api/generate-content#MediaResolution for mediaResolution token counts\n if self.can_media_resolution:\n media_resolution = getattr(prompt.options, \"media_resolution\", None)\n # Only set media resolution if explicitly provided or if there's YouTube content\n if media_resolution is not None:\n generation_config[\"mediaResolution\"] = (\n f\"MEDIA_RESOLUTION_{media_resolution.value.upper()}\"\n )\n elif has_youtube:\n generation_config[\"mediaResolution\"] = \"MEDIA_RESOLUTION_LOW\"","new_string":" # See https://ai.google.dev/api/generate-content#MediaResolution for mediaResolution token counts\n if self.can_media_resolution:\n media_resolution = getattr(prompt.options, \"media_resolution\", None)\n if media_resolution is not None:\n generation_config[\"mediaResolution\"] = (\n f\"MEDIA_RESOLUTION_{media_resolution.value.upper()}\"\n )"}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":46,"cache_read_input_tokens":33911,"cache_creation":{"ephemeral_5m_input_tokens":46,"ephemeral_1h_input_tokens":0},"output_tokens":436,"service_tier":"standard"}},"requestId":"req_011CWEZLERqUGCy5N6Mksw3t","type":"assistant","uuid":"83b17d91-da10-4e5c-b7b8-dad70ce4e76a","timestamp":"2025-12-18T17:37:03.361Z"}
{"parentUuid":"83b17d91-da10-4e5c-b7b8-dad70ce4e76a","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01UEZKuMheAcZAkkkWL6KEEE","type":"tool_result","content":"The file /Users/simon/Dropbox/dev/ecosystem/llm-gemini/llm_gemini.py has been updated. Here's the result of running `cat -n` on a snippet of the edited file:\n 660→ for attachment in response.attachments\n 661→ )\n 662→ )\n 663→\n 664→ # See https://ai.google.dev/api/generate-content#MediaResolution for mediaResolution token counts\n 665→ if self.can_media_resolution:\n 666→ media_resolution = getattr(prompt.options, \"media_resolution\", None)\n 667→ if media_resolution is not None:\n 668→ generation_config[\"mediaResolution\"] = (\n 669→ f\"MEDIA_RESOLUTION_{media_resolution.value.upper()}\"\n 670→ )\n 671→\n 672→ if generation_config:\n 673→ body[\"generationConfig\"] = generation_config\n 674→"}]},"uuid":"e256cbb0-20fa-4a77-a763-0989a0fe7317","timestamp":"2025-12-18T17:37:03.403Z","toolUseResult":{"filePath":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini/llm_gemini.py","oldString":" # See https://ai.google.dev/api/generate-content#MediaResolution for mediaResolution token counts\n if self.can_media_resolution:\n media_resolution = getattr(prompt.options, \"media_resolution\", None)\n # Only set media resolution if explicitly provided or if there's YouTube content\n if media_resolution is not None:\n generation_config[\"mediaResolution\"] = (\n f\"MEDIA_RESOLUTION_{media_resolution.value.upper()}\"\n )\n elif has_youtube:\n generation_config[\"mediaResolution\"] = \"MEDIA_RESOLUTION_LOW\"","newString":" # See https://ai.google.dev/api/generate-content#MediaResolution for mediaResolution token counts\n if self.can_media_resolution:\n media_resolution = getattr(prompt.options, \"media_resolution\", None)\n if media_resolution is not None:\n generation_config[\"mediaResolution\"] = (\n f\"MEDIA_RESOLUTION_{media_resolution.value.upper()}\"\n )","originalFile":"import click\nimport copy\nfrom enum import Enum\nimport httpx\nimport ijson\nimport json\nimport llm\nimport re\nfrom pydantic import Field, create_model\nfrom typing import Optional\n\nSAFETY_SETTINGS = [\n {\n \"category\": \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n \"threshold\": \"BLOCK_NONE\",\n },\n {\n \"category\": \"HARM_CATEGORY_SEXUALLY_EXPLICIT\",\n \"threshold\": \"BLOCK_NONE\",\n },\n {\n \"category\": \"HARM_CATEGORY_HATE_SPEECH\",\n \"threshold\": \"BLOCK_NONE\",\n },\n {\n \"category\": \"HARM_CATEGORY_HARASSMENT\",\n \"threshold\": \"BLOCK_NONE\",\n },\n]\n\n# https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/ground-gemini#supported_models_2\nGOOGLE_SEARCH_MODELS = {\n \"gemini-1.5-pro-latest\",\n \"gemini-1.5-flash-latest\",\n \"gemini-1.5-pro-001\",\n \"gemini-1.5-flash-001\",\n \"gemini-1.5-pro-002\",\n \"gemini-1.5-flash-002\",\n \"gemini-2.0-flash-exp\",\n \"gemini-2.0-flash\",\n \"gemini-2.5-pro-preview-03-25\",\n \"gemini-2.5-pro-exp-03-25\",\n \"gemini-2.5-flash-preview-04-17\",\n \"gemini-2.5-pro-preview-05-06\",\n \"gemini-2.5-flash-preview-05-20\",\n \"gemini-2.5-pro-preview-06-05\",\n \"gemini-2.5-pro\",\n \"gemini-2.5-flash\",\n \"gemini-2.5-flash-lite\",\n \"gemini-flash-latest\",\n \"gemini-flash-lite-latest\",\n \"gemini-2.5-flash-preview-09-2025\",\n \"gemini-2.5-flash-lite-preview-09-2025\",\n \"gemini-3-pro-preview\",\n \"gemini-3-flash-preview\",\n}\n\n# Older Google models used google_search_retrieval instead of google_search\nGOOGLE_SEARCH_MODELS_USING_SEARCH_RETRIEVAL = {\n \"gemini-1.5-pro-latest\",\n \"gemini-1.5-flash-latest\",\n \"gemini-1.5-pro-001\",\n \"gemini-1.5-flash-001\",\n \"gemini-1.5-pro-002\",\n \"gemini-1.5-flash-002\",\n \"gemini-2.0-flash-exp\",\n}\n\nTHINKING_BUDGET_MODELS = {\n \"gemini-2.0-flash-thinking-exp-01-21\",\n \"gemini-2.0-flash-thinking-exp-1219\",\n \"gemini-2.5-flash-preview-04-17\",\n \"gemini-2.5-pro-exp-03-25\",\n \"gemini-2.5-pro-preview-03-25\",\n \"gemini-2.5-pro-preview-05-06\",\n \"gemini-2.5-flash-preview-05-20\",\n \"gemini-2.5-pro-preview-06-05\",\n \"gemini-2.5-pro\",\n \"gemini-2.5-flash\",\n \"gemini-2.5-flash-lite\",\n \"gemini-flash-latest\",\n \"gemini-flash-lite-latest\",\n \"gemini-2.5-flash-preview-09-2025\",\n \"gemini-2.5-flash-lite-preview-09-2025\",\n}\n\n# Model-specific thinking levels - models not listed here don't support thinking_level\nMODEL_THINKING_LEVELS = {\n \"gemini-3-flash-preview\": [\"minimal\", \"low\", \"medium\", \"high\"],\n \"gemini-3-pro-preview\": [\"low\", \"high\"],\n}\n\nNO_VISION_MODELS = {\"gemma-3-1b-it\", \"gemma-3n-e4b-it\"}\n\nNO_MEDIA_RESOLUTION_MODELS = {\n \"gemma-3-1b-it\",\n \"gemma-3-4b-it\",\n \"gemma-3-12b-it\",\n \"gemma-3-27b-it\",\n \"gemma-3n-e4b-it\",\n}\n\n\nclass MediaResolution(str, Enum):\n \"\"\"Allowed media resolution values for Gemini models.\"\"\"\n\n LOW = \"low\"\n MEDIUM = \"medium\"\n HIGH = \"high\"\n UNSPECIFIED = \"unspecified\"\n\n\nATTACHMENT_TYPES = {\n # Text\n \"text/plain\",\n \"text/csv\",\n \"text/html; charset=utf-8\",\n # PDF\n \"application/pdf\",\n # Images\n \"image/png\",\n \"image/jpeg\",\n \"image/webp\",\n \"image/heic\",\n \"image/heif\",\n # Audio\n \"audio/wav\",\n \"audio/mp3\",\n \"audio/aiff\",\n \"audio/aac\",\n \"audio/ogg\",\n \"application/ogg\",\n \"audio/flac\",\n \"audio/mpeg\", # Treated as audio/mp3\n # Video\n \"video/mp4\",\n \"video/mpeg\",\n \"video/mov\",\n \"video/avi\",\n \"video/x-flv\",\n \"video/mpg\",\n \"video/webm\",\n \"video/wmv\",\n \"video/3gpp\",\n \"video/quicktime\",\n \"video/youtube\",\n}\n\n\n@llm.hookimpl\ndef register_models(register):\n # Register both sync and async versions of each model\n for model_id in (\n \"gemini-pro\",\n \"gemini-1.5-pro-latest\",\n \"gemini-1.5-flash-latest\",\n \"gemini-1.5-pro-001\",\n \"gemini-1.5-flash-001\",\n \"gemini-1.5-pro-002\",\n \"gemini-1.5-flash-002\",\n \"gemini-1.5-flash-8b-latest\",\n \"gemini-1.5-flash-8b-001\",\n \"gemini-exp-1114\",\n \"gemini-exp-1121\",\n \"gemini-exp-1206\",\n \"gemini-2.0-flash-exp\",\n \"learnlm-1.5-pro-experimental\",\n # Gemma 3 models:\n \"gemma-3-1b-it\",\n \"gemma-3-4b-it\",\n \"gemma-3-12b-it\", # 12th March 2025\n \"gemma-3-27b-it\",\n \"gemma-3n-e4b-it\", # 20th May 2025\n \"gemini-2.0-flash-thinking-exp-1219\",\n \"gemini-2.0-flash-thinking-exp-01-21\",\n # Released 5th Feb 2025:\n \"gemini-2.0-flash\",\n \"gemini-2.0-pro-exp-02-05\",\n # Released 25th Feb 2025:\n \"gemini-2.0-flash-lite\",\n # 25th March 2025:\n \"gemini-2.5-pro-exp-03-25\",\n # 4th April 2025 (paid):\n \"gemini-2.5-pro-preview-03-25\",\n # 17th April 2025:\n \"gemini-2.5-flash-preview-04-17\",\n # 6th May 2025:\n \"gemini-2.5-pro-preview-05-06\",\n # 20th May 2025:\n \"gemini-2.5-flash-preview-05-20\",\n # 5th June 2025:\n \"gemini-2.5-pro-preview-06-05\",\n \"gemini-2.5-flash\",\n \"gemini-2.5-pro\",\n # 22nd July 2025:\n \"gemini-2.5-flash-lite\",\n # 25th Spetember 2025:\n \"gemini-flash-latest\",\n \"gemini-flash-lite-latest\",\n \"gemini-2.5-flash-preview-09-2025\",\n \"gemini-2.5-flash-lite-preview-09-2025\",\n # 18th November 2025:\n \"gemini-3-pro-preview\",\n # 17th December 2025:\n \"gemini-3-flash-preview\",\n ):\n can_google_search = model_id in GOOGLE_SEARCH_MODELS\n can_thinking_budget = model_id in THINKING_BUDGET_MODELS\n thinking_levels = MODEL_THINKING_LEVELS.get(model_id)\n can_vision = model_id not in NO_VISION_MODELS\n can_schema = \"flash-thinking\" not in model_id and \"gemma-3\" not in model_id\n can_media_resolution = model_id not in NO_MEDIA_RESOLUTION_MODELS\n register(\n GeminiPro(\n model_id,\n can_vision=can_vision,\n can_google_search=can_google_search,\n can_thinking_budget=can_thinking_budget,\n thinking_levels=thinking_levels,\n can_schema=can_schema,\n can_media_resolution=can_media_resolution,\n ),\n AsyncGeminiPro(\n model_id,\n can_vision=can_vision,\n can_google_search=can_google_search,\n can_thinking_budget=can_thinking_budget,\n thinking_levels=thinking_levels,\n can_schema=can_schema,\n can_media_resolution=can_media_resolution,\n ),\n aliases=(model_id,),\n )\n\n\ndef resolve_type(attachment):\n mime_type = attachment.resolve_type()\n # https://github.com/simonw/llm/issues/587#issuecomment-2439785140\n if mime_type == \"audio/mpeg\":\n mime_type = \"audio/mp3\"\n if mime_type == \"application/ogg\":\n mime_type = \"audio/ogg\"\n # Check if this is a YouTube URL\n if attachment.url and is_youtube_url(attachment.url):\n return \"video/youtube\"\n return mime_type\n\n\ndef is_youtube_url(url):\n \"\"\"Check if a URL is a YouTube video URL\"\"\"\n if not url:\n return False\n youtube_patterns = [\n r\"^https?://(www\\.)?youtube\\.com/watch\\?v=\",\n r\"^https?://youtu\\.be/\",\n r\"^https?://(www\\.)?youtube\\.com/embed/\",\n ]\n return any(re.match(pattern, url) for pattern in youtube_patterns)\n\n\ndef cleanup_schema(schema, in_properties=False):\n \"Gemini supports only a subset of JSON schema\"\n keys_to_remove = (\"$schema\", \"additionalProperties\", \"title\")\n\n # First pass: resolve $ref references using $defs\n if isinstance(schema, dict) and \"$defs\" in schema:\n defs = schema.pop(\"$defs\")\n _resolve_refs(schema, defs)\n\n if isinstance(schema, dict):\n # Only remove keys if we're not inside a 'properties' block.\n if not in_properties:\n for key in keys_to_remove:\n schema.pop(key, None)\n for key, value in list(schema.items()):\n # If the key is 'properties', set the flag for its value.\n if key == \"properties\" and isinstance(value, dict):\n cleanup_schema(value, in_properties=True)\n else:\n cleanup_schema(value, in_properties=in_properties)\n elif isinstance(schema, list):\n for item in schema:\n cleanup_schema(item, in_properties=in_properties)\n return schema\n\n\ndef _resolve_refs(schema, defs, expansion_stack=None):\n \"\"\"Recursively resolve $ref references in schema using definitions.\n\n Args:\n schema: The schema to resolve references in\n defs: Dictionary of definitions from $defs\n expansion_stack: List tracking the order of definition expansions (for cycle detection)\n\n Raises:\n ValueError: If a recursive self-reference is detected\n \"\"\"\n if expansion_stack is None:\n expansion_stack = []\n\n if isinstance(schema, dict):\n if \"$ref\" in schema:\n # Extract the reference path (e.g., \"#/$defs/Dog\" -> \"Dog\")\n ref_path = schema.pop(\"$ref\")\n if ref_path.startswith(\"#/$defs/\"):\n def_name = ref_path.split(\"/\")[-1]\n if def_name in defs:\n # Check for recursive reference\n if def_name in expansion_stack:\n # Determine if this is direct or indirect recursion\n if expansion_stack[-1] == def_name:\n # Direct recursion: the definition references itself\n raise ValueError(\n f\"Recursive schema detected: '{def_name}' directly references itself. \"\n f\"The Gemini API does not support recursive Pydantic models. \"\n f\"Please use a non-recursive schema structure.\"\n )\n else:\n # Indirect recursion: the class being referenced is earlier in the stack\n # The last item in expansion_stack is the immediate class that references def_name\n intermediate = expansion_stack[-1]\n raise ValueError(\n f\"Recursive schema detected: '{def_name}' indirectly references itself through '{intermediate}'. \"\n f\"The Gemini API does not support recursive Pydantic models. \"\n f\"Please use a non-recursive schema structure.\"\n )\n\n # Add this definition to the expansion stack\n expansion_stack.append(def_name)\n # Replace the $ref with the actual definition\n resolved = copy.deepcopy(defs[def_name])\n schema.update(resolved)\n # Resolve any nested refs in the newly added definition\n _resolve_refs(schema, defs, expansion_stack)\n # Remove from stack after processing\n expansion_stack.pop()\n return\n\n # Recursively resolve refs in nested structures\n for value in schema.values():\n _resolve_refs(value, defs, expansion_stack)\n elif isinstance(schema, list):\n for item in schema:\n _resolve_refs(item, defs, expansion_stack)\n\n\nclass _SharedGemini:\n needs_key = \"gemini\"\n key_env_var = \"LLM_GEMINI_KEY\"\n can_stream = True\n supports_schema = True\n supports_tools = True\n\n attachment_types = set()\n\n class Options(llm.Options):\n code_execution: Optional[bool] = Field(\n description=\"Enables the model to generate and run Python code\",\n default=None,\n )\n temperature: Optional[float] = Field(\n description=(\n \"Controls the randomness of the output. Use higher values for \"\n \"more creative responses, and lower values for more \"\n \"deterministic responses.\"\n ),\n default=None,\n ge=0.0,\n le=2.0,\n )\n max_output_tokens: Optional[int] = Field(\n description=\"Sets the maximum number of tokens to include in a candidate.\",\n default=None,\n )\n top_p: Optional[float] = Field(\n description=(\n \"Changes how the model selects tokens for output. Tokens are \"\n \"selected from the most to least probable until the sum of \"\n \"their probabilities equals the topP value.\"\n ),\n default=None,\n ge=0.0,\n le=1.0,\n )\n top_k: Optional[int] = Field(\n description=(\n \"Changes how the model selects tokens for output. A topK of 1 \"\n \"means the selected token is the most probable among all the \"\n \"tokens in the model's vocabulary, while a topK of 3 means \"\n \"that the next token is selected from among the 3 most \"\n \"probable using the temperature.\"\n ),\n default=None,\n ge=1,\n )\n json_object: Optional[bool] = Field(\n description=\"Output a valid JSON object {...}\",\n default=None,\n )\n timeout: Optional[float] = Field(\n description=(\n \"The maximum time in seconds to wait for a response. \"\n \"If the model does not respond within this time, \"\n \"the request will be aborted.\"\n ),\n default=None,\n )\n url_context: Optional[bool] = Field(\n description=(\n \"Enable the URL context tool so the model can fetch content \"\n \"from URLs mentioned in the prompt\"\n ),\n default=None,\n )\n\n def __init__(\n self,\n gemini_model_id,\n can_vision=True,\n can_google_search=False,\n can_thinking_budget=False,\n thinking_levels=None,\n can_schema=False,\n can_media_resolution=True,\n ):\n self.model_id = \"gemini/{}\".format(gemini_model_id)\n self.gemini_model_id = gemini_model_id\n self.can_google_search = can_google_search\n self.supports_schema = can_schema\n self.can_thinking_budget = can_thinking_budget\n self.thinking_levels = thinking_levels\n self.can_media_resolution = can_media_resolution\n\n # Build Options class dynamically based on capabilities\n extra_fields = {}\n\n if can_google_search:\n extra_fields[\"google_search\"] = (\n Optional[bool],\n Field(\n description=\"Enables the model to use Google Search to improve the accuracy and recency of responses from the model\",\n default=None,\n ),\n )\n\n if can_thinking_budget:\n extra_fields[\"thinking_budget\"] = (\n Optional[int],\n Field(\n description=\"Indicates the thinking budget in tokens. Set to 0 to disable.\",\n default=None,\n ),\n )\n\n if thinking_levels:\n # Create a dynamic enum with only the supported levels\n ThinkingLevelEnum = Enum(\n \"ThinkingLevel\",\n {level.upper(): level for level in thinking_levels},\n type=str,\n )\n level_choices = \", \".join(f\"'{level}'\" for level in thinking_levels)\n extra_fields[\"thinking_level\"] = (\n Optional[ThinkingLevelEnum],\n Field(\n description=f\"Indicates the thinking level. Can be {level_choices}.\",\n default=None,\n ),\n )\n\n if can_media_resolution:\n extra_fields[\"media_resolution\"] = (\n Optional[MediaResolution],\n Field(\n description=(\n \"Media resolution for the input media (esp. YouTube) \"\n \"- default is low, other values are medium, high, or unspecified\"\n ),\n default=None,\n ),\n )\n\n if extra_fields:\n self.Options = create_model(\n \"Options\",\n __base__=self.Options,\n **extra_fields,\n )\n\n if can_vision:\n self.attachment_types = ATTACHMENT_TYPES\n\n def build_messages(self, prompt, conversation):\n messages = []\n if conversation:\n for response in conversation.responses:\n parts = []\n for attachment in response.attachments:\n mime_type = resolve_type(attachment)\n parts.append(self._build_attachment_part(attachment, mime_type))\n if response.prompt.prompt:\n parts.append({\"text\": response.prompt.prompt})\n if response.prompt.tool_results:\n parts.extend(\n [\n {\n \"function_response\": {\n \"name\": tool_result.name,\n \"response\": {\n \"output\": tool_result.output,\n },\n }\n }\n for tool_result in response.prompt.tool_results\n ]\n )\n messages.append({\"role\": \"user\", \"parts\": parts})\n model_parts = []\n response_text = response.text_or_raise()\n model_parts.append({\"text\": response_text})\n tool_calls = response.tool_calls_or_raise()\n if tool_calls:\n model_parts.extend(\n [\n {\n \"function_call\": {\n \"name\": tool_call.name,\n \"args\": tool_call.arguments,\n }\n }\n for tool_call in tool_calls\n ]\n )\n messages.append({\"role\": \"model\", \"parts\": model_parts})\n\n parts = []\n if prompt.prompt:\n parts.append({\"text\": prompt.prompt})\n if prompt.tool_results:\n parts.extend(\n [\n {\n \"function_response\": {\n \"name\": tool_result.name,\n \"response\": {\n \"output\": tool_result.output,\n },\n }\n }\n for tool_result in prompt.tool_results\n ]\n )\n for attachment in prompt.attachments:\n mime_type = resolve_type(attachment)\n parts.append(self._build_attachment_part(attachment, mime_type))\n\n messages.append({\"role\": \"user\", \"parts\": parts})\n return messages\n\n def _build_attachment_part(self, attachment, mime_type):\n \"\"\"Build the appropriate part for an attachment based on its type\"\"\"\n if mime_type == \"video/youtube\":\n return {\"fileData\": {\"mimeType\": mime_type, \"fileUri\": attachment.url}}\n else:\n return {\n \"inlineData\": {\n \"data\": attachment.base64_content(),\n \"mimeType\": mime_type,\n }\n }\n\n def build_request_body(self, prompt, conversation):\n body = {\n \"contents\": self.build_messages(prompt, conversation),\n \"safetySettings\": SAFETY_SETTINGS,\n }\n if prompt.system:\n body[\"systemInstruction\"] = {\"parts\": [{\"text\": prompt.system}]}\n\n tools = []\n if prompt.options and prompt.options.code_execution:\n tools.append({\"codeExecution\": {}})\n if prompt.options and self.can_google_search and prompt.options.google_search:\n tool_name = (\n \"google_search_retrieval\"\n if self.model_id in GOOGLE_SEARCH_MODELS_USING_SEARCH_RETRIEVAL\n else \"google_search\"\n )\n tools.append({tool_name: {}})\n if prompt.options and prompt.options.url_context:\n tools.append({\"url_context\": {}})\n if prompt.tools:\n tools.append(\n {\n \"functionDeclarations\": [\n {\n \"name\": tool.name,\n \"description\": tool.description,\n \"parameters\": cleanup_schema(\n copy.deepcopy(tool.input_schema)\n ),\n }\n for tool in prompt.tools\n ]\n }\n )\n if tools:\n body[\"tools\"] = tools\n\n generation_config = {}\n\n if prompt.schema:\n generation_config.update(\n {\n \"response_mime_type\": \"application/json\",\n \"response_schema\": cleanup_schema(copy.deepcopy(prompt.schema)),\n }\n )\n\n if self.can_thinking_budget and prompt.options.thinking_budget is not None:\n generation_config[\"thinking_config\"] = {\n \"thinking_budget\": prompt.options.thinking_budget\n }\n\n if (\n self.thinking_levels\n and getattr(prompt.options, \"thinking_level\", None) is not None\n ):\n # Get the string value from the enum\n thinking_level = prompt.options.thinking_level\n if hasattr(thinking_level, \"value\"):\n thinking_level = thinking_level.value\n generation_config[\"thinkingConfig\"] = {\"thinkingLevel\": thinking_level}\n\n config_map = {\n \"temperature\": \"temperature\",\n \"max_output_tokens\": \"maxOutputTokens\",\n \"top_p\": \"topP\",\n \"top_k\": \"topK\",\n }\n if prompt.options and prompt.options.json_object:\n generation_config[\"response_mime_type\"] = \"application/json\"\n\n if any(\n getattr(prompt.options, key, None) is not None for key in config_map.keys()\n ):\n for key, other_key in config_map.items():\n config_value = getattr(prompt.options, key, None)\n if config_value is not None:\n generation_config[other_key] = config_value\n\n has_youtube = any(\n attachment.url and is_youtube_url(attachment.url)\n for attachment in prompt.attachments\n ) or (\n conversation\n and any(\n attachment.url and is_youtube_url(attachment.url)\n for response in conversation.responses\n for attachment in response.attachments\n )\n )\n\n # See https://ai.google.dev/api/generate-content#MediaResolution for mediaResolution token counts\n if self.can_media_resolution:\n media_resolution = getattr(prompt.options, \"media_resolution\", None)\n # Only set media resolution if explicitly provided or if there's YouTube content\n if media_resolution is not None:\n generation_config[\"mediaResolution\"] = (\n f\"MEDIA_RESOLUTION_{media_resolution.value.upper()}\"\n )\n elif has_youtube:\n generation_config[\"mediaResolution\"] = \"MEDIA_RESOLUTION_LOW\"\n\n if generation_config:\n body[\"generationConfig\"] = generation_config\n\n return body\n\n def process_part(self, part, response):\n if \"functionCall\" in part:\n response.add_tool_call(\n llm.ToolCall(\n name=part[\"functionCall\"][\"name\"],\n arguments=part[\"functionCall\"][\"args\"],\n )\n )\n if \"text\" in part:\n return part[\"text\"]\n elif \"executableCode\" in part:\n return f'```{part[\"executableCode\"][\"language\"].lower()}\\n{part[\"executableCode\"][\"code\"].strip()}\\n```\\n'\n elif \"codeExecutionResult\" in part:\n return f'```\\n{part[\"codeExecutionResult\"][\"output\"].strip()}\\n```\\n'\n return \"\"\n\n def process_candidates(self, candidates, response):\n # We only use the first candidate\n for part in candidates[0][\"content\"][\"parts\"]:\n yield self.process_part(part, response)\n\n def set_usage(self, response):\n try:\n # Don't record the \"content\" key from that last candidate\n for candidate in response.response_json[\"candidates\"]:\n candidate.pop(\"content\", None)\n usage = response.response_json.pop(\"usageMetadata\")\n input_tokens = usage.pop(\"promptTokenCount\", None)\n # See https://github.com/simonw/llm-gemini/issues/75#issuecomment-2861827509\n candidates_token_count = usage.get(\"candidatesTokenCount\") or 0\n thoughts_token_count = usage.get(\"thoughtsTokenCount\") or 0\n output_tokens = candidates_token_count + thoughts_token_count\n tool_token_count = usage.get(\"toolUsePromptTokenCount\") or 0\n if tool_token_count:\n if input_tokens is None:\n input_tokens = tool_token_count\n else:\n input_tokens += tool_token_count\n usage.pop(\"totalTokenCount\", None)\n if input_tokens is not None:\n response.set_usage(\n input=input_tokens, output=output_tokens, details=usage or None\n )\n except (IndexError, KeyError):\n pass\n\n\nclass GeminiPro(_SharedGemini, llm.KeyModel):\n def execute(self, prompt, stream, response, conversation, key):\n url = f\"https://generativelanguage.googleapis.com/v1beta/models/{self.gemini_model_id}:streamGenerateContent\"\n gathered = []\n body = self.build_request_body(prompt, conversation)\n\n with httpx.stream(\n \"POST\",\n url,\n timeout=prompt.options.timeout,\n headers={\"x-goog-api-key\": self.get_key(key)},\n json=body,\n ) as http_response:\n events = ijson.sendable_list()\n coro = ijson.items_coro(events, \"item\")\n for chunk in http_response.iter_bytes():\n coro.send(chunk)\n if events:\n for event in events:\n if isinstance(event, dict) and \"error\" in event:\n raise llm.ModelError(event[\"error\"][\"message\"])\n try:\n yield from self.process_candidates(\n event[\"candidates\"], response\n )\n except KeyError:\n yield \"\"\n gathered.append(event)\n events.clear()\n response.response_json = gathered[-1]\n resolved_model = gathered[-1][\"modelVersion\"]\n response.set_resolved_model(resolved_model)\n self.set_usage(response)\n\n\nclass AsyncGeminiPro(_SharedGemini, llm.AsyncKeyModel):\n async def execute(self, prompt, stream, response, conversation, key):\n url = f\"https://generativelanguage.googleapis.com/v1beta/models/{self.gemini_model_id}:streamGenerateContent\"\n gathered = []\n body = self.build_request_body(prompt, conversation)\n\n async with httpx.AsyncClient() as client:\n async with client.stream(\n \"POST\",\n url,\n timeout=prompt.options.timeout,\n headers={\"x-goog-api-key\": self.get_key(key)},\n json=body,\n ) as http_response:\n events = ijson.sendable_list()\n coro = ijson.items_coro(events, \"item\")\n async for chunk in http_response.aiter_bytes():\n coro.send(chunk)\n if events:\n for event in events:\n if isinstance(event, dict) and \"error\" in event:\n raise llm.ModelError(event[\"error\"][\"message\"])\n try:\n for chunk in self.process_candidates(\n event[\"candidates\"], response\n ):\n yield chunk\n except KeyError:\n yield \"\"\n gathered.append(event)\n events.clear()\n response.response_json = gathered[-1]\n self.set_usage(response)\n\n\n@llm.hookimpl\ndef register_embedding_models(register):\n register(GeminiEmbeddingModel(\"text-embedding-004\", \"text-embedding-004\"))\n # gemini-embedding-exp-03-07 in different truncation sizes\n register(\n GeminiEmbeddingModel(\n \"gemini-embedding-exp-03-07\", \"gemini-embedding-exp-03-07\"\n ),\n )\n for i in (128, 256, 512, 1024, 2048):\n register(\n GeminiEmbeddingModel(\n f\"gemini-embedding-exp-03-07-{i}\", f\"gemini-embedding-exp-03-07\", i\n ),\n )\n\n\nclass GeminiEmbeddingModel(llm.EmbeddingModel):\n needs_key = \"gemini\"\n key_env_var = \"LLM_GEMINI_KEY\"\n batch_size = 20\n\n def __init__(self, model_id, gemini_model_id, truncate=None):\n self.model_id = model_id\n self.gemini_model_id = gemini_model_id\n self.truncate = truncate\n\n def embed_batch(self, items):\n headers = {\n \"Content-Type\": \"application/json\",\n \"x-goog-api-key\": self.get_key(),\n }\n data = {\n \"requests\": [\n {\n \"model\": \"models/\" + self.gemini_model_id,\n \"content\": {\"parts\": [{\"text\": item}]},\n }\n for item in items\n ]\n }\n\n with httpx.Client() as client:\n response = client.post(\n f\"https://generativelanguage.googleapis.com/v1beta/models/{self.gemini_model_id}:batchEmbedContents\",\n headers=headers,\n json=data,\n timeout=None,\n )\n\n response.raise_for_status()\n values = [item[\"values\"] for item in response.json()[\"embeddings\"]]\n if self.truncate:\n values = [value[: self.truncate] for value in values]\n return values\n\n\n@llm.hookimpl\ndef register_commands(cli):\n @cli.group()\n def gemini():\n \"Commands relating to the llm-gemini plugin\"\n\n @gemini.command()\n @click.option(\"--key\", help=\"API key to use\")\n @click.option(\n \"methods\",\n \"--method\",\n multiple=True,\n help=\"Filter by supported generation methods\",\n )\n def models(key, methods):\n \"\"\"\n List of Gemini models pulled from their API\n\n Use --method to filter by supported generation methods for example:\n\n llm gemini models --method generateContent --method embedContent\n \"\"\"\n key = llm.get_key(key, \"gemini\", \"LLM_GEMINI_KEY\")\n if not key:\n raise click.ClickException(\n \"You must set the LLM_GEMINI_KEY environment variable or use --key\"\n )\n url = f\"https://generativelanguage.googleapis.com/v1beta/models\"\n response = httpx.get(url, headers={\"x-goog-api-key\": key})\n response.raise_for_status()\n models = response.json()[\"models\"]\n if methods:\n models = [\n model\n for model in models\n if any(\n method in model[\"supportedGenerationMethods\"] for method in methods\n )\n ]\n click.echo(json.dumps(models, indent=2))\n\n @gemini.command()\n @click.option(\"--key\", help=\"API key to use\")\n def files(key):\n \"List of files uploaded to the Gemini API\"\n key = llm.get_key(key, \"gemini\", \"LLM_GEMINI_KEY\")\n response = httpx.get(\n f\"https://generativelanguage.googleapis.com/v1beta/files?key={key}\",\n )\n response.raise_for_status()\n if \"files\" in response.json():\n click.echo(json.dumps(response.json()[\"files\"], indent=2))\n else:\n click.echo(\"No files uploaded to the Gemini API.\", err=True)\n","structuredPatch":[{"oldStart":664,"oldLines":13,"newStart":664,"newLines":10,"lines":[" # See https://ai.google.dev/api/generate-content#MediaResolution for mediaResolution token counts"," if self.can_media_resolution:"," media_resolution = getattr(prompt.options, \"media_resolution\", None)","- # Only set media resolution if explicitly provided or if there's YouTube content"," if media_resolution is not None:"," generation_config[\"mediaResolution\"] = ("," f\"MEDIA_RESOLUTION_{media_resolution.value.upper()}\""," )","- elif has_youtube:","- generation_config[\"mediaResolution\"] = \"MEDIA_RESOLUTION_LOW\""," "," if generation_config:"," body[\"generationConfig\"] = generation_config"]}],"userModified":false,"replaceAll":false}}
{"parentUuid":"e256cbb0-20fa-4a77-a763-0989a0fe7317","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01ARBnyQ2XkLAPoReegsMNnP","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now let me test with the YouTube URL to see if the API works without specifying a media resolution for YouTube videos.","signature":"Ep8CCkYIChgCKkCj2RGTKTmvu6lw9bZ3e9dQ09vc7YKl1l+VeohvdXN94lRUDW/pG3X3l2EFZj1F70ZHEqpWLOtKWCMRZVDHf8GEEgx5412uIe5k7yN3HegaDGIlUKTgmCgZ675Z8yIwG7E37iiE36Qo7szgk3osgoDAlGAZJgsVy7aT+NMIdtLKpuhV8ATKZj0VOHdWwN1cKoYBaQmVeKAEZHdzHKaB3O71g4ADBBG6rCXXmLIG+ztKaZGp0sQ1YBc2mPzPv3ROChHzA/28VYo/7RMNAknXAd5Ug2XJAk8QIKYstzGUYWsLa7Sb35/pB0N3M/3h7NUXwWILVIOJboGpRh59rwp73WTkeZdl/7zmQsGZDtF/jWB2OkEmHKSW6OkYAQ=="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":13,"cache_creation_input_tokens":688,"cache_read_input_tokens":33957,"cache_creation":{"ephemeral_5m_input_tokens":688,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard"}},"requestId":"req_011CWEZLZ8EUGNkQd4mtQ6VD","type":"assistant","uuid":"84d87f36-0693-4c45-8e05-0f4c1a6b1f9c","timestamp":"2025-12-18T17:37:05.323Z"}
{"parentUuid":"84d87f36-0693-4c45-8e05-0f4c1a6b1f9c","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01ARBnyQ2XkLAPoReegsMNnP","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01ABDTDK6yuRfq2h5xkFwo5V","name":"Bash","input":{"command":"uv run llm -m gemini-3-pro-preview describe -a https://www.youtube.com/watch?v=AtlO41RMy9w","description":"Test YouTube video without explicit media resolution","timeout":60000}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":13,"cache_creation_input_tokens":688,"cache_read_input_tokens":33957,"cache_creation":{"ephemeral_5m_input_tokens":688,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard"}},"requestId":"req_011CWEZLZ8EUGNkQd4mtQ6VD","type":"assistant","uuid":"b2d72c65-a3cf-46f6-b811-6562a54ce62e","timestamp":"2025-12-18T17:37:06.281Z"}
{"parentUuid":"b2d72c65-a3cf-46f6-b811-6562a54ce62e","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"Exit code 1\n(eval):1: no matches found: https://www.youtube.com/watch?v=AtlO41RMy9w","is_error":true,"tool_use_id":"toolu_01ABDTDK6yuRfq2h5xkFwo5V"}]},"uuid":"6960529b-82e3-4a06-a2d3-0d62960eb3d9","timestamp":"2025-12-18T17:37:08.612Z","toolUseResult":"Error: Exit code 1\n(eval):1: no matches found: https://www.youtube.com/watch?v=AtlO41RMy9w"}
{"parentUuid":"6960529b-82e3-4a06-a2d3-0d62960eb3d9","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01QS6WHe7ZjBQuNg4xEe7dwz","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The URL has special characters that are being interpreted by the shell. I need to properly quote it. Let me try with single quotes or escaping.","signature":"ErgCCkYIChgCKkAvpet+f+DFyyKfJcsrWa+w0/4iXiTlBQIpT+tW7ph4G/uRoey/KBR/9LfIKhi9+NB7CqrUI51Y1FxjUF8td0ktEgyz/UJAFvTVy7EFlpIaDBg3zyJJclAlfnoTIiIwAz91tV5xGAqeL2+T9TfX0KKhQu1W3FSoK8DV9Vb/WVMS50CDdrvLfoAP8etSoz96Kp8Bh6g6jtcVAH+fqONyVscFN4s2WHY3hPjW9qv/Cn6acR0sRxvXOklKY0zS+i+ZKGoFE4L7fhKyiWS2nuMg6n6d+B7O5W859XRq3Z3beydRrVm6oC8AgY0NodueVvd9QVflz6/7MLLwjE4gCdCI0QO+vvTJnn4tkXx4HMCrq88aBSP7Wg+5NWHmVeIxfqw+Umq3NbuPRtlmWOok57/3xN9rGAE="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":14,"cache_creation_input_tokens":327,"cache_read_input_tokens":34645,"cache_creation":{"ephemeral_5m_input_tokens":327,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWEZLw8Jts4SKK9KtUMZr","type":"assistant","uuid":"8e49c03c-4d20-4173-b389-a26ceb65541e","timestamp":"2025-12-18T17:37:10.567Z"}
{"parentUuid":"8e49c03c-4d20-4173-b389-a26ceb65541e","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01QS6WHe7ZjBQuNg4xEe7dwz","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01QY88iPSTExfPzEqv3a9Sbz","name":"Bash","input":{"command":"uv run llm -m gemini-3-pro-preview describe -a 'https://www.youtube.com/watch?v=AtlO41RMy9w'","description":"Test YouTube video without explicit media resolution","timeout":60000}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":14,"cache_creation_input_tokens":327,"cache_read_input_tokens":34645,"cache_creation":{"ephemeral_5m_input_tokens":327,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}},"requestId":"req_011CWEZLw8Jts4SKK9KtUMZr","type":"assistant","uuid":"89d36645-622d-4e3b-abaf-1916ed8922f9","timestamp":"2025-12-18T17:37:10.896Z"}
{"parentUuid":"89d36645-622d-4e3b-abaf-1916ed8922f9","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01QY88iPSTExfPzEqv3a9Sbz","type":"tool_result","content":"A red double-decker bus, modified with gigantic muscular arms attached to its sides, is parked on a raised platform in an urban setting. The bus, labeled \"London Booster 2012,\" is situated in front of a building covered in ivy, with signs pointing to \"Czech House\" and the \"Hilton Business Design Centre.\"\n\nAt the 0:43 mark, the bus begins to move. Using the large mechanical red arms, which end in flat hands pressed against the pavement, the bus pushes its front end high into the air. It performs a series of push-ups, lifting its chassis to a near 45-degree angle while its rear wheels remain grounded. The machine emits mechanical groaning sounds, mimicking the strain of a workout. Pedestrians walking by on the sidewalk stop to watch and take photos of the spectacle from behind a black metal fence. The bus continues to perform repetitions of these mechanical push-ups for the remainder of the video.","is_error":false}]},"uuid":"4ba76966-0ffd-439b-8677-8a911a102142","timestamp":"2025-12-18T17:37:51.178Z","toolUseResult":{"stdout":"A red double-decker bus, modified with gigantic muscular arms attached to its sides, is parked on a raised platform in an urban setting. The bus, labeled \"London Booster 2012,\" is situated in front of a building covered in ivy, with signs pointing to \"Czech House\" and the \"Hilton Business Design Centre.\"\n\nAt the 0:43 mark, the bus begins to move. Using the large mechanical red arms, which end in flat hands pressed against the pavement, the bus pushes its front end high into the air. It performs a series of push-ups, lifting its chassis to a near 45-degree angle while its rear wheels remain grounded. The machine emits mechanical groaning sounds, mimicking the strain of a workout. Pedestrians walking by on the sidewalk stop to watch and take photos of the spectacle from behind a black metal fence. The bus continues to perform repetitions of these mechanical push-ups for the remainder of the video.","stderr":"","interrupted":false,"isImage":false}}
{"parentUuid":"4ba76966-0ffd-439b-8677-8a911a102142","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01K46LR3vMcGqogJCdSz3gQH","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Great! The YouTube video worked fine without specifying a media resolution. The API doesn't require it to be set - it handles YouTube videos perfectly well without it. So we can keep the code as is, with no media resolution setting required unless the user explicitly provides one.\n\nThis is exactly what the user wanted - now the logs won't show a bunch of records with \"LOW\" media resolution when users didn't actually specify one.","signature":"EtkECkYIChgCKkCdakJl5gSEx7uMyXBhaWlunPi2aKqE6TtsSwfrO8/+QSdJEPeOl8xByoUiNKXApVI3gk4z3Islvsg9zOFG2y+CEgz3r8t6UsuQEcC/cAQaDNt2SOESG0P/OykX2SIwXmGipvfzTOc6CXGGX3BYUIGvHd3WjpGeb2HdYJSMi4kMpFn9WEOVpqIjsGbEfAPyKsADaGhMbw7E5Ob0spnWc/I3UY13Gp6+iQSHc9NKoek7+jNIxldl1RxXSIu6MHCTKwh68cWpDCNF9x+GHrbpwQc6NjefnNxXAV8fWeyPdjrJuP018s1nC5BJvgLdx5GanwsNOqocVo+PsTZQYkPQQUavD8GxNTx/PosyCDZFiapEpTc8+IUJwZWlQdszWI6B5HX4fLwKQ+UJ+ANyBh8Zwsjlhb1OTPdcGBwF+CF3kJsNlLRCPObfBVd156FcEvJ88b9K8Z27DyFOc5tedJpKGefwN3QOIDDuOLeFa5+Ni39jshGyxdu6SA+3LgrJZEw3aywhqz23SjgZ7gn2pirsC53s21689PGyhnWpigjflZo0yqFimscg8CcqXk4yGrkvVQm5TTC8jaqCRL6fBHTc2ceF5ROra5eLPzOZy6OSKuG+TywFKXoZp4+GubZeAp2mTGlbr1Gq0VjKNt0RWOwSReE9ayBIIIaWEOts/n2nUv3tsP2/mm5SyFhL3t9y2Yj8wWcThv6DTpebT9NCgeqhIqM1tE8scU60XbUKZGr0nBC5wZyjgfpMxKtwNsB6ITYP6v7kvqWKSwPPlSx3x7fcCvffrhgB"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":12,"cache_creation_input_tokens":403,"cache_read_input_tokens":34972,"cache_creation":{"ephemeral_5m_input_tokens":403,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard"}},"requestId":"req_011CWEZQ5i5RLGXRBFsehgGz","type":"assistant","uuid":"d3b56a85-d255-4b57-aeb8-c078ca84bffd","timestamp":"2025-12-18T17:37:54.166Z"}
{"parentUuid":"d3b56a85-d255-4b57-aeb8-c078ca84bffd","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01K46LR3vMcGqogJCdSz3gQH","type":"message","role":"assistant","content":[{"type":"text","text":"Excellent! The YouTube video worked perfectly without any explicit media resolution setting. The API handles it just fine on its own. \n\nSo the final code is clean and simple:\n- If the user doesn't specify a media resolution, we don't send one to the API\n- If they explicitly set one, we use it\n- The API defaults appropriately for all media types, including YouTube\n\nThis means your logs will now show `None` (or no media_resolution) for users who didn't specify one, rather than falsely showing `LOW` as a deliberate choice."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":12,"cache_creation_input_tokens":403,"cache_read_input_tokens":34972,"cache_creation":{"ephemeral_5m_input_tokens":403,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard"}},"requestId":"req_011CWEZQ5i5RLGXRBFsehgGz","type":"assistant","uuid":"1ddcd789-7c92-4336-b15d-7652087a1fea","timestamp":"2025-12-18T17:37:55.582Z"}
{"type":"file-history-snapshot","messageId":"1a283685-0905-42ab-b29d-3eeb29618a81","snapshot":{"messageId":"1a283685-0905-42ab-b29d-3eeb29618a81","trackedFileBackups":{"llm_gemini.py":{"backupFileName":"c970ac0709d7b8fa@v4","version":4,"backupTime":"2025-12-18T17:41:34.295Z"}},"timestamp":"2025-12-18T17:41:34.288Z"},"isSnapshotUpdate":false}
{"parentUuid":"1ddcd789-7c92-4336-b15d-7652087a1fea","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","type":"user","message":{"role":"user","content":"Do not run them but construct me a list of uv run llm commands I can execute that will run gemini-3-flash"},"uuid":"1a283685-0905-42ab-b29d-3eeb29618a81","timestamp":"2025-12-18T17:41:34.282Z","thinkingMetadata":{"level":"high","disabled":false,"triggers":[]},"todos":[]}
{"parentUuid":"1a283685-0905-42ab-b29d-3eeb29618a81","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","type":"user","message":{"role":"user","content":[{"type":"text","text":"[Request interrupted by user]"}]},"uuid":"9221f149-eea3-4d49-9036-adc4770652cf","timestamp":"2025-12-18T17:41:35.910Z"}
{"type":"file-history-snapshot","messageId":"c357ccfa-1f6f-408a-8e37-47606eac05ef","snapshot":{"messageId":"c357ccfa-1f6f-408a-8e37-47606eac05ef","trackedFileBackups":{"llm_gemini.py":{"backupFileName":"c970ac0709d7b8fa@v4","version":4,"backupTime":"2025-12-18T17:41:34.295Z"}},"timestamp":"2025-12-18T17:42:00.214Z"},"isSnapshotUpdate":false}
{"parentUuid":"9221f149-eea3-4d49-9036-adc4770652cf","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","type":"user","message":{"role":"user","content":"Do not run them but construct me a list of uv run llm commands I can execute that will run gemini-3-flash-preview first with just hi, then with describe that image URL, then describe that image URL with high media resolution, then both without and with high resolution for the youtube link"},"uuid":"c357ccfa-1f6f-408a-8e37-47606eac05ef","timestamp":"2025-12-18T17:42:00.208Z","thinkingMetadata":{"level":"high","disabled":false,"triggers":[]},"todos":[]}
{"parentUuid":"c357ccfa-1f6f-408a-8e37-47606eac05ef","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Y9hDcpx4TtWTs4QU6ZUV39","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to construct a list of uv run llm commands for testing gemini-3-flash-preview. They want:\n1. Just \"hi\" \n2. Describe the image URL\n3. Describe the image URL with high media resolution\n4. YouTube link without high resolution\n5. YouTube link with high resolution\n\nThe image URL is: https://static.simonwillison.net/static/2025/gemini-3-flash.jpg\nThe YouTube URL is: https://www.youtube.com/watch?v=AtlO41RMy9w\n\nFor media resolution, I need to pass the option. Looking at the code, it should be something like `--media-resolution high` or similar. Let me think about the llm CLI. Options are typically passed with `--option-name value`. So it would likely be `--media_resolution high` or `--media-resolution high`.\n\nLet me construct these:\n1. `uv run llm -m gemini-3-flash-preview 'hi'`\n2. `uv run llm -m gemini-3-flash-preview describe -a https://static.simonwillison.net/static/2025/gemini-3-flash.jpg`\n3. `uv run llm -m gemini-3-flash-preview describe -a https://static.simonwillison.net/static/2025/gemini-3-flash.jpg --media-resolution high`\n4. `uv run llm -m gemini-3-flash-preview describe -a 'https://www.youtube.com/watch?v=AtlO41RMy9w'`\n5. `uv run llm -m gemini-3-flash-preview describe -a 'https://www.youtube.com/watch?v=AtlO41RMy9w' --media-resolution high`\n\nWait, I need to check how options are passed in llm. \n\nI see that CLI parameter conventions typically convert underscores to dashes. This means the option will likely use the dash-separated format for command-line interfaces, confirming the `--media-resolution` syntax I initially considered.","signature":"EoANCkYIChgCKkAjMy25SpLguKK+cEmp0GgsB+IsSPsTqNSd6wnEx18GvwReZ+FmX9CbG028446saPo1dwngtReTED0P939cbi7bEgwXtY6ViSKQL9VesfIaDBUHIXtlJYG6fKrfPyIwM2GT2AJbjIam7Q9o72ctwRf5ItfaOB5Ll8vBZaOGIi++gRYourWhiFdb9JiX1Jx8KucLh6jRd/5IbVuSYA1K34NB/UbNWvgljXJMDbYQc6SepGiJvWd2y8D7HOa5U2ngDEkrdrEl8zv1E3ny6vs4opDdH6WJYQ5xBJRVWnYB+61iJUylFBjOIzNuR+0vFuIz65GgJc333iNUXl+QLQtfrtMMUqaK08qt1UTpXDCd7XlPhzp/kHuqfwg4+ATOyAv6tRMKD6lIpY6m12UnYT2i7GVHTDZnm8lYrdzQubn/3EZYTGA2w2mexXjM5EAKJfwsPB3P+woVtZ4RrpJWtC83/yZfry9Qgmmm33iUgWS35wzYPRkX5NwfwZpjJioj7H0yFISmlSP+1mY+h4GxN9kPx2FPt/9qPkaq8z1+twBbOWitoeOYU1BZBf6AiLppt5MmWyD2X61QSt+7P6Cxwe6Zdgs/n83NElJWR5e0TWGWg8u3WIs+K8bTAT2mIiHFErEDxXxM0nG//s5WG918KAxlrPoEuwrNkD2Evlf/Py4DC7aVRGoi763J72uvGjbFFycDpTRXbICgySE7tyRAF4Dfa2ujT5ylelXC0k0CDKOcFUjkaGStAOfN5u3VOwMOQ2ARnkpaPnGOj4xfHEisRXAkfKjp/Koh+pxIn+e7mIIKwyIWqYq6cyCf+dCGf5J9ewV1MU+LGYg8d4cO0jgHJlhTMgjRhTZSlzNH4I3UiTw8AXqmds3KjI+aAdslowSSpudo+sD/M5hrsR1Y6njnYztV12gJ3N6g0VB89iViwm6CD4g6+0OwiDVaA/YY3xbWyPn9Oj17gKRJ+LG6RlfHcs9Coc6jvPD4bPcpFRzDcQB32QEFDbc3O/7/e9ZZwDzNUL9zXl5HDUmm51d+2uVO5hp0t5cDpeYHXvQ+8dk66icJIBo+t3ONshRdtjhvQFKRfgUrdhT+0T0ZVpZVrvZfkD8ZeFqDWSkGDcUd3hZDMgtATRXSauOQVzMPFVGDpJ4yM0pWTkPtT+RZmkQ7OKrUs+EZQIECvjNjbIiaziLk5yNTG5uK69Qe7ASsuopCF4HUpSn+4Qsy41JtntJmFj3c50fzNNAQJFg63oJCVhkfVgbq8/HySQEUyWqW6eq3ITJmTRdUuxGuWZzyqPmqnmlHc7Bz+YiPu5MG3HBKs2OlsA2ndOY5uf9ZijkrHUSLUMTzezFLcsp4VAstumwvOgoooxi9AkQcLcaVL5zGV2oWiW0OnXr48jbT6yuNGHnZxarFDrpdJ5WZaVgHXCblkv8RfgrxJMOZFtULEoSfjLq9mwwGMlXV2eUOsM9vLUmXAh++il51Myhfla3ClTMgC/a8KJwqS/m+yxwYO4kAcKBNn3Ej8Ts23jg5rdwDhaIcds7UIZLSI71JKvffKMs8XHpyYkrShdC6njy5AYZdm7uPLm/8fkXKSL33aPUEjj4LM1JfiPV98mkrPVGkyE6GSd1ro7WOBN96EbzEr7UXLC7AQqxWkwnY7UssxnEM/ALHTOAmWzv8WAVuQgsJuCEThrAWBYVZwNE4T/EDhkczpiQBuATlOnp11gWUpX+2/BipsLF7jZ6Vl/QNvnur+WLNLXj8N0ZFxXcWhg2D/jiXOmu8dh4m6gVFZ61K/6m9ocZY2q9uWGt2PYgpDLhBRkgO6KoZB99o8+1qhbZIBBP0uv8QQoDlBoVyRof7wBuJzmwL/Wj4IznS5XH747wfXNNZM834HNKy0PwxJ71eBqB6KmoIrvjAkh88Y1YQ1K7il6cDPx4CYkbEjx0pdwBM0N0UqkNSnPY9tyMdxh5+m83dPiMcSJkReqzpiUaH6+UqmFy4S11BbwCJH2rN8KcZXO7CQ1d/SzTv+pJWSD2mI9Nbsa/LmUbXa/n6ZiDkXzOsyNiMMr/zFlzTJpVT+fNzh10XLy+22gnv18OvQEuvktJzscPnJHfuGmsEDfAySdoS5kiwpzAACNjHmSjv42xPDupWEVvajRhKcqCtWAiq4mtoGOZp565/DY+ZeZedVV9LKGQ0YqUbjEuXdILGd7jexQCGCdum01wxpE4hOJ6+muUCdlgYAQ=="}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":69,"cache_read_input_tokens":35367,"cache_creation":{"ephemeral_5m_input_tokens":69,"ephemeral_1h_input_tokens":0},"output_tokens":8,"service_tier":"standard"}},"requestId":"req_011CWEZiSTVw56sivHjUdjEc","type":"assistant","uuid":"fe032b92-76fd-4019-8f18-eb2fb8c9d2a0","timestamp":"2025-12-18T17:42:07.481Z"}
{"parentUuid":"fe032b92-76fd-4019-8f18-eb2fb8c9d2a0","isSidechain":false,"userType":"external","cwd":"/Users/simon/Dropbox/dev/ecosystem/llm-gemini","sessionId":"f2e931f9-8b7b-4282-90df-49a51cfa96a3","version":"2.0.72","gitBranch":"main","slug":"glistening-sprouting-kurzweil","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Y9hDcpx4TtWTs4QU6ZUV39","type":"message","role":"assistant","content":[{"type":"text","text":"Here's a list of `uv run llm` commands for gemini-3-flash-preview testing:\n\n```bash\n# 1. Simple prompt\nuv run llm -m gemini-3-flash-preview 'hi'\n\n# 2. Describe image without explicit media resolution\nuv run llm -m gemini-3-flash-preview describe -a https://static.simonwillison.net/static/2025/gemini-3-flash.jpg\n\n# 3. Describe image with high media resolution\nuv run llm -m gemini-3-flash-preview describe -a https://static.simonwillison.net/static/2025/gemini-3-flash.jpg --media-resolution high\n\n# 4. Describe YouTube video without explicit media resolution\nuv run llm -m gemini-3-flash-preview describe -a 'https://www.youtube.com/watch?v=AtlO41RMy9w'\n\n# 5. Describe YouTube video with high media resolution\nuv run llm -m gemini-3-flash-preview describe -a 'https://www.youtube.com/watch?v=AtlO41RMy9w' --media-resolution high\n```"}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":69,"cache_read_input_tokens":35367,"cache_creation":{"ephemeral_5m_input_tokens":69,"ephemeral_1h_input_tokens":0},"output_tokens":8,"service_tier":"standard"}},"requestId":"req_011CWEZiSTVw56sivHjUdjEc","type":"assistant","uuid":"37da532a-54b3-40fe-ac1b-ced46d04ee1c","timestamp":"2025-12-18T17:42:07.605Z"}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment