@@ -99,23 +99,55 @@ def analyze_with_attachments(
9999 max_tokens = max_tokens ,
100100 )
101101
102+ def _prepare_csv_attachments (
103+ self ,
104+ attachments : list [Mapping [str , str ]] | None ,
105+ * ,
106+ supports_file_attachments : bool = True ,
107+ ) -> list [dict [str , str ]] | None :
108+ """Apply shared CSV-attachment preflight checks and normalization."""
109+ if not bool (getattr (self , "attach_csv_as_file" , False )):
110+ return None
111+ if not attachments :
112+ return None
113+ if getattr (self , "_csv_attachment_supported" , None ) is False :
114+ return None
115+ if not supports_file_attachments :
116+ if hasattr (self , "_csv_attachment_supported" ):
117+ setattr (self , "_csv_attachment_supported" , False )
118+ return None
102119
103- def _resolve_api_key (config_key : str , env_var : str ) -> str :
120+ normalized_attachments = _normalize_attachment_inputs (attachments )
121+ if not normalized_attachments :
122+ return None
123+ return normalized_attachments
124+
125+
126+ def _normalize_api_key_value (value : Any ) -> str :
127+ """Normalize API key-like values from config/env sources."""
128+ if value is None :
129+ return ""
130+ return str (value ).strip ()
131+
132+
133+ def _resolve_api_key (config_key : Any , env_var : str ) -> str :
104134 """Return the API key from config, falling back to an environment variable."""
105- if config_key :
106- return config_key
107- return os .environ .get (env_var , "" )
135+ normalized_config_key = _normalize_api_key_value (config_key )
136+ if normalized_config_key :
137+ return normalized_config_key
138+ return _normalize_api_key_value (os .environ .get (env_var , "" ))
108139
109140
110- def _resolve_api_key_candidates (config_key : str , env_vars : tuple [str , ...]) -> str :
141+ def _resolve_api_key_candidates (config_key : Any , env_vars : tuple [str , ...]) -> str :
111142 """Return API key from config, falling back across multiple environment variables."""
112- if config_key :
113- return config_key
143+ normalized_config_key = _normalize_api_key_value (config_key )
144+ if normalized_config_key :
145+ return normalized_config_key
114146
115147 for env_var in env_vars :
116- value = os .environ .get (env_var , "" )
117- if value :
118- return value
148+ normalized_value = _normalize_api_key_value ( os .environ .get (env_var , "" ) )
149+ if normalized_value :
150+ return normalized_value
119151 return ""
120152
121153
@@ -660,18 +692,19 @@ def __init__(
660692 "anthropic SDK is not installed. Install it with `pip install anthropic`."
661693 ) from error
662694
663- if not api_key :
695+ normalized_api_key = _normalize_api_key_value (api_key )
696+ if not normalized_api_key :
664697 raise AIProviderError (
665698 "Claude API key is not configured. "
666699 "Set `ai.claude.api_key` in config.yaml or the ANTHROPIC_API_KEY environment variable."
667700 )
668701
669702 self ._anthropic = anthropic
670- self .api_key = api_key
703+ self .api_key = normalized_api_key
671704 self .model = model
672705 self .attach_csv_as_file = bool (attach_csv_as_file )
673706 self ._csv_attachment_supported : bool | None = None
674- self .client = anthropic .Anthropic (api_key = api_key )
707+ self .client = anthropic .Anthropic (api_key = normalized_api_key )
675708 logger .info ("Initialized Claude provider with model %s" , model )
676709
677710 def analyze (
@@ -751,14 +784,7 @@ def _request_with_csv_attachments(
751784 max_tokens : int ,
752785 attachments : list [Mapping [str , str ]] | None ,
753786 ) -> str | None :
754- if not self .attach_csv_as_file :
755- return None
756- if not attachments :
757- return None
758- if self ._csv_attachment_supported is False :
759- return None
760-
761- normalized_attachments = _normalize_attachment_inputs (attachments )
787+ normalized_attachments = self ._prepare_csv_attachments (attachments )
762788 if not normalized_attachments :
763789 return None
764790
@@ -905,18 +931,19 @@ def __init__(
905931 "openai SDK is not installed. Install it with `pip install openai`."
906932 ) from error
907933
908- if not api_key :
934+ normalized_api_key = _normalize_api_key_value (api_key )
935+ if not normalized_api_key :
909936 raise AIProviderError (
910937 "OpenAI API key is not configured. "
911938 "Set `ai.openai.api_key` in config.yaml or the OPENAI_API_KEY environment variable."
912939 )
913940
914941 self ._openai = openai
915- self .api_key = api_key
942+ self .api_key = normalized_api_key
916943 self .model = model
917944 self .attach_csv_as_file = bool (attach_csv_as_file )
918945 self ._csv_attachment_supported : bool | None = None
919- self .client = openai .OpenAI (api_key = api_key )
946+ self .client = openai .OpenAI (api_key = normalized_api_key )
920947 logger .info ("Initialized OpenAI provider with model %s" , model )
921948
922949 def analyze (
@@ -1065,17 +1092,10 @@ def _request_with_csv_attachments(
10651092 max_tokens : int ,
10661093 attachments : list [Mapping [str , str ]] | None ,
10671094 ) -> str | None :
1068- if not self .attach_csv_as_file :
1069- return None
1070- if not attachments :
1071- return None
1072- if self ._csv_attachment_supported is False :
1073- return None
1074- if not hasattr (self .client , "files" ) or not hasattr (self .client , "responses" ):
1075- self ._csv_attachment_supported = False
1076- return None
1077-
1078- normalized_attachments = _normalize_attachment_inputs (attachments )
1095+ normalized_attachments = self ._prepare_csv_attachments (
1096+ attachments ,
1097+ supports_file_attachments = hasattr (self .client , "files" ) and hasattr (self .client , "responses" ),
1098+ )
10791099 if not normalized_attachments :
10801100 return None
10811101
@@ -1176,22 +1196,23 @@ def __init__(
11761196 "openai SDK is not installed. Install it with `pip install openai`."
11771197 ) from error
11781198
1179- if not api_key :
1199+ normalized_api_key = _normalize_api_key_value (api_key )
1200+ if not normalized_api_key :
11801201 raise AIProviderError (
11811202 "Kimi API key is not configured. "
11821203 "Set `ai.kimi.api_key` in config.yaml or the MOONSHOT_API_KEY environment variable."
11831204 )
11841205
11851206 self ._openai = openai
1186- self .api_key = api_key
1207+ self .api_key = normalized_api_key
11871208 self .model = _normalize_kimi_model_name (model )
11881209 self .base_url = _normalize_openai_compatible_base_url (
11891210 base_url = base_url ,
11901211 default_base_url = DEFAULT_KIMI_BASE_URL ,
11911212 )
11921213 self .attach_csv_as_file = bool (attach_csv_as_file )
11931214 self ._csv_attachment_supported : bool | None = None
1194- self .client = openai .OpenAI (api_key = api_key , base_url = self .base_url )
1215+ self .client = openai .OpenAI (api_key = normalized_api_key , base_url = self .base_url )
11951216 logger .info ("Initialized Kimi provider at %s with model %s" , self .base_url , self .model )
11961217
11971218 def analyze (
@@ -1295,17 +1316,10 @@ def _request_with_csv_attachments(
12951316 max_tokens : int ,
12961317 attachments : list [Mapping [str , str ]] | None ,
12971318 ) -> str | None :
1298- if not self .attach_csv_as_file :
1299- return None
1300- if not attachments :
1301- return None
1302- if self ._csv_attachment_supported is False :
1303- return None
1304- if not hasattr (self .client , "files" ) or not hasattr (self .client , "responses" ):
1305- self ._csv_attachment_supported = False
1306- return None
1307-
1308- normalized_attachments = _normalize_attachment_inputs (attachments )
1319+ normalized_attachments = self ._prepare_csv_attachments (
1320+ attachments ,
1321+ supports_file_attachments = hasattr (self .client , "files" ) and hasattr (self .client , "responses" ),
1322+ )
13091323 if not normalized_attachments :
13101324 return None
13111325
@@ -1381,16 +1395,18 @@ def __init__(
13811395 "openai SDK is not installed. Install it with `pip install openai`."
13821396 ) from error
13831397
1398+ normalized_api_key = _normalize_api_key_value (api_key ) or "not-needed"
1399+
13841400 self ._openai = openai
13851401 self .base_url = _normalize_openai_compatible_base_url (
13861402 base_url = base_url ,
13871403 default_base_url = DEFAULT_LOCAL_BASE_URL ,
13881404 )
13891405 self .model = model
1390- self .api_key = api_key
1406+ self .api_key = normalized_api_key
13911407 self .attach_csv_as_file = bool (attach_csv_as_file )
13921408 self ._csv_attachment_supported : bool | None = None
1393- self .client = openai .OpenAI (api_key = api_key , base_url = self .base_url )
1409+ self .client = openai .OpenAI (api_key = normalized_api_key , base_url = self .base_url )
13941410 logger .info ("Initialized local provider at %s with model %s" , self .base_url , model )
13951411
13961412 def analyze (
@@ -1486,13 +1502,18 @@ def _request() -> str:
14861502 cleaned_attachment_response = _strip_leading_reasoning_blocks (attachment_response )
14871503 return cleaned_attachment_response or attachment_response .strip ()
14881504
1505+ prompt_for_completion = self ._build_chat_completion_prompt (
1506+ user_prompt = user_prompt ,
1507+ attachments = attachments ,
1508+ )
1509+
14891510 try :
14901511 stream = self .client .chat .completions .create (
14911512 model = self .model ,
14921513 max_tokens = max_tokens ,
14931514 messages = [
14941515 {"role" : "system" , "content" : system_prompt },
1495- {"role" : "user" , "content" : user_prompt },
1516+ {"role" : "user" , "content" : prompt_for_completion },
14961517 ],
14971518 stream = True ,
14981519 )
@@ -1611,12 +1632,17 @@ def _request_non_stream(
16111632 return cleaned_attachment_response
16121633 return attachment_response .strip ()
16131634
1635+ prompt_for_completion = self ._build_chat_completion_prompt (
1636+ user_prompt = user_prompt ,
1637+ attachments = attachments ,
1638+ )
1639+
16141640 response = self .client .chat .completions .create (
16151641 model = self .model ,
16161642 max_tokens = max_tokens ,
16171643 messages = [
16181644 {"role" : "system" , "content" : system_prompt },
1619- {"role" : "user" , "content" : user_prompt },
1645+ {"role" : "user" , "content" : prompt_for_completion },
16201646 ],
16211647 )
16221648 text = _extract_openai_text (response )
@@ -1639,24 +1665,32 @@ def _request_non_stream(
16391665 f"{ reason_detail } . This can happen with reasoning-only outputs or very low token limits."
16401666 )
16411667
1668+ def _build_chat_completion_prompt (
1669+ self ,
1670+ user_prompt : str ,
1671+ attachments : list [Mapping [str , str ]] | None ,
1672+ ) -> str :
1673+ prompt_for_completion = user_prompt
1674+ if attachments and self .attach_csv_as_file :
1675+ prompt_for_completion , inlined_attachment_data = _inline_attachment_data_into_prompt (
1676+ user_prompt = user_prompt ,
1677+ attachments = attachments ,
1678+ )
1679+ if inlined_attachment_data :
1680+ logger .info ("Local attachment fallback inlined attachment data into prompt." )
1681+ return prompt_for_completion
1682+
16421683 def _request_with_csv_attachments (
16431684 self ,
16441685 system_prompt : str ,
16451686 user_prompt : str ,
16461687 max_tokens : int ,
16471688 attachments : list [Mapping [str , str ]] | None ,
16481689 ) -> str | None :
1649- if not self .attach_csv_as_file :
1650- return None
1651- if not attachments :
1652- return None
1653- if self ._csv_attachment_supported is False :
1654- return None
1655- if not hasattr (self .client , "files" ) or not hasattr (self .client , "responses" ):
1656- self ._csv_attachment_supported = False
1657- return None
1658-
1659- normalized_attachments = _normalize_attachment_inputs (attachments )
1690+ normalized_attachments = self ._prepare_csv_attachments (
1691+ attachments ,
1692+ supports_file_attachments = hasattr (self .client , "files" ) and hasattr (self .client , "responses" ),
1693+ )
16601694 if not normalized_attachments :
16611695 return None
16621696
@@ -1728,7 +1762,7 @@ def create_provider(config: dict[str, Any]) -> AIProvider:
17281762 if not isinstance (claude_config , dict ):
17291763 raise ValueError ("Invalid configuration: `ai.claude` must be a dictionary." )
17301764 api_key = _resolve_api_key (
1731- str ( claude_config .get ("api_key" , "" ) ),
1765+ claude_config .get ("api_key" , "" ),
17321766 "ANTHROPIC_API_KEY" ,
17331767 )
17341768 return ClaudeProvider (
@@ -1742,7 +1776,7 @@ def create_provider(config: dict[str, Any]) -> AIProvider:
17421776 if not isinstance (openai_config , dict ):
17431777 raise ValueError ("Invalid configuration: `ai.openai` must be a dictionary." )
17441778 api_key = _resolve_api_key (
1745- str ( openai_config .get ("api_key" , "" ) ),
1779+ openai_config .get ("api_key" , "" ),
17461780 "OPENAI_API_KEY" ,
17471781 )
17481782 return OpenAIProvider (
@@ -1758,7 +1792,7 @@ def create_provider(config: dict[str, Any]) -> AIProvider:
17581792 return LocalProvider (
17591793 base_url = str (local_config .get ("base_url" , DEFAULT_LOCAL_BASE_URL )),
17601794 model = str (local_config .get ("model" , DEFAULT_LOCAL_MODEL )),
1761- api_key = str (local_config .get ("api_key" , "not-needed" )),
1795+ api_key = _normalize_api_key_value (local_config .get ("api_key" , "not-needed" )) or "not-needed" ,
17621796 attach_csv_as_file = bool (local_config .get ("attach_csv_as_file" , True )),
17631797 )
17641798
@@ -1767,7 +1801,7 @@ def create_provider(config: dict[str, Any]) -> AIProvider:
17671801 if not isinstance (kimi_config , dict ):
17681802 raise ValueError ("Invalid configuration: `ai.kimi` must be a dictionary." )
17691803 api_key = _resolve_api_key_candidates (
1770- str ( kimi_config .get ("api_key" , "" ) ),
1804+ kimi_config .get ("api_key" , "" ),
17711805 ("MOONSHOT_API_KEY" , "KIMI_API_KEY" ),
17721806 )
17731807 return KimiProvider (
0 commit comments