freeplay 0.2.25__tar.gz → 0.2.30__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 228Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: freeplay
3
- Version: 0.2.25
3
+ Version: 0.2.30
4
4
  Summary:
5
5
  License: MIT
6
6
  Author: FreePlay Engineering
@@ -12,10 +12,11 @@ Classifier: Programming Language :: Python :: 3.8
12
12
  Classifier: Programming Language :: Python :: 3.9
13
13
  Classifier: Programming Language :: Python :: 3.10
14
14
  Classifier: Programming Language :: Python :: 3.11
15
- Classifier: Programming Language :: Python :: 3.12
16
15
  Requires-Dist: anthropic (>=0.7.7,<0.8.0)
16
+ Requires-Dist: click (==8.1.7)
17
17
  Requires-Dist: dacite (>=1.8.0,<2.0.0)
18
- Requires-Dist: openai (>=0.27.8,<0.28.0)
18
+ Requires-Dist: openai (>=1,<2)
19
+ Requires-Dist: pystache (>=0.6.5,<0.7.0)
19
20
  Requires-Dist: requests (>=2.20.0,<3.0.0dev)
20
21
  Description-Content-Type: text/markdown
21
22
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "freeplay"
3
- version = "0.2.25"
3
+ version = "0.2.30"
4
4
  description = ""
5
5
  authors = ["FreePlay Engineering <engineering@freeplay.ai>"]
6
6
  license = "MIT"
@@ -11,9 +11,21 @@ python = ">=3.8, <4"
11
11
  requests = ">=2.20.0,<3.0.0dev"
12
12
  dacite = "^1.8.0"
13
13
  anthropic = "^0.7.7"
14
- openai = "^0.27.8"
14
+ openai = "^1"
15
+ click = "8.1.7"
16
+ pystache = "^0.6.5"
15
17
 
18
+ [tool.poetry.group.dev.dependencies]
19
+ mypy = "^1"
20
+ types-requests = "^2.31"
21
+
22
+ [tool.poetry.group.test.dependencies]
23
+ responses = "^0.23.1"
24
+ respx = "^0.20.2"
16
25
 
17
26
  [build-system]
18
27
  requires = ["poetry-core"]
19
28
  build-backend = "poetry.core.masonry.api"
29
+
30
+ [tool.poetry.scripts]
31
+ freeplay = "freeplay.freeplay_cli:cli"
@@ -1,6 +1,7 @@
1
1
  import json
2
2
  import logging
3
3
  import typing as t
4
+ from typing import Dict
4
5
 
5
6
  import dacite
6
7
  import requests
@@ -24,7 +25,7 @@ def try_decode(target_type: t.Type[T], data: bytes) -> t.Optional[T]:
24
25
  return None
25
26
 
26
27
 
27
- def post(target_type: t.Type[T], api_key: str, url: str, payload: t.Optional[dict[str, str]] = None) -> T:
28
+ def post(target_type: t.Type[T], api_key: str, url: str, payload: t.Optional[Dict[str, str]] = None) -> T:
28
29
  response = requests.post(
29
30
  url=url,
30
31
  headers={'Authorization': f'Bearer {api_key}'},
@@ -41,7 +42,7 @@ def post(target_type: t.Type[T], api_key: str, url: str, payload: t.Optional[dic
41
42
  return maybe_object
42
43
 
43
44
 
44
- def post_raw(api_key: str, url: str, payload: t.Optional[dict[str, t.Any]] = None) -> Response:
45
+ def post_raw(api_key: str, url: str, payload: t.Optional[Dict[str, t.Any]] = None) -> Response:
45
46
  return requests.post(
46
47
  url=url,
47
48
  headers={'Authorization': f'Bearer {api_key}'},
@@ -1,33 +1,40 @@
1
1
  from dataclasses import dataclass
2
- from typing import Optional, Any
2
+ from typing import Any, Dict, List, Optional, TypedDict
3
+
4
+ from openai.types.chat.chat_completion_chunk import ChoiceDeltaFunctionCall
5
+ from openai.types.chat.chat_completion_message import FunctionCall
3
6
 
4
7
  from .llm_parameters import LLMParameters
5
8
 
6
- ChatMessage = dict[str, str]
7
- OpenAIFunctionCall = dict[str, str]
9
+
10
+ class ChatMessage(TypedDict):
11
+ role: str
12
+ content: str
8
13
 
9
14
 
10
15
  @dataclass
11
16
  class CompletionResponse:
12
17
  content: str
13
18
  is_complete: bool
14
- openai_function_call: Optional[OpenAIFunctionCall] = None
19
+ openai_function_call: Optional[FunctionCall] = None
20
+
15
21
 
16
22
  @dataclass
17
23
  class ChatCompletionResponse:
18
24
  content: str
19
25
  is_complete: bool
20
- message_history: list[ChatMessage]
26
+ message_history: List[ChatMessage]
21
27
 
22
28
 
23
29
  @dataclass
24
30
  class PromptTemplateWithMetadata:
25
- project_version_id: str
26
31
  prompt_template_id: str
32
+ prompt_template_version_id: str
33
+
27
34
  name: str
28
35
  content: str
29
36
  flavor_name: Optional[str]
30
- params: Optional[dict[str, Any]]
37
+ params: Optional[Dict[str, Any]]
31
38
 
32
39
  def get_params(self) -> LLMParameters:
33
40
  return LLMParameters.empty() if self.params is None else LLMParameters(self.params)
@@ -35,11 +42,11 @@ class PromptTemplateWithMetadata:
35
42
 
36
43
  @dataclass
37
44
  class PromptTemplates:
38
- templates: list[PromptTemplateWithMetadata]
45
+ templates: List[PromptTemplateWithMetadata]
39
46
 
40
47
 
41
48
  @dataclass
42
49
  class CompletionChunk:
43
50
  text: str
44
51
  is_complete: bool
45
- openai_function_call: Optional[OpenAIFunctionCall] = None
52
+ openai_function_call: Optional[ChoiceDeltaFunctionCall] = None
@@ -1,34 +1,34 @@
1
1
  import json
2
2
  from abc import abstractmethod, ABC
3
3
  from copy import copy
4
- from typing import Optional, Generator, Any
4
+ from typing import cast, Any, Dict, Generator, List, Optional, Union
5
5
 
6
- import anthropic # type: ignore
6
+ import anthropic
7
7
  import openai
8
- from openai.error import AuthenticationError, InvalidRequestError
8
+ from openai import AuthenticationError, BadRequestError, Stream
9
+ from openai.types.chat import ChatCompletion, ChatCompletionChunk, ChatCompletionMessageParam
9
10
 
10
11
  from .completions import CompletionChunk, PromptTemplateWithMetadata, CompletionResponse, ChatCompletionResponse, \
11
12
  ChatMessage
12
13
  from .errors import FreeplayConfigurationError, LLMClientError, LLMServerError, FreeplayError
13
14
  from .llm_parameters import LLMParameters
14
- from .provider_config import ProviderConfig, OpenAIConfig, AnthropicConfig
15
+ from .provider_config import AnthropicConfig, AzureConfig, OpenAIConfig, ProviderConfig
15
16
  from .utils import format_template_variables
16
17
 
17
18
 
18
19
  class Flavor(ABC):
19
20
  @classmethod
20
21
  def get_by_name(cls, flavor_name: str) -> 'Flavor':
21
- match flavor_name:
22
- case OpenAIChat.record_format_type:
23
- return OpenAIChat()
24
- case AzureOpenAIChat.record_format_type:
25
- return AzureOpenAIChat()
26
- case AnthropicClaudeChat.record_format_type:
27
- return AnthropicClaudeChat()
28
- case _:
29
- raise FreeplayConfigurationError(
30
- 'Configured flavor not found in SDK. Please update your SDK version or configure '
31
- 'a different model in the Freeplay UI.')
22
+ if flavor_name == OpenAIChat.record_format_type:
23
+ return OpenAIChat()
24
+ elif flavor_name == AzureOpenAIChat.record_format_type:
25
+ return AzureOpenAIChat()
26
+ elif flavor_name == AnthropicClaudeChat.record_format_type:
27
+ return AnthropicClaudeChat()
28
+ else:
29
+ raise FreeplayConfigurationError(
30
+ 'Configured flavor not found in SDK. Please update your SDK version or configure '
31
+ 'a different model in the Freeplay UI.')
32
32
 
33
33
  @property
34
34
  @abstractmethod
@@ -45,7 +45,7 @@ class Flavor(ABC):
45
45
  return LLMParameters.empty()
46
46
 
47
47
  @abstractmethod
48
- def format(self, prompt_template: PromptTemplateWithMetadata, variables: dict[str, str]) -> str:
48
+ def format(self, prompt_template: PromptTemplateWithMetadata, variables: Dict[str, str]) -> str:
49
49
  pass
50
50
 
51
51
  @abstractmethod
@@ -74,7 +74,7 @@ class ChatFlavor(Flavor, ABC):
74
74
  @abstractmethod
75
75
  def continue_chat(
76
76
  self,
77
- messages: list[ChatMessage],
77
+ messages: List[ChatMessage],
78
78
  provider_config: ProviderConfig,
79
79
  llm_parameters: LLMParameters
80
80
  ) -> ChatCompletionResponse:
@@ -83,57 +83,29 @@ class ChatFlavor(Flavor, ABC):
83
83
  @abstractmethod
84
84
  def continue_chat_stream(
85
85
  self,
86
- messages: list[ChatMessage],
86
+ messages: List[ChatMessage],
87
87
  provider_config: ProviderConfig,
88
88
  llm_parameters: LLMParameters
89
89
  ) -> Generator[CompletionChunk, None, None]:
90
90
  pass
91
91
 
92
92
 
93
- class OpenAI(Flavor, ABC):
94
- def configure_openai(
95
- self,
96
- openai_config: Optional[OpenAIConfig],
97
- api_base: Optional[str] = None,
98
- api_version: Optional[str] = None,
99
- api_type: Optional[str] = None,
100
- ) -> None:
101
- super().__init__()
102
- if not openai_config:
103
- raise FreeplayConfigurationError(
104
- "Missing OpenAI key. Use a ProviderConfig to specify keys prior to getting completion.")
105
-
106
- if api_base:
107
- openai.api_base = api_base
108
- elif openai_config.api_base:
109
- openai.api_base = openai_config.api_base
110
-
111
- if api_type:
112
- openai.api_type = api_type
113
-
114
- if api_version:
115
- openai.api_version = api_version
93
+ class OpenAIChatFlavor(ChatFlavor, ABC):
116
94
 
117
- if not openai_config.api_key or not openai_config.api_key.strip():
118
- raise FreeplayConfigurationError("OpenAI API key is not set. It must be set to make calls to the service.")
119
-
120
- openai.api_key = openai_config.api_key
121
-
122
- @property
123
- def provider(self) -> str:
124
- return "openai"
125
-
126
-
127
- class OpenAIChat(OpenAI, ChatFlavor):
128
- record_format_type = "openai_chat"
129
- _model_params_with_defaults = LLMParameters({
130
- "model": "gpt-3.5-turbo"
131
- })
95
+ @abstractmethod
96
+ def _call_openai(
97
+ self,
98
+ messages: List[ChatMessage],
99
+ provider_config: ProviderConfig,
100
+ llm_parameters: LLMParameters,
101
+ stream: bool
102
+ ) -> Union[ChatCompletion, openai.Stream[ChatCompletionChunk]]:
103
+ pass
132
104
 
133
- def format(self, prompt_template: PromptTemplateWithMetadata, variables: dict[str, str]) -> str:
105
+ def format(self, prompt_template: PromptTemplateWithMetadata, variables: Dict[str, str]) -> str:
134
106
  # Extract messages JSON to enable formatting of individual content fields of each message. If we do not
135
107
  # extract the JSON, current variable interpolation will fail on JSON curly braces.
136
- messages_as_json: list[dict[str, str]] = json.loads(prompt_template.content)
108
+ messages_as_json: List[Dict[str, str]] = json.loads(prompt_template.content)
137
109
  formatted_messages = [
138
110
  {
139
111
  "content": format_template_variables(message['content'], variables), "role": message['role']
@@ -147,11 +119,12 @@ class OpenAIChat(OpenAI, ChatFlavor):
147
119
  llm_parameters: LLMParameters
148
120
  ) -> CompletionResponse:
149
121
  messages = json.loads(formatted_prompt)
150
- completion = self._call_openai(messages, provider_config, llm_parameters, stream=False)
122
+ completion = cast(ChatCompletion, self._call_openai(messages, provider_config, llm_parameters, stream=False))
123
+
151
124
  return CompletionResponse(
152
125
  content=completion.choices[0].message.content or '',
153
126
  is_complete=completion.choices[0].finish_reason == 'stop',
154
- openai_function_call=completion.choices[0].message.get('function_call')
127
+ openai_function_call=completion.choices[0].message.function_call,
155
128
  )
156
129
 
157
130
  def call_service_stream(
@@ -161,70 +134,129 @@ class OpenAIChat(OpenAI, ChatFlavor):
161
134
  llm_parameters: LLMParameters
162
135
  ) -> Generator[CompletionChunk, None, None]:
163
136
  messages = json.loads(formatted_prompt)
164
- completion_stream = self._call_openai(messages, provider_config, llm_parameters, stream=True)
137
+ completion_stream = cast(Stream[ChatCompletionChunk],
138
+ self._call_openai(messages, provider_config, llm_parameters, stream=True))
165
139
  for chunk in completion_stream:
166
140
  yield CompletionChunk(
167
- text=chunk.choices[0].delta.get('content') or '',
141
+ text=chunk.choices[0].delta.content or '',
168
142
  is_complete=chunk.choices[0].finish_reason == 'stop',
169
- openai_function_call=chunk.choices[0].delta.get('function_call')
143
+ openai_function_call=chunk.choices[0].delta.function_call
170
144
  )
171
145
 
172
146
  def continue_chat(
173
147
  self,
174
- messages: list[ChatMessage],
148
+ messages: List[ChatMessage],
175
149
  provider_config: ProviderConfig,
176
150
  llm_parameters: LLMParameters
177
151
  ) -> ChatCompletionResponse:
178
- completion = self._call_openai(messages, provider_config, llm_parameters, stream=False)
152
+ completion = cast(ChatCompletion, self._call_openai(messages, provider_config, llm_parameters, stream=False))
179
153
 
180
154
  message_history = copy(messages)
181
- message_history.append(completion.choices[0].message.to_dict())
155
+ message = completion.choices[0].message
156
+ message_history.append({
157
+ "role": message.role or '',
158
+ "content": message.content or ''
159
+ })
182
160
  return ChatCompletionResponse(
183
- content=completion.choices[0].message.content,
161
+ content=message.content or '',
184
162
  message_history=message_history,
185
163
  is_complete=completion.choices[0].finish_reason == "stop"
186
164
  )
187
165
 
188
166
  def continue_chat_stream(
189
167
  self,
190
- messages: list[ChatMessage],
168
+ messages: List[ChatMessage],
191
169
  provider_config: ProviderConfig,
192
170
  llm_parameters: LLMParameters
193
171
  ) -> Generator[CompletionChunk, None, None]:
194
- completion_stream = self._call_openai(messages, provider_config, llm_parameters, stream=True)
172
+ completion_stream = cast(Stream[ChatCompletionChunk],
173
+ self._call_openai(messages, provider_config, llm_parameters, stream=True))
195
174
  for chunk in completion_stream:
196
175
  yield CompletionChunk(
197
- text=chunk.choices[0].delta.get('content', ''),
176
+ text=chunk.choices[0].delta.content or '',
198
177
  is_complete=chunk.choices[0].finish_reason == "stop"
199
178
  )
200
179
 
180
+
181
+ class OpenAIChat(OpenAIChatFlavor):
182
+ record_format_type = "openai_chat"
183
+ _model_params_with_defaults = LLMParameters({
184
+ "model": "gpt-3.5-turbo"
185
+ })
186
+
187
+ def __init__(self) -> None:
188
+ self.client: Optional[openai.OpenAI] = None
189
+
190
+ @property
191
+ def provider(self) -> str:
192
+ return "openai"
193
+
194
+ def get_openai_client(self, openai_config: Optional[OpenAIConfig]) -> openai.OpenAI:
195
+ if self.client:
196
+ return self.client
197
+
198
+ if not openai_config:
199
+ raise FreeplayConfigurationError(
200
+ "Missing OpenAI key. Use a ProviderConfig to specify keys prior to getting completion.")
201
+
202
+ self.client = openai.OpenAI(api_key=openai_config.api_key, base_url=openai_config.base_url)
203
+ return self.client
204
+
201
205
  def _call_openai(
202
206
  self,
203
- messages: list[ChatMessage],
207
+ messages: List[ChatMessage],
204
208
  provider_config: ProviderConfig,
205
209
  llm_parameters: LLMParameters,
206
210
  stream: bool
207
- ) -> Any:
208
- self.configure_openai(provider_config.openai)
209
- llm_parameters.pop('endpoint')
211
+ ) -> Union[ChatCompletion, openai.Stream[ChatCompletionChunk]]:
212
+ client = self.get_openai_client(provider_config.openai)
210
213
  try:
211
- return openai.ChatCompletion.create(
212
- messages=messages,
214
+ return client.chat.completions.create(
215
+ messages=cast(List[ChatCompletionMessageParam], messages),
213
216
  **self.get_model_params(llm_parameters),
214
217
  stream=stream,
215
- ) # type: ignore
216
- except (InvalidRequestError, AuthenticationError) as e:
218
+ )
219
+ except (BadRequestError, AuthenticationError) as e:
217
220
  raise LLMClientError("Unable to call OpenAI") from e
218
221
  except Exception as e:
219
222
  raise LLMServerError("Unable to call OpenAI") from e
220
223
 
221
224
 
222
- class AzureOpenAIChat(OpenAIChat):
225
+ class AzureOpenAIChat(OpenAIChatFlavor):
223
226
  record_format_type = "azure_openai_chat"
224
227
 
228
+ def __init__(self) -> None:
229
+ self.client: Optional[openai.AzureOpenAI] = None
230
+
231
+ @property
232
+ def provider(self) -> str:
233
+ return "azure"
234
+
235
+ def get_azure_client(
236
+ self,
237
+ azure_config: Optional[AzureConfig],
238
+ api_version: Optional[str] = None,
239
+ endpoint: Optional[str] = None,
240
+ deployment: Optional[str] = None,
241
+ ) -> openai.AzureOpenAI:
242
+ if self.client:
243
+ return self.client
244
+
245
+ if not azure_config:
246
+ raise FreeplayConfigurationError(
247
+ "Missing Azure key. Use a ProviderConfig to specify keys prior to getting completion.")
248
+
249
+ self.client = openai.AzureOpenAI(
250
+ api_key=azure_config.api_key,
251
+ api_version=api_version,
252
+ azure_endpoint=endpoint or '',
253
+ azure_deployment=deployment,
254
+ )
255
+ return self.client
256
+
225
257
  def _call_openai(
226
258
  self,
227
- messages: list[ChatMessage],
259
+ messages: List[ChatMessage],
228
260
  provider_config: ProviderConfig,
229
261
  llm_parameters: LLMParameters,
230
262
  stream: bool
@@ -233,28 +265,25 @@ class AzureOpenAIChat(OpenAIChat):
233
265
  deployment_id = llm_parameters.get('deployment_id')
234
266
  resource_name = llm_parameters.get('resource_name')
235
267
  endpoint = f'https://{resource_name}.openai.azure.com'
236
- self.configure_openai(
237
- provider_config.azure,
238
- api_base=endpoint,
239
- api_type='azure',
240
- api_version=api_version
241
- )
242
268
  llm_parameters.pop('resource_name')
269
+
270
+ client = self.get_azure_client(
271
+ azure_config=provider_config.azure,
272
+ api_version=api_version,
273
+ endpoint=endpoint,
274
+ deployment=deployment_id,
275
+ )
276
+
243
277
  try:
244
- return openai.ChatCompletion.create(
245
- messages=messages,
278
+ return client.chat.completions.create(
279
+ messages=cast(List[ChatCompletionMessageParam], messages),
246
280
  **self.get_model_params(llm_parameters),
247
- engine=deployment_id,
248
281
  stream=stream,
249
- ) # type: ignore
250
- except (InvalidRequestError, AuthenticationError) as e:
251
- raise LLMClientError("Unable to call OpenAI") from e
282
+ )
283
+ except (BadRequestError, AuthenticationError) as e:
284
+ raise LLMClientError("Unable to call Azure") from e
252
285
  except Exception as e:
253
- raise LLMServerError("Unable to call OpenAI") from e
254
-
255
- @property
256
- def provider(self) -> str:
257
- return "azure"
286
+ raise LLMServerError("Unable to call Azure") from e
258
287
 
259
288
 
260
289
  class AnthropicClaudeText(Flavor):
@@ -282,7 +311,7 @@ class AnthropicClaudeText(Flavor):
282
311
  self.client = anthropic.Client(api_key=anthropic_config.api_key)
283
312
  return self.client
284
313
 
285
- def format(self, prompt_template: PromptTemplateWithMetadata, variables: dict[str, str]) -> str:
314
+ def format(self, prompt_template: PromptTemplateWithMetadata, variables: Dict[str, str]) -> str:
286
315
  interpolated_prompt = format_template_variables(prompt_template.content, variables)
287
316
  # Anthropic expects a specific Chat format "Human: $PROMPT_TEXT\n\nAssistant:". We add the wrapping for Text.
288
317
  chat_formatted_prompt = f"{anthropic.HUMAN_PROMPT} {interpolated_prompt} {anthropic.AI_PROMPT}"
@@ -361,10 +390,10 @@ class AnthropicClaudeChat(ChatFlavor):
361
390
 
362
391
  # This just formats the prompt for uploading to the record endpoint.
363
392
  # TODO: Move this to a base class.
364
- def format(self, prompt_template: PromptTemplateWithMetadata, variables: dict[str, str]) -> str:
393
+ def format(self, prompt_template: PromptTemplateWithMetadata, variables: Dict[str, str]) -> str:
365
394
  # Extract messages JSON to enable formatting of individual content fields of each message. If we do not
366
395
  # extract the JSON, current variable interpolation will fail on JSON curly braces.
367
- messages_as_json: list[dict[str, str]] = json.loads(prompt_template.content)
396
+ messages_as_json: List[Dict[str, str]] = json.loads(prompt_template.content)
368
397
  formatted_messages = [
369
398
  {
370
399
  "content": format_template_variables(message['content'], variables),
@@ -383,7 +412,7 @@ class AnthropicClaudeChat(ChatFlavor):
383
412
  return 'Human'
384
413
 
385
414
  @staticmethod
386
- def __to_anthropic_chat_format(messages: list[ChatMessage]) -> str:
415
+ def __to_anthropic_chat_format(messages: List[ChatMessage]) -> str:
387
416
  formatted_messages = []
388
417
  for message in messages:
389
418
  formatted_messages.append(f"{message['role']}: {message['content']}")
@@ -393,7 +422,7 @@ class AnthropicClaudeChat(ChatFlavor):
393
422
 
394
423
  def continue_chat(
395
424
  self,
396
- messages: list[ChatMessage],
425
+ messages: List[ChatMessage],
397
426
  provider_config: ProviderConfig,
398
427
  llm_parameters: LLMParameters
399
428
  ) -> ChatCompletionResponse:
@@ -416,7 +445,7 @@ class AnthropicClaudeChat(ChatFlavor):
416
445
 
417
446
  def continue_chat_stream(
418
447
  self,
419
- messages: list[ChatMessage],
448
+ messages: List[ChatMessage],
420
449
  provider_config: ProviderConfig,
421
450
  llm_parameters: LLMParameters
422
451
  ) -> Generator[CompletionChunk, None, None]:
@@ -3,12 +3,18 @@ import logging
3
3
  import time
4
4
  from copy import copy
5
5
  from dataclasses import dataclass
6
- from typing import Optional, Generator, Any, cast, Tuple
6
+ from typing import cast, Any, Dict, Generator, List, Optional, Tuple, Union
7
7
 
8
8
  from . import api_support
9
9
  from .api_support import try_decode
10
- from .completions import PromptTemplates, CompletionResponse, CompletionChunk, PromptTemplateWithMetadata, \
11
- ChatCompletionResponse, ChatMessage
10
+ from .completions import (
11
+ PromptTemplates,
12
+ CompletionResponse,
13
+ CompletionChunk,
14
+ PromptTemplateWithMetadata,
15
+ ChatCompletionResponse,
16
+ ChatMessage
17
+ )
12
18
  from .errors import FreeplayConfigurationError, freeplay_response_error, FreeplayServerError
13
19
  from .flavors import Flavor, ChatFlavor
14
20
  from .llm_parameters import LLMParameters
@@ -19,8 +25,8 @@ from .record import (
19
25
  RecordCallFields
20
26
  )
21
27
 
22
- JsonDom = dict[str, Any]
23
- Variables = dict[str, str]
28
+ JsonDom = Dict[str, Any]
29
+ Variables = Dict[str, str]
24
30
 
25
31
  logger = logging.getLogger(__name__)
26
32
  default_tag = 'latest'
@@ -51,9 +57,9 @@ class CallSupport:
51
57
  project_id: str,
52
58
  tag: str,
53
59
  test_run_id: Optional[str] = None,
54
- metadata: Optional[dict[str, str|int|float]] = None
60
+ metadata: Optional[Dict[str, Union[str,int,float]]] = None
55
61
  ) -> JsonDom:
56
- request_body: dict[str, Any] = {}
62
+ request_body: Dict[str, Any] = {}
57
63
  if test_run_id is not None:
58
64
  request_body['test_run_id'] = test_run_id
59
65
  if metadata is not None:
@@ -65,7 +71,7 @@ class CallSupport:
65
71
  payload=request_body)
66
72
 
67
73
  if response.status_code == 201:
68
- return cast(dict[str, Any], json.loads(response.content))
74
+ return cast(Dict[str, Any], json.loads(response.content))
69
75
  else:
70
76
  raise freeplay_response_error('Error while creating a session.', response)
71
77
 
@@ -93,8 +99,8 @@ class CallSupport:
93
99
  tag: str,
94
100
  target_template: PromptTemplateWithMetadata,
95
101
  variables: Variables,
96
- message_history: list[ChatMessage],
97
- new_messages: Optional[list[ChatMessage]],
102
+ message_history: List[ChatMessage],
103
+ new_messages: Optional[List[ChatMessage]],
98
104
  test_run_id: Optional[str] = None,
99
105
  completion_parameters: Optional[LLMParameters] = None) -> ChatCompletionResponse:
100
106
  # make call
@@ -142,7 +148,7 @@ class CallSupport:
142
148
  tag: str,
143
149
  target_template: PromptTemplateWithMetadata,
144
150
  variables: Variables,
145
- message_history: list[ChatMessage],
151
+ message_history: List[ChatMessage],
146
152
  test_run_id: Optional[str] = None,
147
153
  completion_parameters: Optional[LLMParameters] = None
148
154
  ) -> Generator[CompletionChunk, None, None]:
@@ -189,7 +195,7 @@ class CallSupport:
189
195
  session_id: str,
190
196
  prompts: PromptTemplates,
191
197
  template_name: str,
192
- variables: dict[str, str],
198
+ variables: Dict[str, str],
193
199
  flavor: Optional[Flavor],
194
200
  provider_config: ProviderConfig,
195
201
  tag: str,
@@ -239,7 +245,7 @@ class CallSupport:
239
245
  session_id: str,
240
246
  prompts: PromptTemplates,
241
247
  template_name: str,
242
- variables: dict[str, str],
248
+ variables: Dict[str, str],
243
249
  flavor: Optional[Flavor],
244
250
  provider_config: ProviderConfig,
245
251
  tag: str,
@@ -310,7 +316,7 @@ class Session:
310
316
  def get_completion(
311
317
  self,
312
318
  template_name: str,
313
- variables: dict[str, str],
319
+ variables: Dict[str, str],
314
320
  flavor: Optional[Flavor] = None,
315
321
  **kwargs: Any
316
322
  ) -> CompletionResponse:
@@ -328,7 +334,7 @@ class Session:
328
334
  def get_completion_stream(
329
335
  self,
330
336
  template_name: str,
331
- variables: dict[str, str],
337
+ variables: Dict[str, str],
332
338
  flavor: Optional[Flavor] = None,
333
339
  **kwargs: Any
334
340
  ) -> Generator[CompletionChunk, None, None]:
@@ -356,7 +362,7 @@ class ChatSession(Session):
356
362
  variables: Variables,
357
363
  tag: str = default_tag,
358
364
  test_run_id: Optional[str] = None,
359
- messages: Optional[list[ChatMessage]] = None
365
+ messages: Optional[List[ChatMessage]] = None
360
366
  ) -> None:
361
367
  super().__init__(call_support, session_id, prompts, flavor, provider_config, tag, test_run_id)
362
368
  # A Chat Session tracks the template_name and variables for a set of chat completions.
@@ -370,7 +376,7 @@ class ChatSession(Session):
370
376
  def last_message(self) -> Optional[ChatMessage]:
371
377
  return self.message_history[len(self.message_history) - 1]
372
378
 
373
- def store_new_messages(self, new_messages: list[ChatMessage]) -> None:
379
+ def store_new_messages(self, new_messages: List[ChatMessage]) -> None:
374
380
  for message in new_messages:
375
381
  self.message_history.append({
376
382
  "role": message["role"],
@@ -412,7 +418,7 @@ class ChatSession(Session):
412
418
 
413
419
  def continue_chat(
414
420
  self,
415
- new_messages: Optional[list[ChatMessage]] = None,
421
+ new_messages: Optional[List[ChatMessage]] = None,
416
422
  **kwargs: Any
417
423
  ) -> ChatCompletionResponse:
418
424
 
@@ -438,7 +444,7 @@ class ChatSession(Session):
438
444
 
439
445
  def continue_chat_stream(
440
446
  self,
441
- new_messages: Optional[list[ChatMessage]] = None,
447
+ new_messages: Optional[List[ChatMessage]] = None,
442
448
  **kwargs: Any
443
449
  ) -> Generator[CompletionChunk, None, None]:
444
450
  new_messages = new_messages or []
@@ -468,7 +474,7 @@ class FreeplayTestRun:
468
474
  flavor: Optional[Flavor],
469
475
  provider_config: ProviderConfig,
470
476
  test_run_id: str,
471
- inputs: list[dict[str, str]]
477
+ inputs: List[Dict[str, str]]
472
478
  ):
473
479
  self.call_support = call_support
474
480
  self.flavor = flavor
@@ -476,7 +482,7 @@ class FreeplayTestRun:
476
482
  self.test_run_id = test_run_id
477
483
  self.inputs = inputs
478
484
 
479
- def get_inputs(self) -> list[dict[str, str]]:
485
+ def get_inputs(self) -> List[Dict[str, str]]:
480
486
  return self.inputs
481
487
 
482
488
  def create_session(self, project_id: str, tag: str = default_tag) -> Session:
@@ -501,7 +507,7 @@ class Freeplay:
501
507
  **kwargs: Any
502
508
  ) -> None:
503
509
  if not freeplay_api_key or not freeplay_api_key.strip():
504
- raise FreeplayConfigurationError("Freeplay API key not set. It must be set to the Freeplay API.")
510
+ raise FreeplayConfigurationError("Freeplay API key not set. It must be set to use the Freeplay API.")
505
511
  provider_config.validate()
506
512
 
507
513
  self.__record_processor = record_processor or DefaultRecordProcessor(freeplay_api_key, api_base)
@@ -522,7 +528,7 @@ class Freeplay:
522
528
  project_id: str,
523
529
  session_id: str,
524
530
  template_name: str,
525
- variables: dict[str, str],
531
+ variables: Dict[str, str],
526
532
  tag: str = default_tag,
527
533
  flavor: Optional[Flavor] = None,
528
534
  **kwargs: Any
@@ -544,10 +550,10 @@ class Freeplay:
544
550
  self,
545
551
  project_id: str,
546
552
  template_name: str,
547
- variables: dict[str, str],
553
+ variables: Dict[str, str],
548
554
  tag: str = default_tag,
549
555
  flavor: Optional[Flavor] = None,
550
- metadata: Optional[dict[str, str|int|float]] = None,
556
+ metadata: Optional[Dict[str, Union[str,int,float]]] = None,
551
557
  **kwargs: Any
552
558
  ) -> CompletionResponse:
553
559
  project_session = self.call_support.create_session(project_id, tag, None, metadata)
@@ -567,10 +573,10 @@ class Freeplay:
567
573
  self,
568
574
  project_id: str,
569
575
  template_name: str,
570
- variables: dict[str, str],
576
+ variables: Dict[str, str],
571
577
  tag: str = default_tag,
572
578
  flavor: Optional[Flavor] = None,
573
- metadata: Optional[dict[str, str|int|float]] = None,
579
+ metadata: Optional[Dict[str, Union[str,int,float]]] = None,
574
580
  **kwargs: Any
575
581
  ) -> Generator[CompletionChunk, None, None]:
576
582
  project_session = self.call_support.create_session(project_id, tag, None, metadata)
@@ -611,7 +617,7 @@ class Freeplay:
611
617
  template_name: str,
612
618
  variables: Variables,
613
619
  tag: str = default_tag,
614
- metadata: Optional[dict[str, str|int|float]] = None,
620
+ metadata: Optional[Dict[str, Union[str,int,float]]] = None,
615
621
  **kwargs: Any
616
622
  ) -> Tuple[ChatSession, ChatCompletionResponse]:
617
623
  session = self.__create_chat_session(project_id, tag, template_name, variables, metadata)
@@ -625,7 +631,7 @@ class Freeplay:
625
631
  session_id: str,
626
632
  variables: Variables,
627
633
  tag: str = default_tag,
628
- messages: Optional[list[ChatMessage]] = None,
634
+ messages: Optional[List[ChatMessage]] = None,
629
635
  flavor: Optional[ChatFlavor] = None) -> ChatSession:
630
636
  prompts = self.call_support.get_prompts(project_id, tag)
631
637
  chat_flavor = flavor or require_chat_flavor(self.client_flavor) if self.client_flavor else None
@@ -647,7 +653,7 @@ class Freeplay:
647
653
  template_name: str,
648
654
  variables: Variables,
649
655
  tag: str = default_tag,
650
- metadata: Optional[dict[str, str|int|float]] = None,
656
+ metadata: Optional[Dict[str, Union[str,int,float]]] = None,
651
657
  **kwargs: Any
652
658
  ) -> Tuple[ChatSession, Generator[CompletionChunk, None, None]]:
653
659
  """Returns a chat session, the base prompt template messages, and a streamed response from the LLM."""
@@ -661,7 +667,7 @@ class Freeplay:
661
667
  tag: str,
662
668
  template_name: str,
663
669
  variables: Variables,
664
- metadata: Optional[dict[str, str|int|float]] = None) -> ChatSession:
670
+ metadata: Optional[Dict[str, Union[str,int,float]]] = None) -> ChatSession:
665
671
  chat_flavor = require_chat_flavor(self.client_flavor) if self.client_flavor else None
666
672
 
667
673
  project_session = self.call_support.create_session(project_id, tag, None, metadata)
@@ -700,7 +706,8 @@ def require_chat_flavor(flavor: Flavor) -> ChatFlavor:
700
706
 
701
707
  return flavor
702
708
 
703
- def check_all_values_string_or_number(metadata: Optional[dict[str, str|int|float]]) -> None:
709
+
710
+ def check_all_values_string_or_number(metadata: Optional[Dict[str, Union[str,int,float]]]) -> None:
704
711
  if metadata:
705
712
  for key, value in metadata.items():
706
713
  if not isinstance(value, (str, int, float)):
@@ -0,0 +1,106 @@
1
+ import dataclasses
2
+ import json
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+ from stat import S_IREAD, S_IRGRP, S_IROTH, S_IWUSR
7
+
8
+ import click
9
+
10
+ from .completions import PromptTemplates, PromptTemplateWithMetadata
11
+ from .errors import FreeplayClientError, FreeplayServerError
12
+ from .freeplay_thin import FreeplayThin
13
+
14
+
15
+ @click.group()
16
+ def cli() -> None:
17
+ pass
18
+
19
+
20
+ @cli.command()
21
+ @click.option("--project-id", required=True, help="The Freeplay project ID.")
22
+ @click.option("--environment", required=True, help="The environment from which the prompts will be pulled.")
23
+ @click.option("--output-dir", required=True, help="The directory where the prompts will be saved.")
24
+ def download(project_id: str, environment: str, output_dir: str) -> None:
25
+ if "FREEPLAY_API_KEY" not in os.environ:
26
+ print("FREEPLAY_API_KEY is not set. It is required to run the freeplay command.", file=sys.stderr)
27
+ exit(4)
28
+
29
+ if "FREEPLAY_SUBDOMAIN" not in os.environ:
30
+ print("FREEPLAY_SUBDOMAIN is not set. It is required to run the freeplay command.", file=sys.stderr)
31
+ exit(4)
32
+
33
+ FREEPLAY_API_KEY = os.environ["FREEPLAY_API_KEY"]
34
+ freeplay_api_url = f'https://{os.environ["FREEPLAY_SUBDOMAIN"]}.freeplay.ai/api'
35
+
36
+ if "FREEPLAY_API_URL" in os.environ:
37
+ freeplay_api_url = f'{os.environ["FREEPLAY_API_URL"]}/api'
38
+ click.echo("Using URL override for Freeplay specified in the FREEPLAY_API_URL environment variable")
39
+
40
+ click.echo("Downloading prompts for project %s, environment %s, to directory %s from %s" %
41
+ (project_id, environment, output_dir, freeplay_api_url))
42
+
43
+ fp_client = FreeplayThin(
44
+ freeplay_api_key=FREEPLAY_API_KEY,
45
+ api_base=freeplay_api_url
46
+ )
47
+
48
+ try:
49
+ prompts: PromptTemplates = fp_client.get_prompts(project_id, tag=environment)
50
+ click.echo("Found %s prompt templates" % len(prompts.templates))
51
+
52
+ for prompt in prompts.templates:
53
+ __write_single_file(environment, output_dir, project_id, prompt)
54
+ except FreeplayClientError as e:
55
+ print("Error downloading templates: %s.\nIs your project ID correct?" % e, file=sys.stderr)
56
+ exit(1)
57
+ except FreeplayServerError as e:
58
+ print("Error on Freeplay's servers downloading templates: %s.\nTry again after a short wait." % e,
59
+ file=sys.stderr)
60
+ exit(2)
61
+ except Exception as e:
62
+ print("Error downloading templates: %s" % e, file=sys.stderr)
63
+ exit(3)
64
+
65
+
66
+ def __write_single_file(
67
+ environment: str,
68
+ output_dir: str,
69
+ project_id: str,
70
+ prompt: PromptTemplateWithMetadata
71
+ ) -> None:
72
+ directory = __root_dir(environment, output_dir, project_id)
73
+ basename = f'{prompt.name}'
74
+ prompt_path = directory / f'{basename}.json'
75
+ click.echo("Writing prompt file: %s" % prompt_path)
76
+
77
+ full_dict = dataclasses.asdict(prompt)
78
+ del full_dict['prompt_template_id']
79
+ del full_dict['prompt_template_version_id']
80
+ del full_dict['name']
81
+ del full_dict['content']
82
+
83
+ output_dict = {
84
+ 'prompt_template_id': prompt.prompt_template_id,
85
+ 'prompt_template_version_id': prompt.prompt_template_version_id,
86
+ 'name': prompt.name,
87
+ 'content': prompt.content,
88
+ 'metadata': full_dict
89
+ }
90
+
91
+ # Make sure it's owner writable if it already exists
92
+ if prompt_path.is_file():
93
+ os.chmod(prompt_path, S_IWUSR | S_IREAD)
94
+
95
+ with prompt_path.open(mode='w') as f:
96
+ f.write(json.dumps(output_dict, sort_keys=True, indent=4))
97
+ f.write('\n')
98
+
99
+ # Make the file read-only to discourage local changes
100
+ os.chmod(prompt_path, S_IREAD | S_IRGRP | S_IROTH)
101
+
102
+
103
+ def __root_dir(environment: str, output_dir: str, project_id: str) -> Path:
104
+ directory = Path(output_dir) / "freeplay" / "prompts" / project_id / environment
105
+ os.makedirs(directory, exist_ok=True)
106
+ return directory
@@ -0,0 +1,21 @@
1
+ from .completions import PromptTemplates
2
+ from .errors import FreeplayConfigurationError
3
+ from .freeplay import CallSupport
4
+ from .record import DefaultRecordProcessor
5
+
6
+
7
+ class FreeplayThin:
8
+ def __init__(
9
+ self,
10
+ freeplay_api_key: str,
11
+ api_base: str
12
+ ) -> None:
13
+ if not freeplay_api_key or not freeplay_api_key.strip():
14
+ raise FreeplayConfigurationError("Freeplay API key not set. It must be set to the Freeplay API.")
15
+
16
+ self.call_support = CallSupport(freeplay_api_key, api_base, DefaultRecordProcessor(freeplay_api_key, api_base))
17
+ self.freeplay_api_key = freeplay_api_key
18
+ self.api_base = api_base
19
+
20
+ def get_prompts(self, project_id: str, tag: str) -> PromptTemplates:
21
+ return self.call_support.get_prompts(project_id=project_id, tag=tag)
@@ -1,14 +1,12 @@
1
1
  import copy
2
2
  import logging
3
- from dataclasses import dataclass
4
- from typing import Any, Optional
3
+ from typing import Any, Dict, Optional
5
4
 
6
5
  logger = logging.getLogger(__name__)
7
6
 
8
7
 
9
- @dataclass
10
- class LLMParameters(dict[str, Any]):
11
- def __init__(self, members: dict[str, Any]) -> None:
8
+ class LLMParameters(Dict[str, Any]):
9
+ def __init__(self, members: Dict[str, Any]) -> None:
12
10
  super().__init__(members)
13
11
 
14
12
  @classmethod
@@ -7,15 +7,7 @@ from .errors import FreeplayConfigurationError
7
7
  @dataclass
8
8
  class OpenAIConfig:
9
9
  api_key: str
10
- api_base: Optional[str]
11
-
12
- def __init__(
13
- self,
14
- api_key: str,
15
- api_base: Optional[str] = None,
16
- ) -> None:
17
- self.api_key = api_key
18
- self.api_base = api_base
10
+ base_url: Optional[str] = None
19
11
 
20
12
  def validate(self) -> None:
21
13
  if not self.api_key or not self.api_key.strip():
@@ -24,19 +16,8 @@ class OpenAIConfig:
24
16
 
25
17
  @dataclass
26
18
  class AzureConfig(OpenAIConfig):
27
- engine: Optional[str]
28
- api_version: Optional[str]
29
-
30
- def __init__(
31
- self,
32
- api_key: str,
33
- api_base: Optional[str] = None,
34
- engine: Optional[str] = None,
35
- api_version: Optional[str] = None
36
- ):
37
- super().__init__(api_key, api_base)
38
- self.api_version = api_version
39
- self.engine = engine
19
+ engine: Optional[str] = None
20
+ api_version: Optional[str] = None
40
21
 
41
22
  def validate(self) -> None:
42
23
  super().validate()
@@ -61,7 +42,7 @@ class ProviderConfig:
61
42
  azure: Optional[AzureConfig] = None
62
43
 
63
44
  def validate(self) -> None:
64
- if self.anthropic is None and self.openai is None:
45
+ if all(config is None for config in [self.anthropic, self.openai, self.azure]):
65
46
  FreeplayConfigurationError("At least one provider key must be set in ProviderConfig.")
66
47
 
67
48
  if self.openai is not None:
@@ -1,6 +1,6 @@
1
1
  import logging
2
2
  from dataclasses import dataclass
3
- from typing import Optional, Any
3
+ from typing import Any, Dict, Optional
4
4
  from abc import abstractmethod, ABC
5
5
 
6
6
  from .llm_parameters import LLMParameters
@@ -18,7 +18,7 @@ class RecordCallFields:
18
18
  session_id: str
19
19
  start: float
20
20
  target_template: PromptTemplateWithMetadata
21
- variables: dict[str, str]
21
+ variables: Dict[str, str]
22
22
  tag: str
23
23
  test_run_id: Optional[str]
24
24
  record_format_type: Optional[str]
@@ -60,7 +60,7 @@ class DefaultRecordProcessor(RecordProcessor):
60
60
  ) -> None:
61
61
  record_payload = {
62
62
  "session_id": record_call.session_id,
63
- "project_version_id": record_call.target_template.project_version_id,
63
+ "project_version_id": record_call.target_template.prompt_template_version_id,
64
64
  "prompt_template_id": record_call.target_template.prompt_template_id,
65
65
  "start_time": record_call.start,
66
66
  "end_time": record_call.end,
@@ -0,0 +1,21 @@
1
+ import pystache # type: ignore
2
+ from pydantic import RootModel, ValidationError
3
+ from typing import Any, Dict, List, Union
4
+
5
+ from .errors import FreeplayError
6
+
7
+ InputVariable = RootModel[Union[Dict[str, "InputVariable"], List["InputVariable"], str, int, bool, float]]
8
+ InputVariable.model_rebuild()
9
+ InputVariables = Dict[str, Union[str, int, bool, Dict[str, Any], List[Any]]]
10
+ PydanticInputVariables = RootModel[Dict[str, InputVariable]]
11
+
12
+ def format_template_variables(template: str, variables: Any) -> str:
13
+ # Validate that the variables are of the correct type, and do not include functions or None values.
14
+ try:
15
+ PydanticInputVariables.model_validate(variables)
16
+ except ValidationError as err:
17
+ raise FreeplayError('Variables must be a string, number, bool, or a possibly nested list or dict of strings, numbers and booleans.')
18
+
19
+ # When rendering mustache, do not escape HTML special characters.
20
+ rendered: str = pystache.Renderer(escape=lambda s: s).render(template, variables)
21
+ return rendered
@@ -1,11 +0,0 @@
1
- import re
2
-
3
- from .errors import FreeplayError
4
-
5
- variable_regex = re.compile(r"{{(\w+)}}")
6
-
7
- def format_template_variables(template_content: str, variables: dict[str, str]) -> str:
8
- try:
9
- return variable_regex.sub(lambda match: variables[match.group(1)], template_content)
10
- except KeyError as e:
11
- raise FreeplayError(f"Missing variable with key: {e}.")
File without changes