novelai-python 0.2.3__tar.gz → 0.2.5__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.
Files changed (45) hide show
  1. {novelai_python-0.2.3 → novelai_python-0.2.5}/PKG-INFO +2 -2
  2. {novelai_python-0.2.3 → novelai_python-0.2.5}/README.md +1 -1
  3. {novelai_python-0.2.3 → novelai_python-0.2.5}/pyproject.toml +1 -1
  4. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/_exceptions.py +7 -0
  5. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/_response/user/subscription.py +28 -2
  6. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/credential/ApiToken.py +7 -1
  7. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/credential/JwtToken.py +8 -1
  8. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/credential/UserAuth.py +7 -1
  9. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/credential/_base.py +1 -1
  10. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/sdk/ai/generate_image.py +133 -22
  11. novelai_python-0.2.5/src/novelai_python/sdk/schema.py +36 -0
  12. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/sdk/user/__init__.py +1 -0
  13. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/sdk/user/information.py +31 -8
  14. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/sdk/user/login.py +23 -5
  15. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/sdk/user/subscription.py +38 -8
  16. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/server.py +1 -1
  17. novelai_python-0.2.5/src/novelai_python/utils/useful.py +7 -0
  18. {novelai_python-0.2.3 → novelai_python-0.2.5}/tests/test_server_run.py +1 -1
  19. {novelai_python-0.2.3 → novelai_python-0.2.5}/tests/test_user_information.py +3 -0
  20. {novelai_python-0.2.3 → novelai_python-0.2.5}/tests/test_user_login.py +5 -0
  21. {novelai_python-0.2.3 → novelai_python-0.2.5}/tests/test_user_subscription.py +4 -0
  22. {novelai_python-0.2.3 → novelai_python-0.2.5}/LICENSE +0 -0
  23. {novelai_python-0.2.3 → novelai_python-0.2.5}/NOTICE.MD +0 -0
  24. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/__init__.py +0 -0
  25. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/_response/__init__.py +0 -0
  26. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/_response/ai/__init__.py +0 -0
  27. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/_response/ai/generate_image.py +0 -0
  28. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/_response/user/__init__.py +0 -0
  29. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/_response/user/information.py +0 -0
  30. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/_response/user/login.py +0 -0
  31. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/credential/__init__.py +0 -0
  32. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/sdk/__init__.py +0 -0
  33. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/sdk/ai/LICENSE +0 -0
  34. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/sdk/ai/__init__.py +0 -0
  35. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/utils/__init__.py +0 -0
  36. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/utils/encode.py +0 -0
  37. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/utils/hash.py +0 -0
  38. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/utils/random_prompt/__init__.py +0 -0
  39. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/utils/random_prompt/tag.py +0 -0
  40. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/utils/random_prompt/tag_artist.py +0 -0
  41. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/utils/random_prompt/tag_character.py +0 -0
  42. {novelai_python-0.2.3 → novelai_python-0.2.5}/src/novelai_python/utils/random_prompt/tag_nsfw.py +0 -0
  43. {novelai_python-0.2.3 → novelai_python-0.2.5}/tests/__init__.py +0 -0
  44. {novelai_python-0.2.3 → novelai_python-0.2.5}/tests/test_random_prompt.py +0 -0
  45. {novelai_python-0.2.3 → novelai_python-0.2.5}/tests/test_server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: novelai-python
3
- Version: 0.2.3
3
+ Version: 0.2.5
4
4
  Summary: Novelai Python Binding With Pydantic
5
5
  Author-Email: sudoskys <coldlando@hotmail.com>
6
6
  License: Apache License 2.0
@@ -80,7 +80,7 @@ async def main():
80
80
  cost = gen.calculate_cost(is_opus=True)
81
81
  print(f"charge: {cost} if you are vip3")
82
82
 
83
- resp = gen.generate(session=globe_s)
83
+ resp = gen.request(session=globe_s)
84
84
  resp: ImageGenerateResp
85
85
  print(resp.meta)
86
86
 
@@ -54,7 +54,7 @@ async def main():
54
54
  cost = gen.calculate_cost(is_opus=True)
55
55
  print(f"charge: {cost} if you are vip3")
56
56
 
57
- resp = gen.generate(session=globe_s)
57
+ resp = gen.request(session=globe_s)
58
58
  resp: ImageGenerateResp
59
59
  print(resp.meta)
60
60
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "novelai-python"
3
- version = "0.2.3"
3
+ version = "0.2.5"
4
4
  description = "Novelai Python Binding With Pydantic"
5
5
  authors = [
6
6
  { name = "sudoskys", email = "coldlando@hotmail.com" },
@@ -13,6 +13,13 @@ class NovelAiError(Exception):
13
13
  pass
14
14
 
15
15
 
16
+ class InvalidRequestHeader(NovelAiError):
17
+ """
18
+ InvalidRequestHeader is raised when the request header is invalid.
19
+ """
20
+ pass
21
+
22
+
16
23
  class APIError(NovelAiError):
17
24
  """
18
25
  APIError is raised when the API returns an error.
@@ -45,8 +45,34 @@ class SubscriptionResp(BaseModel):
45
45
 
46
46
  @property
47
47
  def anlas_left(self):
48
- return self.trainingStepsLeft.fixedTrainingStepsLeft
48
+ return self.trainingStepsLeft.fixedTrainingStepsLeft + self.trainingStepsLeft.purchasedTrainingSteps
49
49
 
50
50
  @property
51
51
  def is_unlimited_image_generation(self):
52
- return self.perks.unlimitedImageGeneration
52
+ return self.perks.unlimitedImageGeneration and self.perks.imageGeneration
53
+
54
+ @property
55
+ def get_tier_name(self):
56
+ if self.tier == 0:
57
+ return "Paper"
58
+ elif self.tier == 1:
59
+ return "Tablet"
60
+ elif self.tier == 2:
61
+ return "Scroll"
62
+ elif self.tier == 3:
63
+ return "Opus"
64
+ else:
65
+ return "Unknown"
66
+
67
+ @property
68
+ def limit_perks(self):
69
+ perks = []
70
+ if not self.perks.imageGeneration:
71
+ perks.append("imageGeneration")
72
+ if not self.perks.voiceGeneration:
73
+ perks.append("voiceGeneration")
74
+ if not self.perks.unlimitedImageGeneration:
75
+ perks.append("unlimitedImageGeneration")
76
+ if not self.perks.unlimitedMaxPriority:
77
+ perks.append("unlimitedMaxPriority")
78
+ return perks
@@ -17,14 +17,20 @@ class ApiCredential(CredentialBase):
17
17
  api_token: SecretStr = Field(None, description="api token")
18
18
  _session: AsyncSession = None
19
19
 
20
- async def get_session(self, timeout: int = 180):
20
+ async def get_session(self, timeout: int = 180, update_headers: dict = None):
21
+ if update_headers is None:
22
+ update_headers = {}
21
23
  if not self._session:
22
24
  self._session = AsyncSession(timeout=timeout, headers={
25
+ "Accept": "*/*",
26
+ "Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
27
+ "Accept-Encoding": "gzip, deflate, br",
23
28
  "Authorization": f"Bearer {self.api_token.get_secret_value()}",
24
29
  "Content-Type": "application/json",
25
30
  "Origin": "https://novelai.net",
26
31
  "Referer": "https://novelai.net/",
27
32
  }, impersonate="chrome110")
33
+ self._session.headers.update(update_headers)
28
34
  return self._session
29
35
 
30
36
  @field_validator('api_token')
@@ -17,14 +17,21 @@ class JwtCredential(CredentialBase):
17
17
  jwt_token: SecretStr = Field(None, description="jwt token")
18
18
  _session: AsyncSession = None
19
19
 
20
- async def get_session(self, timeout: int = 180):
20
+ async def get_session(self, timeout: int = 180, update_headers: dict = None):
21
+ if update_headers is None:
22
+ update_headers = {}
21
23
  if not self._session:
22
24
  self._session = AsyncSession(timeout=timeout, headers={
25
+ "Accept": "*/*",
26
+ "Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
27
+ "Accept-Encoding": "gzip, deflate, br",
23
28
  "Authorization": f"Bearer {self.jwt_token.get_secret_value()}",
24
29
  "Content-Type": "application/json",
25
30
  "Origin": "https://novelai.net",
26
31
  "Referer": "https://novelai.net/",
27
32
  }, impersonate="chrome110")
33
+ assert isinstance(update_headers, dict), "update_headers must be a dict"
34
+ self._session.headers.update(update_headers)
28
35
  return self._session
29
36
 
30
37
  @field_validator('jwt_token')
@@ -21,16 +21,22 @@ class LoginCredential(CredentialBase):
21
21
  _session: Optional[AsyncSession] = None
22
22
  _update_at: Optional[int] = None
23
23
 
24
- async def get_session(self, timeout: int = 180):
24
+ async def get_session(self, timeout: int = 180, update_headers: dict = None):
25
+ if update_headers is None:
26
+ update_headers = {}
25
27
  # 30 天有效期
26
28
  if not self._session or int(time.time()) - self._update_at > 29 * 24 * 60 * 60:
27
29
  from ..sdk import Login
28
30
  resp = await Login.build(user_name=self.username, password=self.password.get_secret_value()).request()
29
31
  self._session = AsyncSession(timeout=timeout, headers={
32
+ "Accept": "*/*",
33
+ "Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
34
+ "Accept-Encoding": "gzip, deflate, br",
30
35
  "Authorization": f"Bearer {resp.accessToken}",
31
36
  "Content-Type": "application/json",
32
37
  "Origin": "https://novelai.net",
33
38
  "Referer": "https://novelai.net/",
34
39
  }, impersonate="chrome110")
35
40
  self._update_at = int(time.time())
41
+ self._session.headers.update(update_headers)
36
42
  return self._session
@@ -11,5 +11,5 @@ class CredentialBase(BaseModel):
11
11
  _session: AsyncSession = None
12
12
  """会话"""
13
13
 
14
- async def get_session(self, timeout: int = 180):
14
+ async def get_session(self, timeout: int = 180, update_headers: dict = None):
15
15
  raise NotImplementedError
@@ -3,20 +3,24 @@
3
3
  # @Author : sudoskys
4
4
  # @File : generate_image.py
5
5
  # @Software: PyCharm
6
+ import base64
6
7
  import json
7
8
  import math
8
9
  import random
9
10
  from enum import Enum, IntEnum
10
11
  from io import BytesIO
11
12
  from typing import Optional, Union
13
+ from urllib.parse import urlparse
12
14
  from zipfile import ZipFile
13
15
 
16
+ import curl_cffi
14
17
  import httpx
15
- from curl_cffi.requests import AsyncSession, RequestsError
18
+ from curl_cffi.requests import AsyncSession
16
19
  from loguru import logger
17
20
  from pydantic import BaseModel, ConfigDict, PrivateAttr, field_validator, model_validator, Field
18
21
  from typing_extensions import override
19
22
 
23
+ from ..schema import ApiBaseModel
20
24
  from ..._exceptions import APIError, AuthError
21
25
  from ..._response import ImageGenerateResp
22
26
  from ...credential import CredentialBase
@@ -48,14 +52,39 @@ class UCPreset(IntEnum):
48
52
 
49
53
  class Action(Enum):
50
54
  GENERATE = "generate"
55
+ """Generate Image"""
51
56
  IMG2IMG = "img2img"
57
+ """Image to Image"""
52
58
  INFILL = "infill"
59
+ """Inpainting"""
53
60
 
54
61
 
55
62
  class Model(Enum):
56
63
  NAI_DIFFUSION_3 = "nai-diffusion-3"
57
64
  NAI_DIFFUSION_3_INPAINTING = "nai-diffusion-3-inpainting"
58
65
 
66
+ NAI_DIFFUSION = "nai-diffusion"
67
+ NAI_DIFFUSION_INPAINTING = "nai-diffusion-inpainting"
68
+
69
+ SAFE_DIFFUSION = "safe-diffusion"
70
+ SAFE_DIFFUSION_INPAINTING = "safe-diffusion-inpainting"
71
+
72
+ NAI_DIFFUSION_FURRY = "nai-diffusion-furry"
73
+ FURRY_DIFFUSION_INPAINTING = "furry-diffusion-inpainting"
74
+
75
+
76
+ class ControlNetModel(Enum):
77
+ HED = "hed"
78
+ """边缘检测"""
79
+ MIDAS = "midas"
80
+ """景深"""
81
+ FAKE_SCRIBBLE = "fake_scribble"
82
+ """伪涂鸦"""
83
+ M_LSD = "mlsd"
84
+ """(建筑)线条检测"""
85
+ LANDSCAPER = "uniformer"
86
+ """风景生成"""
87
+
59
88
 
60
89
  class Resolution(Enum):
61
90
  RES_512_768 = (512, 768)
@@ -71,25 +100,28 @@ class Resolution(Enum):
71
100
  RES_1920_1088 = (1920, 1088)
72
101
 
73
102
 
74
- class GenerateImageInfer(BaseModel):
103
+ class GenerateImageInfer(ApiBaseModel):
75
104
  _endpoint: Optional[str] = PrivateAttr("https://api.novelai.net")
76
105
  _charge: bool = PrivateAttr(False)
77
106
 
78
107
  class Params(BaseModel):
79
108
  # Inpaint
80
109
  add_original_image: Optional[bool] = False
81
- mask: Optional[str] = None
110
+ mask: Optional[Union[str, bytes]] = None # img2img,base64
82
111
 
83
112
  cfg_rescale: Optional[float] = Field(0, ge=0, le=1, multiple_of=0.02)
113
+ """Prompt Guidance Rescale"""
84
114
  controlnet_strength: Optional[float] = Field(1.0, ge=0.1, le=2, multiple_of=0.1)
115
+ """ControlNet Strength"""
85
116
  dynamic_thresholding: Optional[bool] = False
117
+ """Reduce artifacts caused by high prompt guidance values."""
86
118
  height: Optional[int] = Field(1216, ge=64, le=49152)
87
119
  # Img2Img
88
- image: Optional[str] = None # img2img,base64
89
- strength: Optional[float] = Field(default=0.3, ge=0.01, le=0.99, multiple_of=0.01)
120
+ image: Optional[Union[str, bytes]] = None # img2img,base64
121
+ strength: Optional[float] = Field(default=0.5, ge=0.01, le=0.99, multiple_of=0.01)
90
122
  noise: Optional[float] = Field(default=0, ge=0, le=0.99, multiple_of=0.01)
91
123
  controlnet_condition: Optional[str] = None
92
- controlnet_model: Optional[str] = None
124
+ controlnet_model: Optional[ControlNetModel] = None
93
125
 
94
126
  n_samples: Optional[int] = Field(1, ge=1, le=8)
95
127
  negative_prompt: Optional[str] = ''
@@ -103,6 +135,7 @@ class GenerateImageInfer(BaseModel):
103
135
  qualityToggle: Optional[bool] = True
104
136
  sampler: Optional[Sampler] = Sampler.K_EULER
105
137
  scale: Optional[float] = Field(6.0, ge=0, le=10, multiple_of=0.1)
138
+ """Prompt Guidance"""
106
139
  # Seed
107
140
  seed: Optional[int] = Field(
108
141
  default_factory=lambda: random.randint(0, 4294967295 - 7),
@@ -120,6 +153,7 @@ class GenerateImageInfer(BaseModel):
120
153
  steps: Optional[int] = Field(28, ge=1, le=50)
121
154
  ucPreset: Optional[UCPreset] = 0
122
155
  uncond_scale: Optional[float] = Field(1.0, ge=0, le=1.5, multiple_of=0.05)
156
+ """Undesired Content Strength"""
123
157
  width: Optional[int] = Field(832, ge=64, le=49152)
124
158
 
125
159
  @model_validator(mode="after")
@@ -130,6 +164,22 @@ class GenerateImageInfer(BaseModel):
130
164
  raise ValueError('Invalid Model Params For img2img2 mode... image should match add_original_image!')
131
165
  return self
132
166
 
167
+ @field_validator('mask')
168
+ def mask_validator(cls, v: Union[str, bytes]):
169
+ if isinstance(v, str) and v.startswith("data:image/"):
170
+ raise ValueError("Invalid image format, must be base64 encoded.")
171
+ if isinstance(v, bytes):
172
+ return base64.b64encode(v).decode("utf-8")
173
+ return v
174
+
175
+ @field_validator('image')
176
+ def image_validator(cls, v: Union[str, bytes]):
177
+ if isinstance(v, str) and v.startswith("data:image/"):
178
+ raise ValueError("Invalid image format, must be base64 encoded.")
179
+ if isinstance(v, bytes):
180
+ return base64.b64encode(v).decode("utf-8")
181
+ return v
182
+
133
183
  @field_validator('width')
134
184
  def width_validator(cls, v: int):
135
185
  """
@@ -188,6 +238,12 @@ class GenerateImageInfer(BaseModel):
188
238
  if self.parameters.qualityToggle:
189
239
  self.input += ", best quality, amazing quality, very aesthetic, absurdres"
190
240
 
241
+ @model_validator(mode="after")
242
+ def validate_model(self):
243
+ if self.action == Action.INFILL and not self.parameters.mask:
244
+ logger.warning("Mask maybe required for infill mode.")
245
+ return self
246
+
191
247
  @property
192
248
  def base_url(self):
193
249
  return f"{self.endpoint.strip('/')}/ai/generate-image"
@@ -250,28 +306,42 @@ class GenerateImageInfer(BaseModel):
250
306
  negative_prompt: str = "",
251
307
  seed: int = None,
252
308
  steps: int = 28,
309
+ scale: float = 6.0,
253
310
  cfg_rescale: int = 0,
254
311
  sampler: Union[Sampler, str] = Sampler.K_EULER,
255
312
  width: int = 832,
256
313
  height: int = 1216,
257
314
  qualityToggle: bool = True,
258
315
  ucPreset: Union[UCPreset, int] = UCPreset.TYPE0,
316
+ image: Union[str, bytes] = None,
317
+ add_original_image: bool = None,
318
+ strength: float = None,
319
+ mask: Union[str, bytes] = None,
320
+ controlnet_model: Union[ControlNetModel, str] = None,
321
+ controlnet_condition: str = None,
259
322
  **kwargs
260
323
  ):
261
324
  """
262
- 正负面, step, cfg, 采样方式, seed
263
- :param ucPreset: 0: 重型, 1: 轻型, 2: 人物
264
- :param qualityToggle: 是否开启质量
265
- :param prompt: 输入
266
- :param model: 模型
325
+ The build Function, more convenient to create a GenerateImageInfer instance.
326
+ :param ucPreset: 0: Heavy, 1: Light, 2: Character
327
+ :param qualityToggle: Is quality toggle
328
+ :param prompt: Input prompt for generate image
329
+ :param model: The model for generate image
267
330
  :param action: Mode for img generate [generate, img2img, infill]
268
- :param negative_prompt: 负面
269
- :param seed: 随机种子
270
- :param steps: 步数
271
- :param cfg_rescale: 0-1
272
- :param sampler: 采样方式
331
+ :param negative_prompt: The content of negative prompt
332
+ :param seed: The seed for generate image
333
+ :param steps: The steps for generate image
334
+ :param scale: Prompt Guidance
335
+ :param cfg_rescale: Prompt Guidance Rescale 0-1 lower is more creative
336
+ :param sampler: The sampler for generate image
273
337
  :param width: 宽
274
338
  :param height: 高
339
+ :param image: 图片
340
+ :param add_original_image: 是否添加原始图片
341
+ :param strength: IMG2IMG 强度
342
+ :param mask: Inpainting mask
343
+ :param controlnet_model: 控制网络模型
344
+ :param controlnet_condition: 控制网络条件
275
345
  :return: self
276
346
  """
277
347
  assert isinstance(prompt, str)
@@ -280,12 +350,19 @@ class GenerateImageInfer(BaseModel):
280
350
  "negative_prompt": _negative_prompt,
281
351
  "seed": seed,
282
352
  "steps": steps,
353
+ "scale": scale,
283
354
  "cfg_rescale": cfg_rescale,
284
355
  "sampler": sampler,
285
356
  "width": width,
286
357
  "height": height,
287
358
  "qualityToggle": qualityToggle,
288
359
  "ucPreset": ucPreset,
360
+ "image": image,
361
+ "add_original_image": add_original_image,
362
+ "strength": strength,
363
+ "mask": mask,
364
+ "controlnet_model": controlnet_model,
365
+ "controlnet_condition": controlnet_condition
289
366
  })
290
367
  # 清理空值
291
368
  param = {k: v for k, v in kwargs.items() if v is not None}
@@ -296,18 +373,52 @@ class GenerateImageInfer(BaseModel):
296
373
  parameters=cls.Params(**param)
297
374
  )
298
375
 
299
- async def generate(self, session: Union[AsyncSession, "CredentialBase"],
300
- *,
301
- remove_sign: bool = False) -> ImageGenerateResp:
376
+ async def necessary_headers(self, request_data) -> dict:
377
+ """
378
+ :param request_data:
379
+ :return:
380
+ """
381
+ return {
382
+ "Host": urlparse(self.endpoint).netloc,
383
+ "Accept": "*/*",
384
+ "Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
385
+ "Accept-Encoding": "gzip, deflate, br",
386
+ "Referer": "https://novelai.net/",
387
+ "Content-Type": "application/json",
388
+ "Origin": "https://novelai.net",
389
+ "Content-Length": str(len(json.dumps(request_data).encode("utf-8"))),
390
+ "Connection": "keep-alive",
391
+ "Sec-Fetch-Dest": "empty",
392
+ "Sec-Fetch-Mode": "cors",
393
+ "Sec-Fetch-Site": "same-site",
394
+ "Pragma": "no-cache",
395
+ "Cache-Control": "no-cache",
396
+ }
397
+
398
+ async def request(self,
399
+ session: Union[AsyncSession, "CredentialBase"],
400
+ *,
401
+ override_headers: Optional[dict] = None,
402
+ remove_sign: bool = False
403
+ ) -> ImageGenerateResp:
302
404
  """
303
405
  生成图片
406
+ :param override_headers:
304
407
  :param session: session
305
408
  :param remove_sign: 移除追踪信息
306
409
  :return:
307
410
  """
308
- if isinstance(session, CredentialBase):
309
- session = await session.get_session()
411
+ # Data Build
310
412
  request_data = self.model_dump(mode="json", exclude_none=True)
413
+ # Header
414
+ if isinstance(session, AsyncSession):
415
+ session.headers.update(self.necessary_headers(request_data))
416
+ elif isinstance(session, CredentialBase):
417
+ update_header = await self.necessary_headers(request_data)
418
+ session = await session.get_session(update_headers=update_header)
419
+ if override_headers:
420
+ session.headers.clear()
421
+ session.headers.update(override_headers)
311
422
  logger.debug(f"Request Data: {request_data}")
312
423
  try:
313
424
  assert hasattr(session, "post"), "session must have post method."
@@ -365,7 +476,7 @@ class GenerateImageInfer(BaseModel):
365
476
  ),
366
477
  files=unzip_content
367
478
  )
368
- except RequestsError as exc:
479
+ except curl_cffi.requests.errors.RequestsError as exc:
369
480
  logger.exception(exc)
370
481
  raise RuntimeError(f"An AsyncSession error occurred: {exc}")
371
482
  except httpx.HTTPError as exc:
@@ -0,0 +1,36 @@
1
+ # -*- coding: utf-8 -*-
2
+ # @Time : 2024/2/10 上午11:15
3
+ # @Author : sudoskys
4
+ # @File : schema.py
5
+ # @Software: PyCharm
6
+ from abc import ABC, abstractmethod
7
+ from typing import Optional, Union
8
+
9
+ from curl_cffi.requests import AsyncSession
10
+ from pydantic import BaseModel, PrivateAttr
11
+
12
+ from ..credential import CredentialBase
13
+
14
+
15
+ class ApiBaseModel(BaseModel, ABC):
16
+ _endpoint: Optional[str] = PrivateAttr()
17
+
18
+ @property
19
+ def endpoint(self):
20
+ return self._endpoint
21
+
22
+ @endpoint.setter
23
+ def endpoint(self, value):
24
+ self._endpoint = value
25
+
26
+ @abstractmethod
27
+ async def necessary_headers(self, request_data) -> dict:
28
+ raise NotImplementedError()
29
+
30
+ @abstractmethod
31
+ async def request(self,
32
+ session: Union[AsyncSession, CredentialBase],
33
+ *,
34
+ override_headers: Optional[dict] = None
35
+ ):
36
+ raise NotImplementedError()
@@ -3,3 +3,4 @@
3
3
  # @Author : sudoskys
4
4
  # @File : __init__.py.py
5
5
  # @Software: PyCharm
6
+
@@ -5,18 +5,20 @@
5
5
  # @Software: PyCharm
6
6
  from typing import Optional, Union
7
7
 
8
+ import curl_cffi
8
9
  import httpx
9
- from curl_cffi.requests import AsyncSession, RequestsError
10
+ from curl_cffi.requests import AsyncSession
10
11
  from loguru import logger
11
- from pydantic import BaseModel, PrivateAttr
12
+ from pydantic import PrivateAttr
12
13
 
14
+ from ..schema import ApiBaseModel
13
15
  from ..._exceptions import APIError, AuthError
14
16
  from ..._response.user.information import InformationResp
15
17
  from ...credential import CredentialBase
16
18
  from ...utils import try_jsonfy
17
19
 
18
20
 
19
- class Information(BaseModel):
21
+ class Information(ApiBaseModel):
20
22
  _endpoint: Optional[str] = PrivateAttr("https://api.novelai.net")
21
23
 
22
24
  @property
@@ -31,22 +33,43 @@ class Information(BaseModel):
31
33
  def endpoint(self, value):
32
34
  self._endpoint = value
33
35
 
36
+ async def necessary_headers(self, request_data) -> dict:
37
+ return {
38
+ "Host": "api.novelai.net",
39
+ "Accept": "*/*",
40
+ "Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
41
+ "Accept-Encoding": "gzip, deflate, br",
42
+ "Referer": "https://novelai.net/",
43
+ "Content-Type": "application/json",
44
+ "Origin": "https://novelai.net"
45
+ }
46
+
34
47
  async def request(self,
35
48
  session: Union[AsyncSession, CredentialBase],
49
+ *,
50
+ override_headers: Optional[dict] = None
36
51
  ) -> InformationResp:
37
52
  """
38
53
  Request to get user subscription information
39
- :param session:
54
+ :param override_headers:
55
+ :param session:
40
56
  :return:
41
57
  """
42
- if isinstance(session, CredentialBase):
43
- session = await session.get_session()
44
58
  request_data = {}
59
+ # Data Build
60
+ if isinstance(session, AsyncSession):
61
+ session.headers.update(await self.necessary_headers(request_data))
62
+ elif isinstance(session, CredentialBase):
63
+ session = await session.get_session(update_headers=await self.necessary_headers(request_data))
64
+ # Header
65
+ if override_headers:
66
+ session.headers.clear()
67
+ session.headers.update(override_headers)
45
68
  logger.debug("Information")
46
69
  try:
47
70
  assert hasattr(session, "get"), "session must have get method."
48
71
  response = await session.get(
49
- self.base_url,
72
+ self.base_url
50
73
  )
51
74
  if "application/json" not in response.headers.get('Content-Type') or response.status_code != 200:
52
75
  logger.error(
@@ -74,7 +97,7 @@ class Information(BaseModel):
74
97
  raise APIError(message, request=request_data, status_code=status_code, response=_msg)
75
98
  raise APIError(message, request=request_data, status_code=status_code, response=_msg)
76
99
  return InformationResp.model_validate(response.json())
77
- except RequestsError as exc:
100
+ except curl_cffi.requests.errors.RequestsError as exc:
78
101
  logger.exception(exc)
79
102
  raise RuntimeError(f"An AsyncSession error occurred: {exc}")
80
103
  except httpx.HTTPError as exc:
@@ -4,19 +4,22 @@
4
4
  # @File : login.py
5
5
  # @Software: PyCharm
6
6
  import json
7
- from typing import Optional
7
+ from typing import Optional, Union
8
8
 
9
+ import curl_cffi
9
10
  import httpx
10
- from curl_cffi.requests import AsyncSession, RequestsError
11
+ from curl_cffi.requests import AsyncSession
11
12
  from loguru import logger
12
- from pydantic import BaseModel, PrivateAttr, Field
13
+ from pydantic import PrivateAttr, Field
13
14
 
15
+ from ..schema import ApiBaseModel
14
16
  from ..._exceptions import APIError
15
17
  from ..._response.user.login import LoginResp
18
+ from ...credential import CredentialBase
16
19
  from ...utils import try_jsonfy, encode_access_key
17
20
 
18
21
 
19
- class Login(BaseModel):
22
+ class Login(ApiBaseModel):
20
23
  _endpoint: Optional[str] = PrivateAttr("https://api.novelai.net")
21
24
  _session: Optional[AsyncSession] = PrivateAttr(None)
22
25
  key: str = Field(..., description="User's key")
@@ -59,13 +62,28 @@ class Login(BaseModel):
59
62
  """
60
63
  return cls(key=encode_access_key(user_name, password))
61
64
 
65
+ async def necessary_headers(self, request_data) -> dict:
66
+ return {}
67
+
62
68
  async def request(self,
69
+ session: Union[AsyncSession, CredentialBase] = None,
70
+ *,
71
+ override_headers: Optional[dict] = None,
63
72
  ) -> LoginResp:
64
73
  """
65
74
  Request to get user access token
66
75
  :return:
67
76
  """
77
+ # Data Build
68
78
  request_data = self.model_dump(mode="json", exclude_none=True)
79
+ if isinstance(session, AsyncSession):
80
+ session.headers.update(self.necessary_headers(request_data))
81
+ elif isinstance(session, CredentialBase):
82
+ session = await session.get_session(update_headers=await self.necessary_headers(request_data))
83
+ # Header
84
+ if override_headers:
85
+ session.headers.clear()
86
+ session.headers.update(override_headers)
69
87
  logger.debug("Login")
70
88
  try:
71
89
  assert hasattr(self.session, "post"), "session must have get method."
@@ -97,7 +115,7 @@ class Login(BaseModel):
97
115
  raise APIError(message, request=request_data, status_code=status_code, response=_msg)
98
116
  raise APIError(message, request=request_data, status_code=status_code, response=_msg)
99
117
  return LoginResp.model_validate(response.json())
100
- except RequestsError as exc:
118
+ except curl_cffi.requests.errors.RequestsError as exc:
101
119
  logger.exception(exc)
102
120
  raise RuntimeError(f"An AsyncSession error occurred: {exc}")
103
121
  except httpx.HTTPError as exc:
@@ -5,18 +5,20 @@
5
5
  # @Software: PyCharm
6
6
  from typing import Optional, Union
7
7
 
8
+ import curl_cffi
8
9
  import httpx
9
- from curl_cffi.requests import AsyncSession, RequestsError
10
+ from curl_cffi.requests import AsyncSession
10
11
  from loguru import logger
11
- from pydantic import BaseModel, PrivateAttr
12
+ from pydantic import PrivateAttr
12
13
 
14
+ from ..schema import ApiBaseModel
13
15
  from ..._exceptions import APIError, AuthError
14
16
  from ..._response.user.subscription import SubscriptionResp
15
17
  from ...credential import CredentialBase
16
18
  from ...utils import try_jsonfy
17
19
 
18
20
 
19
- class Subscription(BaseModel):
21
+ class Subscription(ApiBaseModel):
20
22
  _endpoint: Optional[str] = PrivateAttr("https://api.novelai.net")
21
23
 
22
24
  @property
@@ -31,22 +33,50 @@ class Subscription(BaseModel):
31
33
  def endpoint(self, value):
32
34
  self._endpoint = value
33
35
 
36
+ async def necessary_headers(self, request_data) -> dict:
37
+ return {
38
+ "Host": "api.novelai.net",
39
+ "Accept": "*/*",
40
+ "Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
41
+ "Accept-Encoding": "gzip, deflate, br",
42
+ "Referer": "https://novelai.net/",
43
+ "Content-Type": "application/json",
44
+ "Origin": "https://novelai.net",
45
+ "Connection": "keep-alive",
46
+ "Sec-Fetch-Dest": "empty",
47
+ "Sec-Fetch-Mode": "cors",
48
+ "Sec-Fetch-Site": "same-site",
49
+ "Pragma": "no-cache",
50
+ "Cache-Control": "no-cache",
51
+ "TE": "trailers",
52
+ }
53
+
34
54
  async def request(self,
35
55
  session: Union[AsyncSession, CredentialBase],
56
+ *,
57
+ override_headers: Optional[dict] = None
36
58
  ) -> SubscriptionResp:
37
59
  """
38
60
  Request to get user subscription information
39
- :param session:
61
+ :param override_headers:
62
+ :param session:
40
63
  :return:
41
64
  """
42
- if isinstance(session, CredentialBase):
43
- session = await session.get_session()
65
+ # Data Build
44
66
  request_data = {}
67
+ if isinstance(session, AsyncSession):
68
+ session.headers.update(await self.necessary_headers(request_data))
69
+ elif isinstance(session, CredentialBase):
70
+ session = await session.get_session(update_headers=await self.necessary_headers(request_data))
71
+ # Header
72
+ if override_headers:
73
+ session.headers.clear()
74
+ session.headers.update(override_headers)
45
75
  logger.debug("Subscription")
46
76
  try:
47
77
  assert hasattr(session, "get"), "session must have get method."
48
78
  response = await session.get(
49
- self.base_url,
79
+ url=self.base_url,
50
80
  )
51
81
  if "application/json" not in response.headers.get('Content-Type') or response.status_code != 200:
52
82
  logger.error(
@@ -74,7 +104,7 @@ class Subscription(BaseModel):
74
104
  raise APIError(message, request=request_data, status_code=status_code, response=_msg)
75
105
  raise APIError(message, request=request_data, status_code=status_code, response=_msg)
76
106
  return SubscriptionResp.model_validate(response.json())
77
- except RequestsError as exc:
107
+ except curl_cffi.requests.errors.RequestsError as exc:
78
108
  logger.exception(exc)
79
109
  raise RuntimeError(f"An AsyncSession error occurred: {exc}")
80
110
  except httpx.HTTPError as exc:
@@ -102,7 +102,7 @@ async def generate_image(
102
102
  :return:
103
103
  """
104
104
  try:
105
- _result = await req.generate(session=get_session(current_token), remove_sign=True)
105
+ _result = await req.request(session=get_session(current_token), remove_sign=True)
106
106
  zip_file_bytes = io.BytesIO()
107
107
  with zipfile.ZipFile(zip_file_bytes, mode="w", compression=zipfile.ZIP_DEFLATED) as zip_file:
108
108
  for file in _result.files:
@@ -0,0 +1,7 @@
1
+ # -*- coding: utf-8 -*-
2
+ # @Time : 2024/2/9 下午10:41
3
+ # @Author : sudoskys
4
+ # @File : useful.py
5
+ # @Software: PyCharm
6
+ def enum_to_list(enum_):
7
+ return list(map(lambda x: x.value, enum_._member_map_.values()))
@@ -28,7 +28,7 @@ def test_subscription_without_api_key(mock_subscription):
28
28
 
29
29
  @patch('src.novelai_python.server.GenerateImageInfer')
30
30
  def test_generate_image_without_api_key(mock_generate_image):
31
- mock_generate_image.return_value.generate.return_value = {"status": "image generated"}
31
+ mock_generate_image.return_value.request.return_value = {"status": "image generated"}
32
32
  response = client.post("/ai/generate_image")
33
33
  assert response.status_code == 403
34
34
 
@@ -34,6 +34,7 @@ async def test_request_method_successful():
34
34
  }
35
35
  session = mock.MagicMock(spec=AsyncSession)
36
36
  session.get = mock.AsyncMock(return_value=successful_response)
37
+ session.headers = {}
37
38
 
38
39
  info = Information()
39
40
  resp = await info.request(session)
@@ -48,6 +49,7 @@ async def test_request_method_unauthorized_error():
48
49
  auth_response.json.return_value = {"statusCode": 401, "message": "Access Token is incorrect."}
49
50
 
50
51
  session = mock.MagicMock(spec=AsyncSession)
52
+ session.headers = {}
51
53
  session.get = mock.AsyncMock(return_value=auth_response)
52
54
 
53
55
  info = Information()
@@ -66,6 +68,7 @@ async def test_request_method_unknown_error():
66
68
  unknown_error_response.json.return_value = {"statusCode": 500, "message": "An unknown error occurred."}
67
69
 
68
70
  session = mock.MagicMock(spec=AsyncSession)
71
+ session.headers = {}
69
72
  session.get = mock.AsyncMock(return_value=unknown_error_response)
70
73
 
71
74
  info = Information()
@@ -21,6 +21,7 @@ async def test_successful_user_login():
21
21
  }
22
22
  session = mock.MagicMock(spec=AsyncSession)
23
23
  session.post = mock.AsyncMock(return_value=successful_login_response)
24
+ session.headers = {}
24
25
 
25
26
  login = Login(key="encoded_key")
26
27
  login.session = session
@@ -40,6 +41,7 @@ async def test_validation_error_during_login():
40
41
  }
41
42
  session = mock.MagicMock(spec=AsyncSession)
42
43
  session.post = mock.AsyncMock(return_value=validation_error_response)
44
+ session.headers = {}
43
45
 
44
46
  login = Login(key="encoded_key")
45
47
  login.session = session
@@ -61,6 +63,7 @@ async def test_incorrect_access_key_during_login():
61
63
  }
62
64
  session = mock.MagicMock(spec=AsyncSession)
63
65
  session.post = mock.AsyncMock(return_value=incorrect_key_response)
66
+ session.headers = {}
64
67
 
65
68
  login = Login(key="encoded_key")
66
69
  login.session = session
@@ -82,6 +85,8 @@ async def test_unknown_error_during_login():
82
85
  }
83
86
  session = mock.MagicMock(spec=AsyncSession)
84
87
  session.post = mock.AsyncMock(return_value=unknown_error_response)
88
+ session.headers = {}
89
+
85
90
  login = Login(key="encoded_key")
86
91
  login.session = session
87
92
  with pytest.raises(APIError) as e:
@@ -49,6 +49,8 @@ async def test_successful_subscription_request():
49
49
  }
50
50
  session = mock.MagicMock(spec=AsyncSession)
51
51
  session.get = mock.AsyncMock(return_value=successful_response)
52
+ session.headers = {}
53
+
52
54
  subscription = Subscription()
53
55
  resp = await subscription.request(session)
54
56
  assert isinstance(resp, SubscriptionResp)
@@ -65,6 +67,7 @@ async def test_incorrect_access_token_subscription_request():
65
67
  }
66
68
  session = mock.MagicMock(spec=AsyncSession)
67
69
  session.get = mock.AsyncMock(return_value=incorrect_token_response)
70
+ session.headers = {}
68
71
 
69
72
  subscription = Subscription()
70
73
  with pytest.raises(AuthError) as e:
@@ -85,6 +88,7 @@ async def test_unknown_error_subscription_request():
85
88
  }
86
89
  session = mock.MagicMock(spec=AsyncSession)
87
90
  session.get = mock.AsyncMock(return_value=unknown_error_response)
91
+ session.headers = {}
88
92
 
89
93
  subscription = Subscription()
90
94
  with pytest.raises(APIError) as e:
File without changes
File without changes