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

45 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

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

2 

3from langchain_openai import ChatOpenAI 

4 

5from ....security.secure_logging import logger 

6 

7# get_setting_from_snapshot and NoSettingsContextError are imported inside 

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

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

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

11# unaffected by the source-module patch. 

12from ....security.ssrf_validator import assert_base_url_safe 

13from ..base import Exposure 

14from ..openai_base import OpenAICompatibleProvider 

15from .._helpers import ( 

16 build_timeout, 

17 resolve_max_retries, 

18 resolve_request_timeout, 

19) 

20 

21 

22class OpenAIProvider(OpenAICompatibleProvider): 

23 """OpenAI provider for Local Deep Research. 

24 

25 This is the official OpenAI API provider. 

26 """ 

27 

28 provider_name = "OpenAI" 

29 api_key_setting = "llm.openai.api_key" 

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

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

32 

33 # Metadata for auto-discovery 

34 provider_key = "OPENAI" 

35 company_name = "OpenAI" 

36 is_cloud = True 

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

38 egress_exposure = Exposure.EXPOSING 

39 

40 @classmethod 

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

42 """Factory function for OpenAI LLMs. 

43 

44 Args: 

45 model_name: Name of the model to use 

46 temperature: Model temperature (0.0-1.0) 

47 **kwargs: Additional arguments including settings_snapshot 

48 

49 Returns: 

50 A configured ChatOpenAI instance 

51 

52 Raises: 

53 ValueError: If API key is not configured 

54 """ 

55 from ....config.thread_settings import ( 

56 _get_optional_setting, 

57 get_setting_from_snapshot, 

58 NoSettingsContextError, 

59 ) 

60 

61 settings_snapshot = kwargs.get("settings_snapshot") 

62 

63 # resolve_api_key raises ValueError when the required key is missing 

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

65 api_key = cls.resolve_api_key(settings_snapshot) 

66 

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

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

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

70 raise ValueError( 

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

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

73 ) 

74 

75 # Build OpenAI-specific parameters 

76 openai_params = { 

77 "model": model_name, 

78 "api_key": api_key, 

79 "temperature": temperature, 

80 } 

81 

82 # Add optional parameters if they exist in settings 

83 try: 

84 api_base = get_setting_from_snapshot( 

85 "llm.openai.api_base", 

86 default=None, 

87 settings_snapshot=settings_snapshot, 

88 ) 

89 if api_base: 

90 # SSRF guard for operator-configurable api_base. OpenAIProvider 

91 # has url_setting = None and overrides create_llm without 

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

93 # ChatOpenAI uses its own httpx transport that bypasses 

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

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

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

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

98 # metadata endpoint, matching ollama.create_llm. 

99 api_base = assert_base_url_safe( 

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

101 ) 

102 openai_params["openai_api_base"] = api_base 

103 except NoSettingsContextError: 

104 pass # Optional parameter 

105 

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

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

108 _get_optional_setting( 

109 openai_params, 

110 "openai_organization", 

111 "llm.openai.organization", 

112 settings_snapshot, 

113 check="falsy", 

114 ) 

115 

116 _get_optional_setting( 

117 openai_params, 

118 "streaming", 

119 "llm.streaming", 

120 settings_snapshot, 

121 ) 

122 

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

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

125 from .._helpers import ( 

126 compute_max_tokens, 

127 get_context_window_for_provider, 

128 ) 

129 

130 try: 

131 context_window_size = get_context_window_for_provider( 

132 "openai", settings_snapshot=settings_snapshot 

133 ) 

134 max_tokens = compute_max_tokens( 

135 settings_snapshot=settings_snapshot, 

136 context_window_size=context_window_size, 

137 ) 

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

139 openai_params["max_tokens"] = max_tokens 

140 except NoSettingsContextError: 

141 pass # Optional parameter 

142 

143 # Bounded per-operation timeout and retry count (see 

144 # ``_helpers.build_timeout``). Both are always set: langchain 

145 # otherwise forwards an explicit ``timeout=None`` that overrides 

146 # the SDK's own default, leaving reads unbounded. 

147 openai_params["request_timeout"] = build_timeout( 

148 resolve_request_timeout(settings_snapshot) 

149 ) 

150 openai_params["max_retries"] = resolve_max_retries(settings_snapshot) 

151 

152 logger.info( 

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

154 f"temperature: {temperature}" 

155 ) 

156 

157 return ChatOpenAI(**openai_params)