nekoai-api 0.1.1__py3-none-any.whl

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.
nekoai/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .client import NovelAI # noqa: F401
2
+ from .constant import * # noqa: F401, F403
3
+ from .types import Metadata # noqa: F401
nekoai/__main__.py ADDED
@@ -0,0 +1,40 @@
1
+ import asyncio
2
+ import sys
3
+ from argparse import ArgumentParser
4
+
5
+ from .client import NovelAI
6
+
7
+
8
+ async def async_main():
9
+ parser = ArgumentParser()
10
+ subparsers = parser.add_subparsers(dest="command")
11
+
12
+ # Login command
13
+ login_parser = subparsers.add_parser(
14
+ "login", help="Login to NovelAI to get your access token"
15
+ )
16
+ login_parser.add_argument("username", help="NovelAI username")
17
+ login_parser.add_argument("password", help="NovelAI password")
18
+
19
+ args = parser.parse_args()
20
+
21
+ if args.command == "login":
22
+ client = NovelAI(username=args.username, password=args.password)
23
+ await client.init()
24
+
25
+ access_token = await client.get_access_token()
26
+ print(access_token)
27
+ else:
28
+ parser.print_help()
29
+
30
+
31
+ def main():
32
+ """
33
+ Synchronous entry point for the package.
34
+ This function is used when the package is run as a script through the entry point.
35
+ """
36
+ return asyncio.run(async_main())
37
+
38
+
39
+ if __name__ == "__main__":
40
+ sys.exit(main())
nekoai/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1"
nekoai/client.py ADDED
@@ -0,0 +1,522 @@
1
+ import asyncio
2
+ import io
3
+ import zipfile
4
+ from asyncio import Task
5
+ from datetime import datetime
6
+ from typing import TYPE_CHECKING, Optional
7
+
8
+ from httpx import AsyncClient, ReadTimeout
9
+ from loguru import logger
10
+ from pydantic import validate_call
11
+
12
+ from .constant import EmotionLevel, EmotionOptions
13
+ from .types import Image, Metadata, User
14
+
15
+ if TYPE_CHECKING:
16
+ from .types.director import DirectorRequest
17
+
18
+ from .constant import HEADERS, Endpoint, Host
19
+ from .exceptions import AuthError, TimeoutError
20
+ from .types.host import HostInstance
21
+ from .utils import ResponseParser, encode_access_key, parse_image
22
+
23
+
24
+ def running(func) -> callable:
25
+ """
26
+ Decorator to check if client is running before making a request.
27
+ """
28
+
29
+ async def wrapper(self: "NovelAI", *args, **kwargs):
30
+ if not self.running:
31
+ await self.init(auto_close=self.auto_close, close_delay=self.close_delay)
32
+ if self.running:
33
+ return await func(self, *args, **kwargs)
34
+
35
+ raise Exception(
36
+ f"Invalid function call: NAIClient.{func.__name__}. Client initialization failed."
37
+ )
38
+ else:
39
+ return await func(self, *args, **kwargs)
40
+
41
+ return wrapper
42
+
43
+
44
+ class NovelAI:
45
+ """
46
+ Async httpx client interface to interact with NovelAI's service.
47
+
48
+ Parameters
49
+ ----------
50
+ username: `str`, optional
51
+ NovelAI username, usually an email address (required if token is not provided)
52
+ password: `str`, optional
53
+ NovelAI password (required if token is not provided)
54
+ token: `str`, optional
55
+ NovelAI access token (required if username/password is not provided)
56
+ proxy: `dict`, optional
57
+ Proxy to use for the client
58
+
59
+ Notes
60
+ -----
61
+ Either a username/password combination or a token must be provided.
62
+ """
63
+
64
+ __slots__ = [
65
+ "user",
66
+ "proxy",
67
+ "client",
68
+ "running",
69
+ "auto_close",
70
+ "close_delay",
71
+ "close_task",
72
+ ]
73
+
74
+ def __init__(
75
+ self,
76
+ username: str = None,
77
+ password: str = None,
78
+ token: str = None,
79
+ proxy: dict | None = None,
80
+ ):
81
+ self.user = User(username=username, password=password, token=token)
82
+ if not self.user.validate_auth():
83
+ raise ValueError("Either username/password or token must be provided")
84
+
85
+ self.proxy = proxy
86
+ self.client: AsyncClient | None = None
87
+ self.running: bool = False
88
+ self.auto_close: bool = False
89
+ self.close_delay: float = 300
90
+ self.close_task: Task | None = None
91
+
92
+ async def init(
93
+ self, timeout: float = 30, auto_close: bool = False, close_delay: float = 300
94
+ ) -> None:
95
+ """
96
+ Get access token and implement Authorization header.
97
+
98
+ Parameters
99
+ ----------
100
+ timeout: `float`, optional
101
+ Request timeout of the client in seconds. Used to limit the max waiting time when sending a request
102
+ auto_close: `bool`, optional
103
+ If `True`, the client will close connections and clear resource usage after a certain period
104
+ of inactivity. Useful for keep-alive services
105
+ close_delay: `float`, optional
106
+ Time to wait before auto-closing the client in seconds. Effective only if `auto_close` is `True`
107
+ """
108
+ try:
109
+ self.client = AsyncClient(
110
+ timeout=timeout, proxy=self.proxy, headers=HEADERS
111
+ )
112
+ self.client.headers["Authorization"] = (
113
+ f"Bearer {await self.get_access_token()}"
114
+ )
115
+
116
+ self.running = True
117
+ logger.success("NovelAI client initialized successfully.")
118
+
119
+ self.auto_close = auto_close
120
+ self.close_delay = close_delay
121
+ if self.auto_close:
122
+ await self.reset_close_task()
123
+ except Exception:
124
+ await self.close()
125
+ raise
126
+
127
+ async def close(self, delay: float = 0) -> None:
128
+ """
129
+ Close the client after a certain period of inactivity, or call manually to close immediately.
130
+
131
+ Parameters
132
+ ----------
133
+ delay: `float`, optional
134
+ Time to wait before closing the client in seconds
135
+ """
136
+ if delay:
137
+ await asyncio.sleep(delay)
138
+
139
+ if self.close_task:
140
+ self.close_task.cancel()
141
+ self.close_task = None
142
+
143
+ await self.client.aclose()
144
+ self.running = False
145
+
146
+ async def reset_close_task(self) -> None:
147
+ """
148
+ Reset the timer for closing the client when a new request is made.
149
+ """
150
+ if self.close_task:
151
+ self.close_task.cancel()
152
+ self.close_task = None
153
+ self.close_task = asyncio.create_task(self.close(self.close_delay))
154
+
155
+ async def get_access_token(self) -> str:
156
+ """
157
+ Get access token for NovelAI API authorization.
158
+
159
+ If a token is directly provided, it will be used.
160
+ Otherwise, send post request to /user/login endpoint to get user's access token.
161
+
162
+ Returns
163
+ -------
164
+ `str`
165
+ NovelAI access token which is used in the Authorization header with the Bearer scheme
166
+
167
+ Raises
168
+ ------
169
+ `novelai.exceptions.AuthError`
170
+ If the account credentials are incorrect
171
+ """
172
+ # Use token directly if provided
173
+ if self.user.token:
174
+ return self.user.token
175
+
176
+ # Otherwise authenticate with username/password
177
+ access_key = encode_access_key(self.user)
178
+
179
+ response = await self.client.post(
180
+ url=f"{Host.API.value.url}{Endpoint.LOGIN.value}",
181
+ json={
182
+ "key": access_key,
183
+ },
184
+ )
185
+
186
+ # Exceptions are handled in self.init
187
+ ResponseParser(response).handle_status_code()
188
+
189
+ return response.json()["accessToken"]
190
+
191
+ @running
192
+ @validate_call
193
+ async def generate_image(
194
+ self,
195
+ metadata: Metadata | None = None,
196
+ host: Host | HostInstance = Host.WEB,
197
+ verbose: bool = False,
198
+ is_opus: bool = False,
199
+ **kwargs,
200
+ ) -> list[Image]:
201
+ """
202
+ Send post request to /ai/generate-image endpoint for image generation.
203
+
204
+ Parameters
205
+ ----------
206
+ metadata: `novelai.Metadata`
207
+ Metadata object containing parameters required for image generation
208
+ host: `Host` or `HostInstance`, optional
209
+ Host to send the request. Can be:
210
+ - A predefined host from `novelai.constant.Host` (API or WEB)
211
+ - A custom host created with `Host.custom(url, accept)`
212
+ - A direct `HostInstance` object
213
+ verbose: `bool`, optional
214
+ If `True`, will log the estimated Anlas cost before sending the request
215
+ is_opus: `bool`, optional
216
+ Use with `verbose` to calculate the cost based on your subscription tier
217
+ **kwargs: `Any`
218
+ If `metadata` is not provided, these parameters are used to create a `novelai.Metadata` object
219
+
220
+ Returns
221
+ -------
222
+ `list[novelai.Image]`
223
+ List of `Image` objects containing the generated image and its metadata
224
+
225
+ Raises
226
+ ------
227
+ `novelai.exceptions.TimeoutError`
228
+ If the request time exceeds the client's timeout value
229
+ `novelai.exceptions.AuthError`
230
+ If the access token is incorrect or expired
231
+ """
232
+ if metadata is None:
233
+ metadata = Metadata(**kwargs)
234
+
235
+ if verbose:
236
+ logger.info(
237
+ f"Generating image... estimated Anlas cost: {metadata.calculate_cost(is_opus)}"
238
+ )
239
+
240
+ if self.auto_close:
241
+ await self.reset_close_task()
242
+
243
+ # Get the actual host instance (whether from enum or direct HostInstance)
244
+ host_instance = host.value if isinstance(host, Host) else host
245
+
246
+ try:
247
+ # Use model_dump_for_api which properly formats the request for different model versions
248
+ payload = metadata.model_dump_for_api()
249
+
250
+ print(f"Payload: {payload}")
251
+
252
+ response = await self.client.post(
253
+ url=f"{host_instance.url}{Endpoint.IMAGE.value}",
254
+ json=payload,
255
+ )
256
+ except ReadTimeout:
257
+ raise TimeoutError(
258
+ "Request timed out, please try again. If the problem persists, consider setting a higher `timeout` value when initiating NAIClient."
259
+ )
260
+
261
+ try:
262
+ ResponseParser(response).handle_status_code()
263
+ except AuthError:
264
+ await self.close()
265
+ raise
266
+
267
+ assert (
268
+ response.headers["Content-Type"] == host_instance.accept
269
+ ), f"Invalid response content type. Expected '{host_instance.accept}', got '{response.headers['Content-Type']}'."
270
+
271
+ # Use the host name or 'custom' for the filename
272
+ host_name = host_instance.name.lower()
273
+
274
+ return [
275
+ Image(
276
+ filename=f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{host_name}_p{i}.png",
277
+ data=data,
278
+ metadata=metadata,
279
+ )
280
+ for i, data in enumerate(ResponseParser(response).parse_zip_content())
281
+ ]
282
+
283
+ @running
284
+ async def use_director_tool(self, request: "DirectorRequest") -> "Image":
285
+ """
286
+ Send request to /ai/augment-image endpoint for using NovelAI's Director tools.
287
+
288
+ Parameters
289
+ ----------
290
+ request: `DirectorRequest`
291
+ The director tool request containing the necessary parameters
292
+
293
+ Returns
294
+ -------
295
+ `Image`
296
+ An image object containing the generated image
297
+
298
+ Raises
299
+ ------
300
+ `novelai.exceptions.TimeoutError`
301
+ If the request time exceeds the client's timeout value
302
+ `novelai.exceptions.AuthError`
303
+ If the access token is incorrect or expired
304
+ """
305
+ if self.auto_close:
306
+ await self.reset_close_task()
307
+
308
+ host_instance = Host.WEB.value
309
+ try:
310
+ json_data = request.model_dump(mode="json", exclude_none=True)
311
+ response = await self.client.post(
312
+ url=f"{host_instance.url}{Endpoint.DIRECTOR.value}",
313
+ json=json_data,
314
+ )
315
+ except ReadTimeout:
316
+ raise TimeoutError(
317
+ "Request timed out, please try again. If the problem persists, consider setting a higher `timeout` value when initiating NAIClient."
318
+ )
319
+
320
+ try:
321
+ ResponseParser(response).handle_status_code()
322
+ except AuthError:
323
+ await self.close()
324
+ raise
325
+
326
+ if not response.content:
327
+ logger.error("Received empty response from the server.")
328
+ return None
329
+
330
+ image_data = self.handle_decompression(response.content)
331
+
332
+ # Director tool responses are not zipped, but directly return a single image
333
+ return Image(
334
+ filename=f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{request.req_type}.png",
335
+ data=image_data,
336
+ metadata=None,
337
+ )
338
+
339
+ def handle_decompression(self, compressed_data: bytes) -> bytes:
340
+ """
341
+ Handle decompression of the response content.
342
+
343
+ Parameters
344
+ ----------
345
+ response: `bytes`
346
+ The response content to decompress
347
+
348
+ Returns
349
+ -------
350
+ `bytes`
351
+ The decompressed response content
352
+ """
353
+ with zipfile.ZipFile(io.BytesIO(compressed_data)) as zf:
354
+
355
+ # List the contents to see what files are inside
356
+ file_names = zf.namelist()
357
+ return zf.read(file_names[0])
358
+
359
+ @running
360
+ async def lineart(self, image: str) -> "Image":
361
+ """
362
+ Convert an image to line art using the Director tool.
363
+
364
+ Parameters
365
+ ----------
366
+ image: `str`
367
+ path to the image file
368
+
369
+ Returns
370
+ -------
371
+ `Image`
372
+ The processed image
373
+ """
374
+ from .types.director import LineArtRequest
375
+
376
+ width, height, base64_image = parse_image(image)
377
+
378
+ request = LineArtRequest(width=width, height=height, image=base64_image)
379
+ return await self.use_director_tool(request)
380
+
381
+ @running
382
+ async def sketch(self, image: str) -> "Image":
383
+ """
384
+ Convert an image to sketch using the Director tool.
385
+ Parameters
386
+ ----------
387
+ image: `str`
388
+ path to the image file
389
+
390
+ Returns
391
+ -------
392
+ `Image`
393
+ The processed image
394
+ """
395
+ from .types.director import SketchRequest
396
+
397
+ width, height, base64_image = parse_image(image)
398
+
399
+ request = SketchRequest(width=width, height=height, image=base64_image)
400
+ return await self.use_director_tool(request)
401
+
402
+ @running
403
+ async def background_removal(self, image: str) -> "Image":
404
+ """
405
+ Remove background from an image using the Director tool.
406
+
407
+ Parameters
408
+ ----------
409
+ image: `str`
410
+ Base64-encoded image
411
+ width: `int`
412
+ Width of the image in pixels
413
+ height: `int`
414
+ Height of the image in pixels
415
+
416
+ Returns
417
+ -------
418
+ `Image`
419
+ The processed image with background removed
420
+ """
421
+ from .types.director import BackgroundRemovalRequest
422
+
423
+ width, height, base64_image = parse_image(image)
424
+
425
+ request = BackgroundRemovalRequest(
426
+ width=width, height=height, image=base64_image
427
+ )
428
+ return await self.use_director_tool(request)
429
+
430
+ @running
431
+ async def declutter(self, image: str) -> "Image":
432
+ """
433
+ Declutter an image using the Director tool.
434
+
435
+ Parameters
436
+ ----------
437
+ image: `str`
438
+ path to the image file
439
+
440
+ Returns
441
+ -------
442
+ `Image`
443
+ The processed image
444
+ """
445
+ from .types.director import DeclutterRequest
446
+
447
+ width, height, base64_image = parse_image(image)
448
+
449
+ request = DeclutterRequest(width=width, height=height, image=base64_image)
450
+ return await self.use_director_tool(request)
451
+
452
+ @running
453
+ async def colorize(self, image: str, width: int, height: int) -> "Image":
454
+ """
455
+ Colorize a line art or sketch using the Director tool.
456
+
457
+ Parameters
458
+ ----------
459
+ image: `str`
460
+ Base64-encoded image
461
+ width: `int`
462
+ Width of the image in pixels
463
+ height: `int`
464
+ Height of the image in pixels
465
+
466
+ Returns
467
+ -------
468
+ `Image`
469
+ The colorized image
470
+ """
471
+ from .types.director import ColorizeRequest
472
+
473
+ request = ColorizeRequest(width=width, height=height, image=image)
474
+ return await self.use_director_tool(request)
475
+
476
+ @running
477
+ async def change_emotion(
478
+ self,
479
+ image: str,
480
+ emotion: "EmotionOptions",
481
+ prompt: Optional[str] = "",
482
+ emotion_level: "EmotionLevel" = EmotionLevel.NORMAL,
483
+ ) -> "Image":
484
+ """
485
+ Change the emotion of a character in an image using the Director tool.
486
+
487
+ Parameters
488
+ ----------
489
+ image: `str`
490
+ path to the image file
491
+ motion: EmotionOptions
492
+ The target emotion to apply
493
+ prompt: str
494
+ Addtional prompt for the request
495
+ emotion_level: EmotionLevel, optional
496
+ Strength level of the emotion, defaults to NORMAL
497
+
498
+ Returns
499
+ -------
500
+ `Image`
501
+ The image with modified emotion
502
+ """
503
+ from .types.director import EmotionRequest
504
+
505
+ # Validate inputs are proper enums
506
+ if not isinstance(emotion, EmotionOptions):
507
+ emotion = EmotionOptions(emotion)
508
+
509
+ if not isinstance(emotion_level, EmotionLevel):
510
+ emotion_level = EmotionLevel(emotion_level)
511
+
512
+ width, height, base64_image = parse_image(image)
513
+
514
+ request = EmotionRequest.create(
515
+ width=width,
516
+ height=height,
517
+ image=base64_image,
518
+ emotion=emotion,
519
+ prompt=prompt,
520
+ emotion_level=emotion_level,
521
+ )
522
+ return await self.use_director_tool(request)
nekoai/config.py ADDED
@@ -0,0 +1,6 @@
1
+ HEADERS = {
2
+ "Content-Type": "application/json",
3
+ "Origin": "https://novelai.net",
4
+ "Referer": "https://novelai.net",
5
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0",
6
+ }