botrun-flow-lang 5.12.263__py3-none-any.whl → 6.2.21__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. botrun_flow_lang/api/auth_api.py +39 -39
  2. botrun_flow_lang/api/auth_utils.py +183 -183
  3. botrun_flow_lang/api/botrun_back_api.py +65 -65
  4. botrun_flow_lang/api/flow_api.py +3 -3
  5. botrun_flow_lang/api/hatch_api.py +508 -508
  6. botrun_flow_lang/api/langgraph_api.py +816 -811
  7. botrun_flow_lang/api/langgraph_constants.py +11 -0
  8. botrun_flow_lang/api/line_bot_api.py +1484 -1484
  9. botrun_flow_lang/api/model_api.py +300 -300
  10. botrun_flow_lang/api/rate_limit_api.py +32 -32
  11. botrun_flow_lang/api/routes.py +79 -79
  12. botrun_flow_lang/api/search_api.py +53 -53
  13. botrun_flow_lang/api/storage_api.py +395 -395
  14. botrun_flow_lang/api/subsidy_api.py +290 -290
  15. botrun_flow_lang/api/subsidy_api_system_prompt.txt +109 -109
  16. botrun_flow_lang/api/user_setting_api.py +70 -70
  17. botrun_flow_lang/api/version_api.py +31 -31
  18. botrun_flow_lang/api/youtube_api.py +26 -26
  19. botrun_flow_lang/constants.py +13 -13
  20. botrun_flow_lang/langgraph_agents/agents/agent_runner.py +178 -178
  21. botrun_flow_lang/langgraph_agents/agents/agent_tools/step_planner.py +77 -77
  22. botrun_flow_lang/langgraph_agents/agents/checkpointer/firestore_checkpointer.py +666 -666
  23. botrun_flow_lang/langgraph_agents/agents/gov_researcher/GOV_RESEARCHER_PRD.md +192 -192
  24. botrun_flow_lang/langgraph_agents/agents/gov_researcher/gemini_subsidy_graph.py +460 -460
  25. botrun_flow_lang/langgraph_agents/agents/gov_researcher/gov_researcher_2_graph.py +1002 -1002
  26. botrun_flow_lang/langgraph_agents/agents/gov_researcher/gov_researcher_graph.py +822 -822
  27. botrun_flow_lang/langgraph_agents/agents/langgraph_react_agent.py +730 -723
  28. botrun_flow_lang/langgraph_agents/agents/search_agent_graph.py +864 -864
  29. botrun_flow_lang/langgraph_agents/agents/tools/__init__.py +4 -4
  30. botrun_flow_lang/langgraph_agents/agents/tools/gemini_code_execution.py +376 -376
  31. botrun_flow_lang/langgraph_agents/agents/util/gemini_grounding.py +66 -66
  32. botrun_flow_lang/langgraph_agents/agents/util/html_util.py +316 -316
  33. botrun_flow_lang/langgraph_agents/agents/util/img_util.py +336 -294
  34. botrun_flow_lang/langgraph_agents/agents/util/local_files.py +419 -419
  35. botrun_flow_lang/langgraph_agents/agents/util/mermaid_util.py +86 -86
  36. botrun_flow_lang/langgraph_agents/agents/util/model_utils.py +143 -143
  37. botrun_flow_lang/langgraph_agents/agents/util/pdf_analyzer.py +562 -486
  38. botrun_flow_lang/langgraph_agents/agents/util/pdf_cache.py +250 -250
  39. botrun_flow_lang/langgraph_agents/agents/util/pdf_processor.py +204 -204
  40. botrun_flow_lang/langgraph_agents/agents/util/perplexity_search.py +464 -464
  41. botrun_flow_lang/langgraph_agents/agents/util/plotly_util.py +59 -59
  42. botrun_flow_lang/langgraph_agents/agents/util/tavily_search.py +199 -199
  43. botrun_flow_lang/langgraph_agents/agents/util/usage_metadata.py +34 -0
  44. botrun_flow_lang/langgraph_agents/agents/util/youtube_util.py +90 -90
  45. botrun_flow_lang/langgraph_agents/cache/langgraph_botrun_cache.py +197 -197
  46. botrun_flow_lang/llm_agent/llm_agent.py +19 -19
  47. botrun_flow_lang/llm_agent/llm_agent_util.py +83 -83
  48. botrun_flow_lang/log/.gitignore +2 -2
  49. botrun_flow_lang/main.py +61 -61
  50. botrun_flow_lang/main_fast.py +51 -51
  51. botrun_flow_lang/mcp_server/__init__.py +10 -10
  52. botrun_flow_lang/mcp_server/default_mcp.py +854 -744
  53. botrun_flow_lang/models/nodes/utils.py +205 -205
  54. botrun_flow_lang/models/token_usage.py +34 -34
  55. botrun_flow_lang/requirements.txt +21 -21
  56. botrun_flow_lang/services/base/firestore_base.py +30 -30
  57. botrun_flow_lang/services/hatch/hatch_factory.py +11 -11
  58. botrun_flow_lang/services/hatch/hatch_fs_store.py +419 -419
  59. botrun_flow_lang/services/storage/storage_cs_store.py +206 -206
  60. botrun_flow_lang/services/storage/storage_factory.py +12 -12
  61. botrun_flow_lang/services/storage/storage_store.py +65 -65
  62. botrun_flow_lang/services/user_setting/user_setting_factory.py +9 -9
  63. botrun_flow_lang/services/user_setting/user_setting_fs_store.py +66 -66
  64. botrun_flow_lang/static/docs/tools/index.html +926 -926
  65. botrun_flow_lang/tests/api_functional_tests.py +1525 -1525
  66. botrun_flow_lang/tests/api_stress_test.py +357 -357
  67. botrun_flow_lang/tests/shared_hatch_tests.py +333 -333
  68. botrun_flow_lang/tests/test_botrun_app.py +46 -46
  69. botrun_flow_lang/tests/test_html_util.py +31 -31
  70. botrun_flow_lang/tests/test_img_analyzer.py +190 -190
  71. botrun_flow_lang/tests/test_img_util.py +39 -39
  72. botrun_flow_lang/tests/test_local_files.py +114 -114
  73. botrun_flow_lang/tests/test_mermaid_util.py +103 -103
  74. botrun_flow_lang/tests/test_pdf_analyzer.py +104 -104
  75. botrun_flow_lang/tests/test_plotly_util.py +151 -151
  76. botrun_flow_lang/tests/test_run_workflow_engine.py +65 -65
  77. botrun_flow_lang/tools/generate_docs.py +133 -133
  78. botrun_flow_lang/tools/templates/tools.html +153 -153
  79. botrun_flow_lang/utils/__init__.py +7 -7
  80. botrun_flow_lang/utils/botrun_logger.py +344 -344
  81. botrun_flow_lang/utils/clients/rate_limit_client.py +209 -209
  82. botrun_flow_lang/utils/clients/token_verify_client.py +153 -153
  83. botrun_flow_lang/utils/google_drive_utils.py +654 -654
  84. botrun_flow_lang/utils/langchain_utils.py +324 -324
  85. botrun_flow_lang/utils/yaml_utils.py +9 -9
  86. {botrun_flow_lang-5.12.263.dist-info → botrun_flow_lang-6.2.21.dist-info}/METADATA +6 -6
  87. botrun_flow_lang-6.2.21.dist-info/RECORD +104 -0
  88. botrun_flow_lang-5.12.263.dist-info/RECORD +0 -102
  89. {botrun_flow_lang-5.12.263.dist-info → botrun_flow_lang-6.2.21.dist-info}/WHEEL +0 -0
@@ -1,294 +1,336 @@
1
- import anthropic
2
- import base64
3
- import httpx
4
- import os
5
- import imghdr
6
- from pathlib import Path
7
- from dotenv import load_dotenv
8
-
9
- load_dotenv()
10
-
11
-
12
- def get_img_content_type(file_path: str | Path) -> str:
13
- """
14
- Get the content type (MIME type) of a local file.
15
- This function checks the actual image format rather than relying on file extension.
16
-
17
- Args:
18
- file_path: Path to the local file (can be string or Path object)
19
-
20
- Returns:
21
- str: The content type of the file (e.g., 'image/jpeg', 'image/png')
22
-
23
- Raises:
24
- FileNotFoundError: If the file does not exist
25
- ValueError: If the file type is not recognized or not supported
26
- """
27
- if not os.path.exists(file_path):
28
- raise FileNotFoundError(f"File not found: {file_path}")
29
-
30
- # Check actual image type using imghdr
31
- img_type = imghdr.what(file_path)
32
- if not img_type:
33
- raise ValueError(f"File is not a recognized image format: {file_path}")
34
-
35
- # Map image type to MIME type
36
- mime_types = {
37
- "jpeg": "image/jpeg",
38
- "jpg": "image/jpeg",
39
- "png": "image/png",
40
- "gif": "image/gif",
41
- "webp": "image/webp",
42
- }
43
-
44
- content_type = mime_types.get(img_type.lower())
45
- if not content_type:
46
- raise ValueError(f"Unsupported image format '{img_type}': {file_path}")
47
-
48
- return content_type
49
-
50
-
51
- def analyze_imgs_with_claude(
52
- img_urls: list[str], user_input: str, model_name: str = "claude-sonnet-4-5-20250929"
53
- ) -> str:
54
- """
55
- Analyze multiple images using Claude Vision API
56
-
57
- Args:
58
- img_urls: List of URLs to the image files
59
- user_input: User's query about the image content(s)
60
- model_name: Claude model name to use
61
-
62
- Returns:
63
- str: Claude's analysis of the image content(s) based on the query
64
-
65
- Raises:
66
- ValueError: If image URLs are invalid or model parameters are incorrect
67
- anthropic.APIError: If there's an error with the Claude API
68
- Exception: For other errors during processing
69
- """
70
- # Initialize message content
71
- message_content = []
72
-
73
- # Download and encode each image file from URLs
74
- with httpx.Client(follow_redirects=True) as client:
75
- for img_url in img_urls:
76
- response = client.get(img_url)
77
- if response.status_code != 200:
78
- raise ValueError(f"Failed to download image from URL: {img_url}")
79
-
80
- # Detect content type from response headers
81
- content_type = response.headers.get("content-type", "")
82
- if not content_type.startswith("image/"):
83
- raise ValueError(f"URL does not point to a valid image: {img_url}")
84
-
85
- # Check file size (5MB limit for API)
86
- if len(response.content) > 5 * 1024 * 1024:
87
- raise ValueError(f"Image file size exceeds 5MB limit: {img_url}")
88
-
89
- # Encode image data
90
- img_data = base64.standard_b64encode(response.content).decode("utf-8")
91
-
92
- # Add image to message content
93
- message_content.append(
94
- {
95
- "type": "image",
96
- "source": {
97
- "type": "base64",
98
- "media_type": content_type,
99
- "data": img_data,
100
- },
101
- }
102
- )
103
-
104
- # Add user input text
105
- message_content.append({"type": "text", "text": user_input})
106
-
107
- # Initialize Anthropic client
108
- client = anthropic.Anthropic()
109
-
110
- try:
111
- # Send to Claude
112
- message = client.messages.create(
113
- model=model_name,
114
- max_tokens=1024,
115
- messages=[
116
- {
117
- "role": "user",
118
- "content": message_content,
119
- }
120
- ],
121
- )
122
-
123
- print(
124
- f"analyze_imgs_with_claude============> input_token: {message.usage.input_tokens} output_token: {message.usage.output_tokens}",
125
- )
126
- return message.content[0].text
127
- except anthropic.APIError as e:
128
- import traceback
129
-
130
- traceback.print_exc()
131
- raise anthropic.APIError(
132
- f"Claude API error with model {model_name}: {str(e)}"
133
- )
134
- except Exception as e:
135
- import traceback
136
-
137
- traceback.print_exc()
138
- raise Exception(
139
- f"Error analyzing image(s) with Claude {model_name}: {str(e)}"
140
- )
141
-
142
-
143
- def analyze_imgs_with_gemini(
144
- img_urls: list[str],
145
- user_input: str,
146
- model_name: str = "gemini-2.5-flash",
147
- ) -> str:
148
- """
149
- Analyze multiple images using Gemini Vision API
150
-
151
- Args:
152
- img_urls: List of URLs to the image files
153
- user_input: User's query about the image content(s)
154
- model_name: Gemini model name to use
155
-
156
- Returns:
157
- str: Gemini's analysis of the image content(s) based on the query
158
-
159
- Raises:
160
- ValueError: If image URLs are invalid or model parameters are incorrect
161
- Exception: For errors during API calls or other processing
162
- """
163
- # 放到要用的時候才 import,不然loading 會花時間
164
- from google import genai
165
- from google.genai.types import HttpOptions, Part
166
- from google.oauth2 import service_account
167
-
168
- # Initialize the Gemini client
169
- api_key = os.getenv("GEMINI_API_KEY", "")
170
- if not api_key:
171
- raise ValueError("GEMINI_API_KEY environment variable not set")
172
-
173
- # 設定 API 金鑰
174
-
175
- try:
176
- # 初始化模型並準備內容列表
177
- credentials = service_account.Credentials.from_service_account_file(
178
- os.getenv("GOOGLE_APPLICATION_CREDENTIALS_FOR_FASTAPI"),
179
- scopes=["https://www.googleapis.com/auth/cloud-platform"],
180
- )
181
-
182
- client = genai.Client(
183
- credentials=credentials,
184
- project="scoop-386004",
185
- location="us-central1",
186
- )
187
- contents = [user_input]
188
-
189
- # 下載並處理每個圖片
190
- with httpx.Client(follow_redirects=True) as http_client:
191
- for img_url in img_urls:
192
- response = http_client.get(img_url)
193
- if response.status_code != 200:
194
- raise ValueError(f"Failed to download image from URL: {img_url}")
195
-
196
- # 檢測內容類型
197
- content_type = response.headers.get("content-type", "")
198
- if not content_type.startswith("image/"):
199
- raise ValueError(f"URL does not point to a valid image: {img_url}")
200
-
201
- # 檢查檔案大小
202
- if len(response.content) > 20 * 1024 * 1024: # 20MB 限制
203
- raise ValueError(f"Image file size too large: {img_url}")
204
-
205
- # 將圖片添加到內容中
206
- contents.append(
207
- Part.from_bytes(
208
- data=response.content,
209
- mime_type=content_type,
210
- )
211
- )
212
-
213
- # 使用 genai 生成內容
214
- response = client.models.generate_content(
215
- model=model_name,
216
- contents=contents,
217
- )
218
-
219
- print(
220
- f"analyze_imgs_with_gemini============> input_token: {response.usage_metadata.prompt_token_count} output_token: {response.usage_metadata.candidates_token_count}"
221
- )
222
- return response.text
223
-
224
- except httpx.RequestError as e:
225
- import traceback
226
-
227
- traceback.print_exc()
228
- raise ValueError(f"Failed to download image(s): {str(e)}")
229
- except Exception as e:
230
- import traceback
231
-
232
- traceback.print_exc()
233
- raise Exception(f"Error analyzing image(s) with Gemini {model_name}: {str(e)}")
234
-
235
-
236
- def analyze_imgs(img_urls: list[str], user_input: str) -> str:
237
- """
238
- Analyze multiple images using configured AI models.
239
-
240
- Uses models specified in IMG_ANALYZER_MODEL environment variable.
241
- When multiple models are specified (comma-separated), tries them in order
242
- until one succeeds, falling back to next model if a model fails.
243
-
244
- Example: IMG_ANALYZER_MODEL=claude-3-7-sonnet-latest,gemini-2.0-flash
245
-
246
- Args:
247
- img_urls: List of URLs to the image files
248
- user_input: User's query about the image content(s)
249
-
250
- Returns:
251
- str: AI analysis of the image content(s) based on the query
252
- """
253
- # Get models from environment variable, split by comma if multiple models
254
- models_str = os.getenv("IMG_ANALYZER_MODEL", "gemini-2.5-flash")
255
- print(f"[analyze_imgs] 分析IMG使用模型: {models_str}")
256
- models = models_str.split(",")
257
-
258
- # Remove whitespace around model names
259
- models = [model.strip() for model in models]
260
- print(f"[analyze_imgs] 處理後模型列表: {models}")
261
-
262
- last_error = None
263
- errors = []
264
-
265
- # Try each model in sequence until one succeeds
266
- for model in models:
267
- try:
268
- if model.startswith("gemini-"):
269
- print(f"[analyze_imgs] 嘗試使用 Gemini 模型: {model}")
270
- result = analyze_imgs_with_gemini(img_urls, user_input, model)
271
- return result
272
- elif model.startswith("claude-"):
273
- print(f"[analyze_imgs] 嘗試使用 Claude 模型: {model}")
274
- result = analyze_imgs_with_claude(img_urls, user_input, model)
275
- return result
276
- else:
277
- print(f"[analyze_imgs] 不支持的模型格式: {model}, 跳過")
278
- errors.append(f"不支持的模型格式: {model}")
279
- continue
280
-
281
- except Exception as e:
282
- last_error = e
283
- error_msg = str(e)
284
- print(f"[analyze_imgs] 模型 {model} 失敗,錯誤: {error_msg}")
285
- import traceback
286
-
287
- traceback.print_exc()
288
- errors.append(f"模型 {model} 異常: {error_msg}")
289
- # Continue to the next model in the list
290
- continue
291
-
292
- # If we've tried all models and none succeeded, return all errors
293
- error_summary = "\n".join(errors)
294
- return f"錯誤: 所有配置的模型都失敗了。詳細錯誤:\n{error_summary}"
1
+ import anthropic
2
+ import base64
3
+ import httpx
4
+ import os
5
+ import imghdr
6
+ from pathlib import Path
7
+ from typing import Dict, Any, List, Tuple
8
+ from dotenv import load_dotenv
9
+
10
+ from botrun_flow_lang.langgraph_agents.agents.util.usage_metadata import UsageMetadata
11
+
12
+ load_dotenv()
13
+
14
+
15
+ def get_img_content_type(file_path: str | Path) -> str:
16
+ """
17
+ Get the content type (MIME type) of a local file.
18
+ This function checks the actual image format rather than relying on file extension.
19
+
20
+ Args:
21
+ file_path: Path to the local file (can be string or Path object)
22
+
23
+ Returns:
24
+ str: The content type of the file (e.g., 'image/jpeg', 'image/png')
25
+
26
+ Raises:
27
+ FileNotFoundError: If the file does not exist
28
+ ValueError: If the file type is not recognized or not supported
29
+ """
30
+ if not os.path.exists(file_path):
31
+ raise FileNotFoundError(f"File not found: {file_path}")
32
+
33
+ # Check actual image type using imghdr
34
+ img_type = imghdr.what(file_path)
35
+ if not img_type:
36
+ raise ValueError(f"File is not a recognized image format: {file_path}")
37
+
38
+ # Map image type to MIME type
39
+ mime_types = {
40
+ "jpeg": "image/jpeg",
41
+ "jpg": "image/jpeg",
42
+ "png": "image/png",
43
+ "gif": "image/gif",
44
+ "webp": "image/webp",
45
+ }
46
+
47
+ content_type = mime_types.get(img_type.lower())
48
+ if not content_type:
49
+ raise ValueError(f"Unsupported image format '{img_type}': {file_path}")
50
+
51
+ return content_type
52
+
53
+
54
+ def analyze_imgs_with_claude(
55
+ img_urls: list[str], user_input: str, model_name: str = "claude-sonnet-4-5-20250929"
56
+ ) -> Tuple[str, UsageMetadata]:
57
+ """
58
+ Analyze multiple images using Claude Vision API
59
+
60
+ Args:
61
+ img_urls: List of URLs to the image files
62
+ user_input: User's query about the image content(s)
63
+ model_name: Claude model name to use
64
+
65
+ Returns:
66
+ Tuple[str, UsageMetadata]: Claude's analysis and usage metadata
67
+
68
+ Raises:
69
+ ValueError: If image URLs are invalid or model parameters are incorrect
70
+ anthropic.APIError: If there's an error with the Claude API
71
+ Exception: For other errors during processing
72
+ """
73
+ # Initialize message content
74
+ message_content = []
75
+
76
+ # Download and encode each image file from URLs
77
+ with httpx.Client(follow_redirects=True) as client:
78
+ for img_url in img_urls:
79
+ response = client.get(img_url)
80
+ if response.status_code != 200:
81
+ raise ValueError(f"Failed to download image from URL: {img_url}")
82
+
83
+ # Detect content type from response headers
84
+ content_type = response.headers.get("content-type", "")
85
+ if not content_type.startswith("image/"):
86
+ raise ValueError(f"URL does not point to a valid image: {img_url}")
87
+
88
+ # Check file size (5MB limit for API)
89
+ if len(response.content) > 5 * 1024 * 1024:
90
+ raise ValueError(f"Image file size exceeds 5MB limit: {img_url}")
91
+
92
+ # Encode image data
93
+ img_data = base64.standard_b64encode(response.content).decode("utf-8")
94
+
95
+ # Add image to message content
96
+ message_content.append(
97
+ {
98
+ "type": "image",
99
+ "source": {
100
+ "type": "base64",
101
+ "media_type": content_type,
102
+ "data": img_data,
103
+ },
104
+ }
105
+ )
106
+
107
+ # Add user input text
108
+ message_content.append({"type": "text", "text": user_input})
109
+
110
+ # Initialize Anthropic client
111
+ client = anthropic.Anthropic()
112
+
113
+ try:
114
+ # Send to Claude
115
+ message = client.messages.create(
116
+ model=model_name,
117
+ max_tokens=1024,
118
+ messages=[
119
+ {
120
+ "role": "user",
121
+ "content": message_content,
122
+ }
123
+ ],
124
+ )
125
+
126
+ # Extract usage metadata
127
+ usage = UsageMetadata(
128
+ prompt_tokens=message.usage.input_tokens,
129
+ completion_tokens=message.usage.output_tokens,
130
+ total_tokens=message.usage.input_tokens + message.usage.output_tokens,
131
+ cache_creation_input_tokens=getattr(message.usage, 'cache_creation_input_tokens', 0) or 0,
132
+ cache_read_input_tokens=getattr(message.usage, 'cache_read_input_tokens', 0) or 0,
133
+ model=model_name,
134
+ )
135
+
136
+ print(
137
+ f"analyze_imgs_with_claude============> input_token: {message.usage.input_tokens} output_token: {message.usage.output_tokens}",
138
+ )
139
+ return message.content[0].text, usage
140
+ except anthropic.APIError as e:
141
+ import traceback
142
+
143
+ traceback.print_exc()
144
+ raise anthropic.APIError(
145
+ f"Claude API error with model {model_name}: {str(e)}"
146
+ )
147
+ except Exception as e:
148
+ import traceback
149
+
150
+ traceback.print_exc()
151
+ raise Exception(
152
+ f"Error analyzing image(s) with Claude {model_name}: {str(e)}"
153
+ )
154
+
155
+
156
+ def analyze_imgs_with_gemini(
157
+ img_urls: list[str],
158
+ user_input: str,
159
+ model_name: str = "gemini-2.5-flash",
160
+ ) -> Tuple[str, UsageMetadata]:
161
+ """
162
+ Analyze multiple images using Gemini Vision API
163
+
164
+ Args:
165
+ img_urls: List of URLs to the image files
166
+ user_input: User's query about the image content(s)
167
+ model_name: Gemini model name to use
168
+
169
+ Returns:
170
+ Tuple[str, UsageMetadata]: Gemini's analysis and usage metadata
171
+
172
+ Raises:
173
+ ValueError: If image URLs are invalid or model parameters are incorrect
174
+ Exception: For errors during API calls or other processing
175
+ """
176
+ # 放到要用的時候才 import,不然loading 會花時間
177
+ from google import genai
178
+ from google.genai.types import HttpOptions, Part
179
+ from google.oauth2 import service_account
180
+
181
+ # Initialize the Gemini client
182
+ api_key = os.getenv("GEMINI_API_KEY", "")
183
+ if not api_key:
184
+ raise ValueError("GEMINI_API_KEY environment variable not set")
185
+
186
+ # 設定 API 金鑰
187
+
188
+ try:
189
+ # 初始化模型並準備內容列表
190
+ credentials = service_account.Credentials.from_service_account_file(
191
+ os.getenv("GOOGLE_APPLICATION_CREDENTIALS_FOR_FASTAPI"),
192
+ scopes=["https://www.googleapis.com/auth/cloud-platform"],
193
+ )
194
+
195
+ client = genai.Client(
196
+ credentials=credentials,
197
+ project="scoop-386004",
198
+ location="us-central1",
199
+ )
200
+ contents = [user_input]
201
+
202
+ # 下載並處理每個圖片
203
+ with httpx.Client(follow_redirects=True) as http_client:
204
+ for img_url in img_urls:
205
+ response = http_client.get(img_url)
206
+ if response.status_code != 200:
207
+ raise ValueError(f"Failed to download image from URL: {img_url}")
208
+
209
+ # 檢測內容類型
210
+ content_type = response.headers.get("content-type", "")
211
+ if not content_type.startswith("image/"):
212
+ raise ValueError(f"URL does not point to a valid image: {img_url}")
213
+
214
+ # 檢查檔案大小
215
+ if len(response.content) > 20 * 1024 * 1024: # 20MB 限制
216
+ raise ValueError(f"Image file size too large: {img_url}")
217
+
218
+ # 將圖片添加到內容中
219
+ contents.append(
220
+ Part.from_bytes(
221
+ data=response.content,
222
+ mime_type=content_type,
223
+ )
224
+ )
225
+
226
+ # 使用 genai 生成內容
227
+ response = client.models.generate_content(
228
+ model=model_name,
229
+ contents=contents,
230
+ )
231
+
232
+ # Extract usage metadata
233
+ usage = UsageMetadata(model=model_name)
234
+ if hasattr(response, "usage_metadata"):
235
+ usage_meta = response.usage_metadata
236
+ usage = UsageMetadata(
237
+ prompt_tokens=getattr(usage_meta, 'prompt_token_count', 0) or 0,
238
+ completion_tokens=getattr(usage_meta, 'candidates_token_count', 0) or 0,
239
+ total_tokens=getattr(usage_meta, 'total_token_count', 0) or 0,
240
+ cache_creation_input_tokens=0,
241
+ cache_read_input_tokens=getattr(usage_meta, 'cached_content_token_count', 0) or 0,
242
+ model=model_name,
243
+ )
244
+ print(
245
+ f"analyze_imgs_with_gemini============> input_token: {usage_meta.prompt_token_count} output_token: {usage_meta.candidates_token_count}"
246
+ )
247
+
248
+ return response.text, usage
249
+
250
+ except httpx.RequestError as e:
251
+ import traceback
252
+
253
+ traceback.print_exc()
254
+ raise ValueError(f"Failed to download image(s): {str(e)}")
255
+ except Exception as e:
256
+ import traceback
257
+
258
+ traceback.print_exc()
259
+ raise Exception(f"Error analyzing image(s) with Gemini {model_name}: {str(e)}")
260
+
261
+
262
+ def analyze_imgs(img_urls: list[str], user_input: str) -> Dict[str, Any]:
263
+ """
264
+ Analyze multiple images using configured AI models.
265
+
266
+ Uses models specified in IMG_ANALYZER_MODEL environment variable.
267
+ When multiple models are specified (comma-separated), tries them in order
268
+ until one succeeds, falling back to next model if a model fails.
269
+
270
+ Example: IMG_ANALYZER_MODEL=claude-3-7-sonnet-latest,gemini-2.0-flash
271
+
272
+ Args:
273
+ img_urls: List of URLs to the image files
274
+ user_input: User's query about the image content(s)
275
+
276
+ Returns:
277
+ Dict[str, Any]: {
278
+ "result": str, # AI analysis result
279
+ "usage_metadata": List[Dict] # Token usage for each LLM call
280
+ }
281
+ """
282
+ usage_list: List[UsageMetadata] = []
283
+
284
+ # Get models from environment variable, split by comma if multiple models
285
+ models_str = os.getenv("IMG_ANALYZER_MODEL", "gemini-2.5-flash")
286
+ print(f"[analyze_imgs] 分析IMG使用模型: {models_str}")
287
+ models = models_str.split(",")
288
+
289
+ # Remove whitespace around model names
290
+ models = [model.strip() for model in models]
291
+ print(f"[analyze_imgs] 處理後模型列表: {models}")
292
+
293
+ last_error = None
294
+ errors = []
295
+
296
+ # Try each model in sequence until one succeeds
297
+ for model in models:
298
+ try:
299
+ if model.startswith("gemini-"):
300
+ print(f"[analyze_imgs] 嘗試使用 Gemini 模型: {model}")
301
+ result, usage = analyze_imgs_with_gemini(img_urls, user_input, model)
302
+ usage_list.append(usage)
303
+ return {
304
+ "result": result,
305
+ "usage_metadata": [u.to_dict() for u in usage_list],
306
+ }
307
+ elif model.startswith("claude-"):
308
+ print(f"[analyze_imgs] 嘗試使用 Claude 模型: {model}")
309
+ result, usage = analyze_imgs_with_claude(img_urls, user_input, model)
310
+ usage_list.append(usage)
311
+ return {
312
+ "result": result,
313
+ "usage_metadata": [u.to_dict() for u in usage_list],
314
+ }
315
+ else:
316
+ print(f"[analyze_imgs] 不支持的模型格式: {model}, 跳過")
317
+ errors.append(f"不支持的模型格式: {model}")
318
+ continue
319
+
320
+ except Exception as e:
321
+ last_error = e
322
+ error_msg = str(e)
323
+ print(f"[analyze_imgs] 模型 {model} 失敗,錯誤: {error_msg}")
324
+ import traceback
325
+
326
+ traceback.print_exc()
327
+ errors.append(f"模型 {model} 異常: {error_msg}")
328
+ # Continue to the next model in the list
329
+ continue
330
+
331
+ # If we've tried all models and none succeeded, return all errors
332
+ error_summary = "\n".join(errors)
333
+ return {
334
+ "result": f"錯誤: 所有配置的模型都失敗了。詳細錯誤:\n{error_summary}",
335
+ "usage_metadata": [u.to_dict() for u in usage_list],
336
+ }