Coverage for src/local_deep_research/llm/providers/implementations/openai.py: 100%

44 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +0000

1"""OpenAI LLM provider for Local Deep Research.""" 

2 

3from langchain_openai import ChatOpenAI 

4from ....security.secure_logging import logger 

5 

6# get_setting_from_snapshot and NoSettingsContextError are imported inside 

7# create_llm() so test patches at the source module 

8# (`local_deep_research.config.thread_settings`) are picked up by the 

9# function-local import at call time. Module-level binding here would be 

10# unaffected by the source-module patch. 

11from ....security.ssrf_validator import assert_base_url_safe 

12from ..base import Exposure 

13from ..openai_base import OpenAICompatibleProvider 

14 

15 

16class OpenAIProvider(OpenAICompatibleProvider): 

17 """OpenAI provider for Local Deep Research. 

18 

19 This is the official OpenAI API provider. 

20 """ 

21 

22 provider_name = "OpenAI" 

23 api_key_setting = "llm.openai.api_key" 

24 default_model = "" # User must explicitly pick a model — no silent fallback 

25 default_base_url = "https://api.openai.com/v1" 

26 

27 # Metadata for auto-discovery 

28 provider_key = "OPENAI" 

29 company_name = "OpenAI" 

30 is_cloud = True 

31 # Egress exposure (ADR-0007): cloud inference sink — data leaves the box. 

32 egress_exposure = Exposure.EXPOSING 

33 

34 @classmethod 

35 def create_llm(cls, model_name=None, temperature=0.7, **kwargs): 

36 """Factory function for OpenAI LLMs. 

37 

38 Args: 

39 model_name: Name of the model to use 

40 temperature: Model temperature (0.0-1.0) 

41 **kwargs: Additional arguments including settings_snapshot 

42 

43 Returns: 

44 A configured ChatOpenAI instance 

45 

46 Raises: 

47 ValueError: If API key is not configured 

48 """ 

49 from ....config.thread_settings import ( 

50 _get_optional_setting, 

51 get_setting_from_snapshot, 

52 NoSettingsContextError, 

53 ) 

54 

55 settings_snapshot = kwargs.get("settings_snapshot") 

56 

57 # resolve_api_key raises ValueError when the required key is missing 

58 # (preserves the legacy behavior with a unified error message). 

59 api_key = cls.resolve_api_key(settings_snapshot) 

60 

61 # Require an explicit model — no silent fallback to a hardcoded default. 

62 if not model_name or not model_name.strip(): 

63 logger.error(f"{cls.provider_name} model name not provided") 

64 raise ValueError( 

65 f"{cls.provider_name} model not configured. " 

66 f"Please set llm.model in settings (e.g. 'gpt-4o-mini')." 

67 ) 

68 

69 # Build OpenAI-specific parameters 

70 openai_params = { 

71 "model": model_name, 

72 "api_key": api_key, 

73 "temperature": temperature, 

74 } 

75 

76 # Add optional parameters if they exist in settings 

77 try: 

78 api_base = get_setting_from_snapshot( 

79 "llm.openai.api_base", 

80 default=None, 

81 settings_snapshot=settings_snapshot, 

82 ) 

83 if api_base: 

84 # SSRF guard for operator-configurable api_base. OpenAIProvider 

85 # has url_setting = None and overrides create_llm without 

86 # calling super, so the base-class guard never runs here. 

87 # ChatOpenAI uses its own httpx transport that bypasses 

88 # safe_requests, so an attacker who can edit llm.openai.api_base 

89 # could route inference at internal/cloud-credential endpoints. 

90 # Let ValueError propagate (fail-fast) — at inference 

91 # construction we fail rather than silently route traffic to a 

92 # metadata endpoint, matching ollama.create_llm. 

93 api_base = assert_base_url_safe( 

94 api_base, setting_key="llm.openai.api_base" 

95 ) 

96 openai_params["openai_api_base"] = api_base 

97 except NoSettingsContextError: 

98 pass # Optional parameter 

99 

100 # organization uses the falsy check intentionally — an empty string 

101 # must be dropped rather than forwarded to the OpenAI client. 

102 _get_optional_setting( 

103 openai_params, 

104 "openai_organization", 

105 "llm.openai.organization", 

106 settings_snapshot, 

107 check="falsy", 

108 ) 

109 

110 _get_optional_setting( 

111 openai_params, 

112 "streaming", 

113 "llm.streaming", 

114 settings_snapshot, 

115 ) 

116 

117 _get_optional_setting( 

118 openai_params, 

119 "max_retries", 

120 "llm.max_retries", 

121 settings_snapshot, 

122 ) 

123 

124 _get_optional_setting( 

125 openai_params, 

126 "request_timeout", 

127 "llm.request_timeout", 

128 settings_snapshot, 

129 ) 

130 

131 # Apply context-window-aware max_tokens cap (was previously only 

132 # applied in dead code in llm_config.get_llm). 

133 from .._helpers import ( 

134 compute_max_tokens, 

135 get_context_window_for_provider, 

136 ) 

137 

138 try: 

139 context_window_size = get_context_window_for_provider( 

140 "openai", settings_snapshot=settings_snapshot 

141 ) 

142 max_tokens = compute_max_tokens( 

143 settings_snapshot=settings_snapshot, 

144 context_window_size=context_window_size, 

145 ) 

146 if max_tokens: # Treat 0 as unset (matches legacy behavior) 

147 openai_params["max_tokens"] = max_tokens 

148 except NoSettingsContextError: 

149 pass # Optional parameter 

150 

151 logger.info( 

152 f"Creating {cls.provider_name} LLM with model: {model_name}, " 

153 f"temperature: {temperature}" 

154 ) 

155 

156 return ChatOpenAI(**openai_params)