├── .github ├── CONTRIBUTING.md └── workflows │ ├── publish.yml │ └── python_test.yml ├── .gitignore ├── .nerve.toml ├── .pre-commit-config.yaml ├── LICENSE ├── NOTICE.MD ├── README.md ├── pdm.lock ├── playground ├── augment-image.png ├── augment-image.py ├── banner-raw.png ├── enhance.py ├── generate.py ├── generate_image.py ├── generate_image_img2img.py ├── generate_stream.py ├── generate_voice.py ├── image_metadata │ ├── apply_lsb_to_image.py │ ├── image_lsb_data_extractor.py │ ├── read_image_info.py │ ├── read_image_nai_tag.py │ ├── sample-0316-out.png │ ├── sample-0316.png │ ├── sample-0317.png │ └── verify_is_nai_generated.py ├── information.py ├── login.py ├── paint_mask │ └── __init__.py ├── random_prompt.py ├── simple.py ├── static_image.png ├── static_image_paint.jpg ├── static_image_paint_mask.png ├── static_refer.png ├── static_refer_banner.png ├── subscription.py ├── suggest_tag.py ├── tokenizer │ ├── ignore.tokenizer_demo.py │ ├── usage.py │ └── use_tokenizer_directly.py ├── upscale.py ├── vibe.py ├── vibe_img2img.py └── vibe_inpaint.py ├── pyproject.toml ├── record └── ai │ ├── augment_image │ ├── emotion.json │ ├── emotion │ │ ├── image.png │ │ └── schema.json │ └── export.py │ ├── generate_image │ ├── enhance.json │ ├── enhance │ │ ├── image.png │ │ └── schema.json │ ├── export.py │ ├── image2image.json │ ├── image2image │ │ ├── image.png │ │ └── schema.json │ ├── image2image_v3.json │ ├── image2image_v3 │ │ ├── image.png │ │ └── schema.json │ ├── inpaint.json │ ├── inpaint │ │ ├── image.png │ │ ├── mask.png │ │ └── schema.json │ ├── inpaint_v3.json │ ├── inpaint_v3 │ │ ├── image.png │ │ ├── mask.png │ │ └── schema.json │ ├── text2image.json │ ├── text2image │ │ └── schema.json │ ├── text2image_v3.json │ ├── text2image_v3 │ │ └── schema.json │ ├── text2image_v4.json │ ├── text2image_v4 │ │ └── schema.json │ ├── vibe_image.json │ ├── vibe_image │ │ ├── 0-reference_image_multiple.png │ │ ├── 1-reference_image_multiple.png │ │ └── schema.json │ ├── vibe_img2img.json │ ├── vibe_img2img │ │ ├── image.png │ │ ├── reference_image.png │ │ └── schema.json │ ├── vibe_inpaint.json │ └── vibe_inpaint │ │ ├── image.png │ │ ├── mask.png │ │ ├── reference_image.png │ │ └── schema.json │ └── generate_stream │ ├── common.json │ ├── common │ └── schema.json │ └── export.py ├── src └── novelai_python │ ├── __init__.py │ ├── _enum.py │ ├── _exceptions.py │ ├── _response │ ├── __init__.py │ ├── ai │ │ ├── __init__.py │ │ ├── generate.py │ │ ├── generate_image.py │ │ ├── generate_stream.py │ │ ├── generate_voice.py │ │ └── upscale.py │ ├── schema.py │ └── user │ │ ├── __init__.py │ │ ├── information.py │ │ ├── login.py │ │ └── subscription.py │ ├── credential │ ├── ApiToken.py │ ├── JwtToken.py │ ├── UserAuth.py │ ├── __init__.py │ └── _base.py │ ├── sdk │ ├── __init__.py │ ├── ai │ │ ├── LICENSE │ │ ├── __init__.py │ │ ├── _const.py │ │ ├── _cost.py │ │ ├── _enum.py │ │ ├── augment_image │ │ │ ├── __init__.py │ │ │ └── _enum.py │ │ ├── generate │ │ │ ├── __init__.py │ │ │ ├── _const.py │ │ │ ├── _enum.py │ │ │ ├── _schema.py │ │ │ └── themes.json │ │ ├── generate_image │ │ │ ├── __init__.py │ │ │ ├── params.py │ │ │ ├── schema.py │ │ │ ├── suggest_tags.py │ │ │ └── tokenizer.py │ │ ├── generate_stream.py │ │ ├── generate_voice │ │ │ ├── __init__.py │ │ │ └── _enum.py │ │ └── upscale.py │ ├── schema.py │ └── user │ │ ├── __init__.py │ │ ├── information.py │ │ ├── login.py │ │ └── subscription.py │ ├── server.py │ ├── tokenizer │ ├── __init__.py │ ├── clip_simple_tokenizer.py │ ├── nerdstash_v1.model │ ├── nerdstash_v2.model │ ├── novelai.model │ └── novelai_v2.model │ ├── tool │ ├── __init__.py │ ├── image_metadata │ │ ├── __init__.py │ │ ├── bch_utils.py │ │ ├── lsb_extractor.py │ │ └── lsb_injector.py │ ├── paint_mask │ │ └── __init__.py │ └── random_prompt │ │ ├── __init__.py │ │ ├── generate_scene_composition.py │ │ ├── generate_scene_tags.py │ │ └── generate_tags.py │ └── utils │ ├── __init__.py │ ├── encode.py │ └── useful.py └── tests ├── __init__.py ├── test_generate_voice.py ├── test_random_prompt.py ├── test_server.py ├── test_server_run.py ├── test_tokenizer.py ├── test_upscale.py ├── test_user_information.py ├── test_user_login.py └── test_user_subscription.py /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Pull requests, bug reports, and all other forms of contribution are welcomed and highly encouraged! :octocat: 4 | 5 | ## 📝 Overview 6 | 7 | - `src/novelai_python/utils` should only contain **utility** functions **for** other modules. Tool **interfaces** are 8 | not allowed to be placed in this directory. 9 | 10 | ## 📦 Branching 11 | 12 | - `main` is the default branch. 13 | - `dev` is the development branch. 14 | 15 | We work on the `dev` branch and create pull requests to merge into `main`. 16 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | on: 3 | workflow_dispatch: 4 | push: 5 | tags: 6 | - pypi* 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | pypi-publish: 13 | name: upload release to PyPI 14 | runs-on: ubuntu-latest 15 | permissions: 16 | # IMPORTANT: this permission is mandatory for trusted publishing 17 | id-token: write 18 | steps: 19 | - uses: actions/checkout@v3 20 | 21 | - uses: pdm-project/setup-pdm@v3 22 | 23 | - name: Publish package distributions to PyPI 24 | run: pdm publish 25 | -------------------------------------------------------------------------------- /.github/workflows/python_test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | - dev 9 | - develop 10 | - '**-develop' 11 | 12 | jobs: 13 | Testing: 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | matrix: 17 | python-version: [ '3.9', '3.10', '3.11','3.12' ] 18 | os: [ ubuntu-latest ] #, windows-latest, macos-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up PDM 23 | uses: pdm-project/setup-pdm@v3 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | 27 | - name: Install dependencies 28 | run: | 29 | pdm install --no-lock -G testing 30 | - name: Run Tests 31 | run: | 32 | pdm run -v pytest tests -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm-project.org/#use-with-ide 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | .idea/ 163 | /playground/generate_image.png 164 | /playground/generate_image_img2img.png 165 | /playground/vibe_inpaint.png 166 | /playground/vibe_img2img.png 167 | /playground/vibe.png 168 | /playground/upscale.py.png 169 | /playground/upscale.png 170 | /playground/test.png 171 | /playground/random_play/ 172 | /playground/upscale/ 173 | /playground/vibe/ 174 | /playground/mask/ 175 | /playground/newtag/ 176 | /playground/oldtag/ 177 | /playground/art_assert/ 178 | /playground/unpack/ 179 | /playground/boom-train/ 180 | /frontend/ 181 | -------------------------------------------------------------------------------- /.nerve.toml: -------------------------------------------------------------------------------- 1 | # https://github.com/LlmKira/contributor/blob/main/.nerve.toml 2 | contributor = "9f30f440-3d09-43dd-aa61-285c66f89356" 3 | language = "English" 4 | issue_auto_label = true 5 | issue_title_format = true 6 | issue_body_format = false 7 | issue_close_with_report = true 8 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v3.2.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-yaml 8 | - id: check-added-large-files 9 | 10 | - repo: https://github.com/astral-sh/ruff-pre-commit 11 | # Ruff version. 12 | rev: v0.1.7 13 | hooks: 14 | # Run the linter. 15 | - id: ruff 16 | args: [ --fix ] 17 | # Run the formatter. 18 | - id: ruff-format 19 | # pdm lock 20 | - repo: https://github.com/pdm-project/pdm 21 | rev: 2.12.1 # a PDM release exposing the hook 22 | hooks: 23 | - id: pdm-lock-check 24 | -------------------------------------------------------------------------------- /NOTICE.MD: -------------------------------------------------------------------------------- 1 | # NOTICE OF THIS PROJECT 2 | 3 | ## MIT License 4 | 5 | The MIT license applies to the files in: 6 | 7 | file: "src/novelai_python/sdk/ai/generate-image.py" from https://github.com/HanaokaYuzu/NovelAI-API 8 | file: "src/novelai_python/tokenizer/novelai.model" from https://github.com/NovelAI/novelai-tokenizer 9 | file: "src/novelai_python/tokenizer/novelai_v2.model from https://github.com/NovelAI/novelai-tokenizer 10 | file: "src/novelai_python/tool/image_metadata/lsb_injector.py" from https://github.com/NovelAI/novelai-image-metadata 11 | -------------------------------------------------------------------------------- /playground/augment-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/playground/augment-image.png -------------------------------------------------------------------------------- /playground/augment-image.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/1/26 下午12:23 3 | # @Author : sudoskys 4 | # @File : __init__.py.py 5 | 6 | import asyncio 7 | import os 8 | import pathlib 9 | 10 | from dotenv import load_dotenv 11 | from pydantic import SecretStr 12 | 13 | from novelai_python import APIError, LoginCredential, JwtCredential, ImageGenerateResp 14 | from novelai_python import AugmentImageInfer 15 | from novelai_python.sdk.ai.augment_image import ReqType, Moods 16 | 17 | 18 | async def generate( 19 | image, 20 | request_type: ReqType = ReqType.SKETCH, 21 | ): 22 | jwt = os.getenv("NOVELAI_JWT", None) 23 | if jwt is None: 24 | raise ValueError("NOVELAI_JWT is not set in `.env` file, please create one and set it") 25 | credential = JwtCredential(jwt_token=SecretStr(jwt)) 26 | """Or you can use the login credential to get the renewable jwt token""" 27 | _login_credential = LoginCredential( 28 | username=os.getenv("NOVELAI_USER"), 29 | password=SecretStr(os.getenv("NOVELAI_PASS")) 30 | ) 31 | try: 32 | agent = AugmentImageInfer.build( 33 | req_type=request_type, 34 | image=image, 35 | # mood=Moods.Shy, 36 | prompt="", 37 | defry=0, 38 | ) 39 | print(f"charge: {agent.calculate_cost(is_opus=True)} if you are vip3") 40 | # print(f"charge: {agent.calculate_cost(is_opus=False)} if you are not vip3") 41 | result = await agent.request( 42 | session=credential 43 | ) 44 | except APIError as e: 45 | print(f"Error: {e.message}") 46 | return None 47 | else: 48 | print(f"Meta: {result.meta.endpoint}") 49 | _res: ImageGenerateResp 50 | file = result.files[0] 51 | with open(f"{pathlib.Path(__file__).stem}.png", "wb") as f: 52 | f.write(file[1]) 53 | 54 | 55 | load_dotenv() 56 | loop = asyncio.new_event_loop() 57 | loop.run_until_complete( 58 | generate( 59 | image=pathlib.Path(__file__).parent / "static_image.png", 60 | request_type=ReqType.SKETCH 61 | ) 62 | ) 63 | -------------------------------------------------------------------------------- /playground/banner-raw.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/playground/banner-raw.png -------------------------------------------------------------------------------- /playground/enhance.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/13 下午1:58 3 | # @Author : sudoskys 4 | # @File : enhance.py 5 | 6 | from loguru import logger 7 | import random 8 | from typing import Tuple 9 | from novelai_python.sdk.ai.generate_image import Model, Params, UCPreset, Sampler 10 | from novelai_python.sdk.ai._enum import get_supported_params 11 | 12 | # NOTE About Enhance Mode 13 | # Enhance Mode = origin model name + img2img action + width *1.5(or 1) +height *1.5(or 1) + !!diff seed!! 14 | # :) 15 | logger.info("Enhance Mode = origin model name + img2img action + width *1.5(or 1) +height *1.5(or 1) + diff seed") 16 | logger.warning("If you use the nai-generated image as input, please diff the seed!") 17 | 18 | 19 | async def enhance_image( 20 | model: Model, 21 | original_image: bytes, 22 | original_width: int, 23 | original_height: int, 24 | prompt: str, 25 | enhance_scale: float = 1.5, 26 | enhance_strength: float = 0.7, 27 | enhance_noise: float = 0.1, 28 | quality_toggle: bool = True, 29 | uc_preset: UCPreset = UCPreset.TYPE0, 30 | character_prompts: list = None, 31 | ) -> Tuple[str, Params]: 32 | """ 33 | Generate enhanced image with novelai api 34 | 35 | Args: 36 | model: The model to use 37 | original_image: The original image data 38 | original_width: The original image width 39 | original_height: The original image height 40 | prompt: The prompt 41 | enhance_scale: The enhance scale, default 1.5 42 | enhance_strength: The enhance strength, default 0.7 43 | enhance_noise: The enhance noise, default 0.1 44 | quality_toggle: Whether to enable quality toggle 45 | uc_preset: UC preset 46 | character_prompts: The character prompts list 47 | 48 | Returns: 49 | Tuple[str, Params]: The processed prompt and parameters 50 | """ 51 | # Calculate new size 52 | new_width = int(original_width * enhance_scale) 53 | new_height = int(original_height * enhance_scale) 54 | 55 | # Base parameters setting 56 | params = Params( 57 | width=new_width, 58 | height=new_height, 59 | n_samples=1, 60 | image=original_image, # base64 encoded image 61 | strength=enhance_strength, 62 | noise=enhance_noise, 63 | seed=random.randint(0, 4294967295 - 7), # Use different seed 64 | character_prompts=character_prompts or [], 65 | quality_toggle=quality_toggle, 66 | uc_preset=uc_preset, 67 | sampler=Sampler.K_EULER_ANCESTRAL, 68 | ) 69 | 70 | # Process prompt 71 | final_prompt = prompt 72 | if get_supported_params(model).enhancePromptAdd and "upscaled, blurry" not in prompt: 73 | # Add negative prompt 74 | negative_prompt = ", -2::upscaled, blurry::," 75 | # Insert negative prompt after the first comma 76 | if "," in prompt: 77 | parts = prompt.split(",", 1) 78 | final_prompt = parts[0] + negative_prompt + "," + parts[1] 79 | else: 80 | final_prompt += negative_prompt 81 | 82 | return final_prompt, params 83 | 84 | 85 | # Example usage 86 | async def example_usage(): 87 | """ 88 | Example usage of enhance mode 89 | """ 90 | # Assume we have an original image 91 | with open("original.png", "rb") as f: 92 | original_image = f.read() 93 | 94 | # Call enhance function 95 | prompt, params = await enhance_image( 96 | model=Model.NAI_DIFFUSION_4_5_CURATED, 97 | original_image=original_image, 98 | original_width=832, 99 | original_height=1216, 100 | prompt="1girl, masterpiece, best quality", 101 | enhance_scale=1.5, 102 | enhance_strength=0.7, 103 | enhance_noise=0.1, 104 | ) 105 | 106 | logger.info(f"Enhanced prompt: {prompt}") 107 | logger.info(f"Enhanced params: {params}") 108 | -------------------------------------------------------------------------------- /playground/generate.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | 4 | from dotenv import load_dotenv 5 | from loguru import logger 6 | from pydantic import SecretStr 7 | 8 | from novelai_python import APIError, LoginCredential 9 | from novelai_python import JwtCredential 10 | from novelai_python.sdk.ai.generate import TextLLMModel, LLM, AdvanceLLMSetting 11 | from novelai_python.sdk.ai.generate._enum import get_model_preset 12 | 13 | load_dotenv() 14 | jwt = os.getenv("NOVELAI_JWT", None) 15 | if jwt is None: 16 | raise ValueError("NOVELAI_JWT is not set in `.env` file, please create one and set it") 17 | credential = JwtCredential(jwt_token=SecretStr(jwt)) 18 | 19 | 20 | async def chat(prompt="Hello"): 21 | """Or you can use the login credential to get the renewable jwt token""" 22 | _login_credential = LoginCredential( 23 | username=os.getenv("NOVELAI_USER"), 24 | password=SecretStr(os.getenv("NOVELAI_PASS")) 25 | ) 26 | # await _login_credential.request() 27 | # print(f"Model List:{enum_to_list(TextLLMModel)}") 28 | try: 29 | agent = LLM.build( 30 | prompt=prompt, 31 | model=TextLLMModel.ERATO, 32 | parameters=get_model_preset(TextLLMModel.ERATO).get_all_presets()[0].parameters, 33 | advanced_setting=AdvanceLLMSetting( 34 | min_length=1, 35 | max_length=None, # Auto 36 | ) 37 | ) 38 | # NOTE:parameter > advanced_setting, which logic in generate/__init__.py 39 | # If you not pass the parameter, it will use the default preset. 40 | # So if you want to set the generation params, you should pass your own params. 41 | # Only if you want to use some params not affect the generation, you can use advanced_setting. 42 | result = await agent.request(session=_login_credential) 43 | except APIError as e: 44 | logger.exception(e) 45 | print(f"Error: {e.message}") 46 | return None 47 | except Exception as e: 48 | logger.exception(e) 49 | else: 50 | print(f"Result:\n{result.text}") 51 | 52 | 53 | loop = asyncio.new_event_loop() 54 | loop.run_until_complete(chat( 55 | prompt="a fox jumped over the lazy dog, and the dog barked at the fox. The fox ran away." 56 | )) 57 | -------------------------------------------------------------------------------- /playground/generate_image.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/1/26 下午12:23 3 | # @Author : sudoskys 4 | # @File : __init__.py.py 5 | 6 | import asyncio 7 | import base64 8 | import os 9 | import pathlib 10 | import random 11 | 12 | from dotenv import load_dotenv 13 | from pydantic import SecretStr 14 | 15 | from novelai_python import APIError, LoginCredential 16 | from novelai_python import GenerateImageInfer, ImageGenerateResp, ApiCredential 17 | from novelai_python.sdk.ai.generate_image import Action, Model, Sampler, Character, UCPreset, Params 18 | from novelai_python.sdk.ai.generate_image.schema import PositionMap 19 | from novelai_python.sdk.ai.generate_image.tokenizer import get_prompt_tokenizer 20 | from novelai_python.tool.random_prompt import RandomPromptGenerator 21 | from novelai_python.utils.useful import enum_to_list 22 | 23 | 24 | async def generate( 25 | prompt=None, 26 | ): 27 | jwt_token = os.getenv("NOVELAI_JWT", None) 28 | if jwt_token is None: 29 | raise ValueError("NOVELAI_JWT is not set in `.env` file, please create one and set it") 30 | jwt_credential = ApiCredential(api_token=SecretStr(jwt_token)) 31 | 32 | """Or you can use the login credential to get the renewable jwt token""" 33 | login_credential = LoginCredential( 34 | username=os.getenv("NOVELAI_USER"), 35 | password=SecretStr(os.getenv("NOVELAI_PASS")) 36 | ) 37 | 38 | model = Model.NAI_DIFFUSION_4_5_CURATED 39 | 40 | # await _login_credential.request() 41 | print(f"Action List:{enum_to_list(Action)}") 42 | print( 43 | """ 44 | PositionMap 45 | .1 .3 .5 .7 .9 46 | A1 B1 C1 D1 E1 47 | A2 B2 C2 D2 E2 48 | A3 B3 C3 D3 E3 49 | .............. 50 | A5 B5 C5 D5 E5 51 | """ 52 | ) 53 | 54 | # Randomly generate a scene 55 | prompt_generator = RandomPromptGenerator() 56 | scene = prompt_generator.generate_scene_composition() 57 | if prompt is None: 58 | prompt = scene.pop(0) + ','.join([ 59 | 'muelsyse (arknights)' 60 | ]) 61 | character = [ 62 | Character( 63 | prompt=c_prompt, 64 | uc="red hair", 65 | center=PositionMap.B2 66 | ) for c_prompt in scene 67 | ] 68 | 69 | # Length 70 | tokenizer = get_prompt_tokenizer(model=model) 71 | print( 72 | f"Prompt Length {len(tokenizer.encode(prompt))}" 73 | ) 74 | 75 | # Generate 76 | try: 77 | agent = GenerateImageInfer.build_generate( 78 | prompt=os.getenv("TEST_TAG", prompt), 79 | width=1024, 80 | height=1024, 81 | model=model, 82 | character_prompts=None if os.getenv("TEST_TAG") else character, 83 | sampler=Sampler.K_EULER_ANCESTRAL, 84 | ucPreset=UCPreset.TYPE0, 85 | # Recommended, using preset negative_prompt depends on selected model 86 | qualityToggle=True, 87 | decrisp_mode=False, 88 | variety_boost=False, 89 | # Checkbox in novelai.net 90 | ).set_mutual_exclusion(True) 91 | print(f"charge: {agent.calculate_cost(is_opus=True)} if you are vip3") 92 | print(f"charge: {agent.calculate_cost(is_opus=False)} if you are not vip3") 93 | result = await agent.request( 94 | session=login_credential 95 | ) 96 | except APIError as e: 97 | print(f"Error: {e.message}") 98 | return None 99 | else: 100 | print(f"Meta: {result.meta}") 101 | _res: ImageGenerateResp 102 | file = result.files[0] 103 | with open(f"{pathlib.Path(__file__).stem}.png", "wb") as f: 104 | f.write(file[1]) 105 | 106 | 107 | async def direct_use(): 108 | """ 109 | Don't like use build-in method? you can directly initialize the class. 110 | that's pydantic! 111 | :return: 112 | """ 113 | credential = ApiCredential(api_token=SecretStr("pst-5555")) 114 | result: ImageGenerateResp = await GenerateImageInfer( 115 | input="1girl", 116 | model=Model.NAI_DIFFUSION_4_5_FULL, 117 | parameters=Params( 118 | width=832, 119 | height=1216, 120 | characterPrompts=[], 121 | seed=random.randint(0, 4294967295 - 7), 122 | scale=6, 123 | qualityToggle=True, 124 | sampler=Sampler.K_EULER_ANCESTRAL, 125 | ucPreset=UCPreset.TYPE0, 126 | steps=23, 127 | n_samples=1, 128 | ) 129 | ).request(session=credential) 130 | print(f"Meta: {result.meta}") 131 | file = result.files[0] 132 | with open(f"{pathlib.Path(__file__).stem}.png", "wb") as f: 133 | f.write(file[1]) 134 | 135 | 136 | load_dotenv() 137 | loop = asyncio.new_event_loop() 138 | loop.run_until_complete(generate()) 139 | -------------------------------------------------------------------------------- /playground/generate_image_img2img.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/9 下午10:04 3 | # @Author : sudoskys 4 | # @File : generate_image_img2img.py 5 | 6 | import asyncio 7 | import base64 8 | import os 9 | import pathlib 10 | 11 | from dotenv import load_dotenv 12 | from loguru import logger 13 | from pydantic import SecretStr 14 | 15 | from novelai_python import APIError, LoginCredential 16 | from novelai_python import GenerateImageInfer, ImageGenerateResp, ApiCredential 17 | from novelai_python.sdk.ai._enum import Model 18 | from novelai_python.sdk.ai.generate_image import Action, Sampler 19 | from novelai_python.utils.useful import enum_to_list 20 | 21 | 22 | async def generate( 23 | prompt="1girl, year 2023, dynamic angle, best quality, amazing quality, very aesthetic, absurdres", 24 | image_path="static_image.png" 25 | ): 26 | jwt = os.getenv("NOVELAI_JWT", None) 27 | if jwt is None: 28 | raise ValueError("NOVELAI_JWT is not set in `.env` file, please create one and set it") 29 | credential = ApiCredential(api_token=SecretStr(jwt)) 30 | """Or you can use the login credential to get the jwt token""" 31 | _login_credential = LoginCredential( 32 | username=os.getenv("NOVELAI_USER"), 33 | password=SecretStr(os.getenv("NOVELAI_PASS")) 34 | ) 35 | # await _login_credential.request() 36 | print(f"Action List:{enum_to_list(Action)}") 37 | print(f"Image Path: {image_path}") 38 | try: 39 | if not os.path.exists(image_path): 40 | raise ValueError(f"Image not found: {image_path}") 41 | with open(image_path, "rb") as f: 42 | # Base64 encode the image 43 | image = base64.b64encode(f.read()).decode() 44 | # image = f.read() # Or you can use the raw bytes 45 | agent = GenerateImageInfer.build_img2img( 46 | model=Model.NAI_DIFFUSION_4_5_FULL, 47 | prompt=prompt, 48 | sampler=Sampler.K_DPMPP_SDE, 49 | image=image, 50 | ) 51 | print(f"charge: {agent.calculate_cost(is_opus=True)} if you are vip3") 52 | print(f"charge: {agent.calculate_cost(is_opus=False)} if you are not vip3") 53 | result = await agent.request( 54 | session=_login_credential 55 | ) 56 | logger.info("Using login credential") 57 | except APIError as e: 58 | print(f"Error: {e.message}") 59 | return None 60 | else: 61 | print(f"Meta: {result.meta}") 62 | _res: ImageGenerateResp 63 | file = result.files[0] 64 | with open(f"{pathlib.Path(__file__).stem}.png", "wb") as f: 65 | f.write(file[1]) 66 | logger.warning(f"If you use the nai-generated image as input,please diff the seed!") 67 | 68 | 69 | load_dotenv() 70 | loop = asyncio.new_event_loop() 71 | loop.run_until_complete(generate(image_path="static_refer.png")) 72 | -------------------------------------------------------------------------------- /playground/generate_stream.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | 4 | from dotenv import load_dotenv 5 | from loguru import logger 6 | from pydantic import SecretStr 7 | 8 | from novelai_python import APIError, LoginCredential 9 | from novelai_python import JwtCredential 10 | from novelai_python.sdk.ai.generate_stream import TextLLMModel, LLMStream, LLMStreamResp 11 | 12 | load_dotenv() 13 | jwt = os.getenv("NOVELAI_JWT", None) 14 | if jwt is None: 15 | raise ValueError("NOVELAI_JWT is not set in `.env` file, please create one and set it") 16 | credential = JwtCredential(jwt_token=SecretStr(jwt)) 17 | 18 | 19 | def loop_connect(resp: list): 20 | b = [] 21 | for i in resp: 22 | b.append(i.text) 23 | return ''.join(b) 24 | 25 | 26 | async def stream(prompt="Hello"): 27 | """Or you can use the login credential to get the renewable jwt token""" 28 | _login_credential = LoginCredential( 29 | username=os.getenv("NOVELAI_USER"), 30 | password=SecretStr(os.getenv("NOVELAI_PASS")) 31 | ) 32 | # await _login_credential.request() 33 | # print(f"Model List:{enum_to_list(TextLLMModel)}") 34 | try: 35 | agent = LLMStream.build( 36 | prompt=prompt, 37 | model=TextLLMModel.ERATO, 38 | ) 39 | _data = [] 40 | generator = agent.request(session=_login_credential) 41 | async for data in generator: 42 | data: LLMStreamResp 43 | _data.append(data) 44 | except APIError as e: 45 | print(f"Error: {e.message}") 46 | return None 47 | except Exception as e: 48 | logger.exception(e) 49 | else: 50 | print(f"Meta: {loop_connect(_data)}") 51 | 52 | 53 | loop = asyncio.new_event_loop() 54 | loop.run_until_complete(stream()) 55 | -------------------------------------------------------------------------------- /playground/generate_voice.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import pathlib 4 | 5 | from dotenv import load_dotenv 6 | from pydantic import SecretStr 7 | 8 | from novelai_python import VoiceGenerate, VoiceResponse, JwtCredential, APIError 9 | from novelai_python.sdk.ai.generate_voice import VoiceSpeakerV2 10 | from novelai_python.utils.useful import enum_to_list 11 | 12 | load_dotenv() 13 | jwt = os.getenv("NOVELAI_JWT", None) 14 | if jwt is None: 15 | raise ValueError("NOVELAI_JWT is not set in `.env` file, please create one and set it") 16 | 17 | 18 | async def generate_voice(text: str): 19 | credential = JwtCredential(jwt_token=SecretStr(jwt)) 20 | """Or you can use the login credential to get the renewable jwt token""" 21 | # await _login_credential.request() 22 | print(f"VoiceSpeakerV2 List:{enum_to_list(VoiceSpeakerV2)}") 23 | try: 24 | voice_gen = VoiceGenerate.build( 25 | text=text, 26 | speaker=VoiceSpeakerV2.Ligeia, # VoiceSpeakerV2.Ligeia, 27 | ) 28 | result = await voice_gen.request( 29 | session=credential 30 | ) 31 | except APIError as e: 32 | print(f"Error: {e.message}") 33 | return None 34 | else: 35 | print(f"Meta: {result.meta}") 36 | _res: VoiceResponse 37 | # 写出到 同名的 mp3 文件 38 | file = result.audio 39 | with open(f"{pathlib.Path(__file__).stem}.mp3", "wb") as f: 40 | f.write(file) 41 | 42 | 43 | loop = asyncio.new_event_loop() 44 | loop.run_until_complete( 45 | generate_voice("Hello, I am a test voice, limit 1000 characters") 46 | ) 47 | -------------------------------------------------------------------------------- /playground/image_metadata/apply_lsb_to_image.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from PIL import Image 4 | 5 | from novelai_python.tool.image_metadata import ImageMetadata, ImageLsbDataExtractor, ImageVerifier 6 | 7 | ''' 8 | You can apply metadata to an image using the LSB method. 9 | ''' 10 | 11 | image = Path(__file__).parent.joinpath("sample-0316.png") 12 | write_out = Path(__file__).parent.joinpath("sample-0316-out.png") 13 | try: 14 | with Image.open(image) as img: 15 | meta = ImageMetadata.load_image(img) 16 | with Image.open(image) as img: 17 | new_io = meta.apply_to_image(img, inject_lsb=True) 18 | with open(write_out, 'wb') as f: 19 | f.write(new_io.getvalue()) 20 | with Image.open(write_out) as img: 21 | new_meta = ImageMetadata.load_image(img) 22 | data = ImageLsbDataExtractor().extract_data(img) 23 | except ValueError: 24 | raise LookupError("Cant find a MetaData") 25 | print(data) 26 | print(new_meta) 27 | print(new_meta.used_model) 28 | with Image.open(write_out) as img: 29 | print(ImageVerifier().verify(img)) 30 | -------------------------------------------------------------------------------- /playground/image_metadata/image_lsb_data_extractor.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from PIL import Image 4 | 5 | from novelai_python.tool.image_metadata.lsb_extractor import ImageLsbDataExtractor 6 | 7 | image = Path(__file__).parent.joinpath("sample-0316.png") 8 | try: 9 | with Image.open(image) as img: 10 | data = ImageLsbDataExtractor().extract_data(img) 11 | except ValueError: 12 | raise LookupError("Cant find a MetaData") 13 | 14 | print(data) 15 | -------------------------------------------------------------------------------- /playground/image_metadata/read_image_info.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | from PIL import Image 5 | from loguru import logger 6 | 7 | """ 8 | Read the metadata from the image using raw PIL 9 | To conveniently read the metadata from the image, check read_image_nai_tag.py 10 | """ 11 | 12 | image_io = Path(__file__).parent.joinpath("sample-0316.png") 13 | with Image.open(image_io) as img: 14 | title = img.info.get("Title", None) 15 | prompt = img.info.get("Description", None) 16 | comment = img.info.get("Comment", None) 17 | print(img.info) 18 | 19 | assert isinstance(comment, str), ValueError("Comment Empty") 20 | try: 21 | comment = json.loads(comment) 22 | except Exception as e: 23 | logger.debug(e) 24 | comment = {} 25 | print(title) 26 | print(prompt) 27 | print(comment) 28 | 29 | """ 30 | AI generated image 31 | 1girl, year 2023, dynamic angle, best quality, amazing quality, very aesthetic, absurdres, rating:general, amazing quality, very aesthetic, absurdres 32 | {'prompt': '1girl, year 2023, dynamic angle, best quality, amazing quality, very aesthetic, absurdres, rating:general, amazing quality, very aesthetic, absurdres', 'steps': 23, 'height': 1216, 'width': 832, 'scale': 6.0, 'uncond_scale': 0.0, 'cfg_rescale': 0.0, 'seed': 3685348292, 'n_samples': 1, 'noise_schedule': 'karras', 'legacy_v3_extend': False, 'reference_information_extracted_multiple': [], 'reference_strength_multiple': [], 'extra_passthrough_testing': {'prompt': None, 'uc': None, 'hide_debug_overlay': False, 'r': 0.0, 'eta': 1.0, 'negative_momentum': 0.0}, 'v4_prompt': {'caption': {'base_caption': '1girl, year 2023, dynamic angle, best quality, amazing quality, very aesthetic, absurdres, rating:general, amazing quality, very aesthetic, absurdres', 'char_captions': [{'char_caption': '1girl', 'centers': [{'x': 0.0, 'y': 0.0}]}, {'char_caption': '1boy', 'centers': [{'x': 0.9, 'y': 0.9}]}]}, 'use_coords': True, 'use_order': True}, 'v4_negative_prompt': {'caption': {'base_caption': 'lowres', 'char_captions': [{'char_caption': 'red hair', 'centers': [{'x': 0.0, 'y': 0.0}]}, {'char_caption': '', 'centers': [{'x': 0.9, 'y': 0.9}]}]}, 'use_coords': False, 'use_order': False}, 'sampler': 'k_euler_ancestral', 'controlnet_strength': 1.0, 'controlnet_model': None, 'dynamic_thresholding': False, 'dynamic_thresholding_percentile': 0.999, 'dynamic_thresholding_mimic_scale': 10.0, 'sm': False, 'sm_dyn': False, 'skip_cfg_above_sigma': None, 'skip_cfg_below_sigma': 0.0, 'lora_unet_weights': None, 'lora_clip_weights': None, 'deliberate_euler_ancestral_bug': False, 'prefer_brownian': True, 'cfg_sched_eligibility': 'enable_for_post_summer_samplers', 'explike_fine_detail': False, 'minimize_sigma_inf': False, 'uncond_per_vibe': True, 'wonky_vibe_correlation': True, 'version': 1, 'uc': 'lowres', 'request_type': 'PromptGenerateRequest', 'signed_hash': 'usuVOlHJg8QFGj4nXAkC7iWKlemqttUcuvjtvGRtPzBZWiHwa/XrcM3p928gsz0F97JMb70YoVYvBG+Cbtu/Bw=='} 33 | """ 34 | -------------------------------------------------------------------------------- /playground/image_metadata/read_image_nai_tag.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/8 下午4:57 3 | # @Author : sudoskys 4 | # @File : read_nai_tag.py 5 | from pathlib import Path 6 | 7 | from PIL import Image 8 | 9 | from novelai_python.tool.image_metadata import ImageMetadata, ImageVerifier 10 | 11 | image = Path(__file__).parent.joinpath("sample-0316.png") 12 | with Image.open(image) as img: 13 | image_clear = ImageMetadata.reset_alpha( 14 | image=img, 15 | ) 16 | 17 | try: 18 | with Image.open(image) as img: 19 | meta_auto = ImageMetadata.load_image(img) 20 | meta1 = ImageMetadata.load_from_watermark(img) 21 | meta2 = ImageMetadata.load_from_pnginfo(img) 22 | except ValueError: 23 | raise LookupError("Cant find a MetaData") 24 | 25 | print(meta1.Generation_time) # Meatadata from watermark have no Generation_time... 26 | print(meta2.Generation_time) 27 | print(f"Description: {meta_auto.Description}") 28 | print(f"Comment: {meta_auto.Comment}") 29 | print(f"Request Method: {meta_auto.Comment.request_type}") 30 | print(f"Used image model: {meta_auto.used_model}") 31 | # Verify if the image is from NovelAI 32 | with Image.open(image) as img: 33 | is_novelai, have_latent = ImageVerifier().verify(image=img) 34 | print(f"Is NovelAI: {is_novelai}") 35 | print(f"Have Latent: {have_latent}") 36 | -------------------------------------------------------------------------------- /playground/image_metadata/sample-0316-out.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/playground/image_metadata/sample-0316-out.png -------------------------------------------------------------------------------- /playground/image_metadata/sample-0316.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/playground/image_metadata/sample-0316.png -------------------------------------------------------------------------------- /playground/image_metadata/sample-0317.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/playground/image_metadata/sample-0317.png -------------------------------------------------------------------------------- /playground/image_metadata/verify_is_nai_generated.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from PIL import Image 4 | 5 | from novelai_python.tool.image_metadata import ImageVerifier 6 | 7 | image = Path(__file__).parent.joinpath("sample-0316.png") 8 | try: 9 | with Image.open(image) as img: 10 | verify, have_latent = ImageVerifier().verify(image=img) 11 | except ValueError: 12 | raise LookupError("Cant find a MetaData") 13 | 14 | print(f"It is a NovelAI image: {verify}") 15 | -------------------------------------------------------------------------------- /playground/information.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/8 下午4:34 3 | # @Author : sudoskys 4 | # @File : information.py 5 | 6 | import asyncio 7 | import os 8 | 9 | from dotenv import load_dotenv 10 | from loguru import logger 11 | from pydantic import SecretStr 12 | 13 | from novelai_python import APIError 14 | from novelai_python import Information, InformationResp, JwtCredential 15 | 16 | load_dotenv() 17 | 18 | enhance = "year 2023,dynamic angle, best quality, amazing quality, very aesthetic, absurdres" 19 | token = None 20 | jwt = os.getenv("NOVELAI_JWT") or token 21 | 22 | 23 | async def main(): 24 | globe_s = JwtCredential(jwt_token=SecretStr(jwt)) 25 | try: 26 | _res = await Information().request( 27 | session=globe_s 28 | ) 29 | _res: InformationResp 30 | print(f"Information: {_res}") 31 | print(_res.model_dump()) 32 | except APIError as e: 33 | logger.exception(e) 34 | print(e.__dict__) 35 | return 36 | 37 | 38 | loop = asyncio.get_event_loop() 39 | loop.run_until_complete(main()) 40 | -------------------------------------------------------------------------------- /playground/login.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/7 下午12:07 3 | # @Author : sudoskys 4 | # @File : login.py 5 | 6 | import asyncio 7 | import os 8 | 9 | from dotenv import load_dotenv 10 | from loguru import logger 11 | 12 | from novelai_python import APIError 13 | from novelai_python import Login, LoginResp 14 | 15 | load_dotenv() 16 | 17 | 18 | async def main(): 19 | try: 20 | _res = await Login.build(user_name=os.getenv("NOVELAI_USER"), password=os.getenv("NOVELAI_PASS") 21 | ).request() 22 | except APIError as e: 23 | logger.exception(e) 24 | print(e.__dict__) 25 | return 26 | 27 | _res: LoginResp 28 | print(_res) 29 | print(f"Access Token: {_res.accessToken}") 30 | 31 | 32 | loop = asyncio.get_event_loop() 33 | loop.run_until_complete(main()) 34 | -------------------------------------------------------------------------------- /playground/paint_mask/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/13 下午12:42 3 | # @Author : sudoskys 4 | # @File : __init__.py.py 5 | 6 | from novelai_python.tool.paint_mask import create_mask_from_sketch 7 | 8 | with open('sk.jpg', 'rb') as f: 9 | sk_bytes = f.read() 10 | 11 | with open('ori.png', 'rb') as f: 12 | ori_bytes = f.read() 13 | 14 | return_bytes = create_mask_from_sketch( 15 | original_img_bytes=ori_bytes, 16 | sketch_img_bytes=sk_bytes, 17 | jagged_edges=True, 18 | min_block_size=15 19 | ) 20 | 21 | with open('mask_export.png', 'wb') as f: 22 | f.write(return_bytes) 23 | -------------------------------------------------------------------------------- /playground/random_prompt.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/7 下午12:51 3 | # @Author : sudoskys 4 | # @File : random_prompt.py 5 | 6 | 7 | from novelai_python.tool.random_prompt import RandomPromptGenerator 8 | 9 | gen = RandomPromptGenerator() 10 | for i in range(200): 11 | s = RandomPromptGenerator() 12 | print(s.generate_common_tags(nsfw=False)) 13 | print(s.generate_scene_tags()) 14 | print(s.generate_scene_composition()) 15 | print(s.get_holiday_themed_tags()) 16 | print(s.generate_character( 17 | tags=["vampire", "werewolf"], 18 | gender="f", 19 | additional_tags="", 20 | character_limit=1, 21 | )) 22 | print(s.generate_character_traits( 23 | gender="f", 24 | portrait_type="half-length portrait", 25 | level=1 26 | )) 27 | -------------------------------------------------------------------------------- /playground/simple.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from novelai_python.sdk.ai.generate_image import Model 4 | 5 | 6 | async def main(): 7 | from pydantic import SecretStr 8 | from novelai_python import GenerateImageInfer, LoginCredential 9 | credential = LoginCredential( 10 | username="NOVELAI_USERNAME", 11 | password=SecretStr("NOVELAI_PASSWORD") 12 | ) 13 | gen = GenerateImageInfer.build_generate( 14 | prompt="1girl, year 2023, dynamic angle, best quality, amazing quality, very aesthetic, absurdres", 15 | model=Model.NAI_DIFFUSION_3, 16 | ) 17 | print(f"消耗点数: vip3:{gen.calculate_cost(is_opus=True)}, {gen.calculate_cost(is_opus=False)}") 18 | resp = await gen.request(session=credential) 19 | with open("image.png", "wb") as f: 20 | f.write(resp.files[0][1]) 21 | 22 | 23 | loop = asyncio.new_event_loop() 24 | loop.run_until_complete(main()) 25 | -------------------------------------------------------------------------------- /playground/static_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/playground/static_image.png -------------------------------------------------------------------------------- /playground/static_image_paint.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/playground/static_image_paint.jpg -------------------------------------------------------------------------------- /playground/static_image_paint_mask.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/playground/static_image_paint_mask.png -------------------------------------------------------------------------------- /playground/static_refer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/playground/static_refer.png -------------------------------------------------------------------------------- /playground/static_refer_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/playground/static_refer_banner.png -------------------------------------------------------------------------------- /playground/subscription.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/7 上午10:16 3 | # @Author : sudoskys 4 | # @File : subscription.py 5 | 6 | import asyncio 7 | import os 8 | 9 | from dotenv import load_dotenv 10 | from loguru import logger 11 | from pydantic import SecretStr 12 | 13 | from novelai_python import APIError, SubscriptionResp, LoginCredential 14 | from novelai_python import Subscription, JwtCredential 15 | 16 | load_dotenv() 17 | 18 | enhance = "year 2023,dynamic angle, best quality, amazing quality, very aesthetic, absurdres" 19 | token = None 20 | jwt = os.getenv("NOVELAI_JWT") or token 21 | 22 | 23 | async def main(): 24 | globe_s = JwtCredential(jwt_token=SecretStr(jwt)) 25 | try: 26 | sub = Subscription() 27 | _res = await sub.request( 28 | session=globe_s 29 | ) 30 | _res: SubscriptionResp 31 | print(f"JwtCredential/Subscription: {_res}") 32 | print(_res.is_active) 33 | print(_res.anlas_left) 34 | except APIError as e: 35 | logger.exception(e) 36 | print(e.__dict__) 37 | return 38 | 39 | try: 40 | cre = LoginCredential( 41 | username=os.getenv("NOVELAI_USER"), 42 | password=SecretStr(os.getenv("NOVELAI_PASS")) 43 | ) 44 | _res = await Subscription().request( 45 | session=cre 46 | ) 47 | print(f"LoginCredential/User subscription: {_res}") 48 | print(_res.is_active) 49 | print(_res.anlas_left) 50 | except Exception as e: 51 | logger.exception(e) 52 | 53 | 54 | loop = asyncio.new_event_loop() 55 | loop.run_until_complete(main()) 56 | -------------------------------------------------------------------------------- /playground/suggest_tag.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/13 下午8:25 3 | # @Author : sudoskys 4 | # @File : suggest_tag.py 5 | 6 | import asyncio 7 | import os 8 | 9 | from dotenv import load_dotenv 10 | from loguru import logger 11 | from pydantic import SecretStr 12 | 13 | from novelai_python import APIError 14 | from novelai_python import SuggestTags, SuggestTagsResp, JwtCredential 15 | 16 | load_dotenv() 17 | 18 | token = None 19 | jwt = os.getenv("NOVELAI_JWT") or token 20 | 21 | 22 | async def main(): 23 | globe_s = JwtCredential(jwt_token=SecretStr(jwt)) 24 | try: 25 | _res = await SuggestTags().request( 26 | session=globe_s 27 | ) 28 | _res: SuggestTagsResp 29 | print(f"Information: {_res}") 30 | print(_res.model_dump()) 31 | except APIError as e: 32 | logger.exception(e) 33 | print(e.__dict__) 34 | return 35 | 36 | 37 | loop = asyncio.get_event_loop() 38 | loop.run_until_complete(main()) 39 | -------------------------------------------------------------------------------- /playground/tokenizer/ignore.tokenizer_demo.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import pathlib 4 | import zlib 5 | from typing import Dict, List, Optional 6 | 7 | import requests 8 | from json_repair import repair_json 9 | from pydantic import BaseModel, model_validator 10 | from tokenizers import Tokenizer, pre_tokenizers, Regex, decoders 11 | from tokenizers.models import BPE 12 | 13 | # https://novelai.net/tokenizer/compressed/llama3nai_tokenizer.def?v=2&static=true 14 | 15 | model_name = "clip_tokenizer" 16 | model_full_name = f"{model_name}.def" 17 | url = f"https://novelai.net/tokenizer/compressed/{model_full_name}?v=2&static=true" 18 | if not os.path.exists(model_full_name): 19 | print(f"Downloading {model_full_name} from {url}") 20 | response = requests.get(url) 21 | response.raise_for_status() 22 | # write down 23 | with open(model_full_name, "wb") as f: 24 | f.write(response.content) 25 | 26 | 27 | class TokenizerSetting(BaseModel): 28 | class TokenizerConfig(BaseModel): 29 | splitRegex: str 30 | maxEncodeChars: Optional[int] = None 31 | maxNoWhitespaceChars: Optional[int] = None 32 | ignoreMerges: Optional[bool] = False 33 | 34 | config: TokenizerConfig 35 | specialTokens: List[str] 36 | vocab: Dict[str, int] 37 | merges: list 38 | 39 | @model_validator(mode="after") 40 | def ensure(self): 41 | self.merges = [tuple(merge) for merge in self.merges] 42 | return self 43 | 44 | 45 | # 读取和解压文件 46 | file = pathlib.Path(__file__).parent.joinpath(model_full_name) 47 | encoded_data = file.read_bytes() 48 | decompress_obj = zlib.decompressobj(-zlib.MAX_WBITS) 49 | decode = decompress_obj.decompress(encoded_data) 50 | 51 | # 修复和解析 JSON 52 | repaired_json = repair_json(decode.decode('utf-8'), return_objects=True) 53 | json.dump(repaired_json, open(f"{model_name}.json", "w"), indent=2) 54 | tokenizer_setting = TokenizerSetting.model_validate(repaired_json) 55 | 56 | # 创建 tokenizer 57 | tokenizer = Tokenizer(BPE( 58 | vocab=tokenizer_setting.vocab, 59 | merges=tokenizer_setting.merges, 60 | ignore_merges=tokenizer_setting.config.ignoreMerges 61 | )) 62 | 63 | # 设置特殊 tokens 64 | tokenizer.add_special_tokens(tokenizer_setting.specialTokens) 65 | print(tokenizer.token_to_id(" ")) 66 | if tokenizer_setting.config.maxEncodeChars: 67 | tokenizer.enable_truncation(max_length=tokenizer_setting.config.maxEncodeChars) 68 | # 设置 normalizer 69 | # tokenizer.normalizer = normalizers.Sequence([]) 70 | 71 | # 设置 pre_tokenizer 72 | pre_zus = [ 73 | pre_tokenizers.Split( 74 | behavior="merged_with_next", 75 | pattern=Regex(tokenizer_setting.config.splitRegex) 76 | ), 77 | ] 78 | if tokenizer.token_to_id(" ") is None: 79 | pre_zus.append(pre_tokenizers.ByteLevel(add_prefix_space=False, use_regex=False)) 80 | pre_tokenizer = pre_tokenizers.Sequence(pre_zus) 81 | 82 | tokenizer.pre_tokenizer = pre_tokenizer 83 | tokenizer.decoder = decoders.ByteLevel() 84 | 85 | # 使用 tokenizer 86 | text = "Hello, World! This is a test." 87 | encoded = tokenizer.encode(text, add_special_tokens=True) 88 | print(f"Pre-tokenized text: {pre_tokenizer.pre_tokenize_str(text)}") 89 | print(f"Encoded tokens: {encoded.tokens}") 90 | print(f"Token IDs: {encoded.ids}") 91 | 92 | # 解码 93 | decoded = tokenizer.decode(encoded.ids) 94 | print(f"Decoded text:{decoded}") 95 | -------------------------------------------------------------------------------- /playground/tokenizer/usage.py: -------------------------------------------------------------------------------- 1 | from novelai_python._enum import get_tokenizer_model, TextLLMModel, TextTokenizerGroup 2 | from novelai_python.tokenizer import NaiTokenizer 3 | from novelai_python.utils.encode import b64_to_tokens 4 | 5 | # !Through llm model name to get the tokenizer 6 | tokenizer_package = NaiTokenizer(get_tokenizer_model(TextLLMModel.ERATO)) 7 | 8 | # Directly use the tokenizer! 9 | clip_tokenizer = NaiTokenizer(TextTokenizerGroup.CLIP) 10 | 11 | t_text = "a fox jumped over the lazy dog" 12 | encode_tokens = tokenizer_package.encode(t_text) 13 | print(tokenizer_package.tokenize_text(t_text)) 14 | print(f"Tokenized text: {encode_tokens}") 15 | print(tokenizer_package.decode(tokenizer_package.encode(t_text))) 16 | 17 | b64 = "UfQBADoAAABIAQAAGQAAANwAAAATAAAAexQAAEAAAAD/mwAA2GkAAJ8DAAAXAQAAtT4AAC8WAAA=" 18 | oks = b64_to_tokens(b64) 19 | print(oks) 20 | 21 | 22 | def limit_prompt_shown(raw_text: str, token_limit=225): 23 | assert isinstance(raw_text, str), "raw_text must be a string" 24 | tokenizer = NaiTokenizer(TextTokenizerGroup.NERDSTASH_V2) 25 | token_array = tokenizer.encode(raw_text) 26 | used_tokens_len = len(token_array) 27 | if used_tokens_len > token_limit: 28 | clipped_text = tokenizer.decode(token_array[:token_limit]) 29 | return f"{clipped_text}...{used_tokens_len}/{token_limit}" 30 | else: 31 | return f"{raw_text}" 32 | 33 | 34 | raw_text = "The quick brown fox jumps over the goblin." 35 | print(limit_prompt_shown(raw_text, 5)) 36 | -------------------------------------------------------------------------------- /playground/tokenizer/use_tokenizer_directly.py: -------------------------------------------------------------------------------- 1 | from novelai_python._enum import TextTokenizerGroup 2 | from novelai_python.tokenizer import NaiTokenizer 3 | 4 | # Directly use the tokenizer! 5 | t5_tokenizer = NaiTokenizer(TextTokenizerGroup.T5) 6 | 7 | encoded = t5_tokenizer.encode("a fox jumped over the lazy dog") 8 | print(encoded) 9 | -------------------------------------------------------------------------------- /playground/upscale.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/13 上午11:58 3 | # @Author : sudoskys 4 | # @File : upscale.py 5 | 6 | # -*- coding: utf-8 -*- 7 | # @Time : 2024/2/14 下午5:20 8 | # @Author : sudoskys 9 | # @File : upscale_demo.py 10 | 11 | # To run the demo, you need an event loop, for instance by using asyncio 12 | import asyncio 13 | import os 14 | from pathlib import Path 15 | 16 | from dotenv import load_dotenv 17 | from pydantic import SecretStr 18 | 19 | from novelai_python import APIError, Upscale 20 | from novelai_python import UpscaleResp, JwtCredential 21 | from novelai_python.sdk.ai.generate_image import Action 22 | from novelai_python.utils.useful import enum_to_list 23 | 24 | load_dotenv() 25 | 26 | token = None 27 | jwt = os.getenv("NOVELAI_JWT") or token 28 | 29 | 30 | async def generate( 31 | image_path="static_refer.png" 32 | ): 33 | globe_s = JwtCredential(jwt_token=SecretStr(jwt)) 34 | if not os.path.exists(image_path): 35 | raise FileNotFoundError(f"{image_path} not found") 36 | with open(image_path, "rb") as f: 37 | data = f.read() 38 | try: 39 | print(f"Action List:{enum_to_list(Action)}") 40 | upscale = Upscale(image=data) # Auto detect image size | base64 41 | 42 | _res = await upscale.request( 43 | session=globe_s 44 | ) 45 | except APIError as e: 46 | print(e.response) 47 | return 48 | 49 | # Meta 50 | _res: UpscaleResp 51 | print(_res.meta.endpoint) 52 | file = _res.files 53 | with open(f"{Path(__file__).stem}.png", "wb") as f: 54 | f.write(file[1]) 55 | 56 | 57 | loop = asyncio.get_event_loop() 58 | loop.run_until_complete(generate()) 59 | -------------------------------------------------------------------------------- /playground/vibe.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import asyncio 3 | import os 4 | import pathlib 5 | 6 | from dotenv import load_dotenv 7 | from pydantic import SecretStr 8 | 9 | from novelai_python import APIError, Login 10 | from novelai_python import GenerateImageInfer, ImageGenerateResp, JwtCredential 11 | from novelai_python.sdk.ai.generate_image import Action, Sampler, Model 12 | from novelai_python.utils.useful import enum_to_list 13 | 14 | 15 | async def generate( 16 | prompt="1girl, year 2023, dynamic angle, best quality, amazing quality, very aesthetic, absurdres", 17 | image_path="static_refer_banner.png" 18 | ): 19 | jwt = os.getenv("NOVELAI_JWT", None) 20 | if jwt is None: 21 | raise ValueError("NOVELAI_JWT is not set in `.env` file, please create one and set it") 22 | credential = JwtCredential(jwt_token=SecretStr(jwt)) 23 | """Or you can use the login credential to get the jwt token""" 24 | _login_credential = Login.build( 25 | user_name=os.getenv("NOVELAI_USER"), 26 | password=os.getenv("NOVELAI_PASS") 27 | ) 28 | # await _login_credential.request() 29 | print(f"Action List:{enum_to_list(Action)}") 30 | try: 31 | if not os.path.exists(image_path): 32 | raise ValueError(f"Image not found: {image_path}") 33 | with open(image_path, "rb") as f: 34 | image = f.read() 35 | agent = GenerateImageInfer.build_generate( 36 | prompt=prompt, 37 | sampler=Sampler.K_DPMPP_SDE, 38 | model=Model.NAI_DIFFUSION_3, 39 | reference_image_multiple=[image], 40 | reference_strength_multiple=[0.9], 41 | reference_information_extracted_multiple=[1], 42 | add_original_image=True, 43 | # This Not affect the vibe generation 44 | ) 45 | print(f"charge: {agent.calculate_cost(is_opus=True)} if you are vip3") 46 | print(f"charge: {agent.calculate_cost(is_opus=False)} if you are not vip3") 47 | result = await agent.request( 48 | session=credential 49 | ) 50 | except APIError as e: 51 | print(f"Error: {e.message}") 52 | return None 53 | else: 54 | pass 55 | # print(f"Meta: {result.meta}") 56 | _res: ImageGenerateResp 57 | file = result.files[0] 58 | with open(f"{pathlib.Path(__file__).stem}.png", "wb") as f: 59 | f.write(file[1]) 60 | 61 | 62 | load_dotenv() 63 | loop = asyncio.get_event_loop() 64 | loop.run_until_complete(generate()) 65 | -------------------------------------------------------------------------------- /playground/vibe_img2img.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import asyncio 3 | import os 4 | import pathlib 5 | 6 | from dotenv import load_dotenv 7 | from pydantic import SecretStr 8 | 9 | from novelai_python import APIError, LoginCredential 10 | from novelai_python import GenerateImageInfer, ImageGenerateResp, JwtCredential 11 | from novelai_python.sdk.ai.generate_image import Action, Sampler 12 | from novelai_python.utils.useful import enum_to_list 13 | 14 | 15 | async def generate( 16 | prompt="1girl, year 2023, dynamic angle, best quality, amazing quality, very aesthetic, absurdres", 17 | image_path="static_image.png", 18 | reference_image_path="static_refer.png" 19 | ): 20 | jwt = os.getenv("NOVELAI_JWT", None) 21 | if jwt is None: 22 | raise ValueError("NOVELAI_JWT is not set in `.env` file, please create one and set it") 23 | credential = JwtCredential(jwt_token=SecretStr(jwt)) 24 | """Or you can use the login credential to get the jwt token""" 25 | _login_credential = LoginCredential( 26 | username=os.getenv("NOVELAI_USER"), 27 | password=SecretStr(os.getenv("NOVELAI_PASS")) 28 | ) 29 | # await _login_credential.request() 30 | print(f"Action List:{enum_to_list(Action)}") 31 | try: 32 | if not os.path.exists(image_path): 33 | raise ValueError(f"Image not found: {image_path}") 34 | if not os.path.exists(reference_image_path): 35 | raise ValueError(f"Image not found: {reference_image_path}") 36 | with open(image_path, "rb") as f: 37 | image = f.read() 38 | with open(reference_image_path, "rb") as f: 39 | reference_image = f.read() 40 | agent = GenerateImageInfer.build_img2img( 41 | prompt=prompt, 42 | sampler=Sampler.K_DPMPP_SDE, 43 | image=image, 44 | strength=0.6, 45 | reference_image_multiple=[reference_image], 46 | reference_strength_multiple=[0.9], 47 | reference_information_extracted_multiple=[1], 48 | add_original_image=True, # This Not affect the vibe generation 49 | qualityToggle=True, 50 | ) 51 | print(f"charge: {agent.calculate_cost(is_opus=True)} if you are vip3") 52 | print(f"charge: {agent.calculate_cost(is_opus=False)} if you are not vip3") 53 | result = await agent.request( 54 | session=_login_credential 55 | ) 56 | except APIError as e: 57 | print(f"Error: {e.message}") 58 | return None 59 | else: 60 | print(f"Meta: {result.meta}") 61 | _res: ImageGenerateResp 62 | file = result.files[0] 63 | with open(f"{pathlib.Path(__file__).stem}.png", "wb") as f: 64 | f.write(file[1]) 65 | 66 | 67 | load_dotenv() 68 | loop = asyncio.new_event_loop() 69 | loop.run_until_complete(generate()) 70 | -------------------------------------------------------------------------------- /playground/vibe_inpaint.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import asyncio 3 | import os 4 | import pathlib 5 | 6 | from dotenv import load_dotenv 7 | from pydantic import SecretStr 8 | 9 | from novelai_python import APIError, Login, ApiCredential 10 | from novelai_python import GenerateImageInfer, ImageGenerateResp 11 | from novelai_python.sdk.ai.generate_image import Action, Sampler, Model 12 | from novelai_python.tool.paint_mask import create_mask_from_sketch 13 | from novelai_python.utils.useful import enum_to_list 14 | 15 | with open('static_image.png', 'rb') as f: 16 | ori_bytes = f.read() 17 | with open('static_image_paint.jpg', 'rb') as f: 18 | sk_bytes = f.read() 19 | return_bytes = create_mask_from_sketch(original_img_bytes=ori_bytes, 20 | sketch_img_bytes=sk_bytes, 21 | jagged_edges=True, 22 | min_block_size=15 23 | ) 24 | with open('static_image_paint_mask.png', 'wb') as f: 25 | f.write(return_bytes) 26 | print('Mask exported') 27 | 28 | 29 | async def generate( 30 | prompt="1girl, holding gun, year 2023, dynamic angle, best quality, amazing quality, very aesthetic, absurdres", 31 | image_path="static_image.png", # This is the original image 32 | reference_style_image_path="static_refer.png", # This is the reference image 33 | mask_path="static_image_paint_mask.png", # This is the mask 34 | ): 35 | jwt = os.getenv("NOVELAI_JWT", None) 36 | if jwt is None: 37 | raise ValueError("NOVELAI_JWT is not set in `.env` file, please create one and set it") 38 | credential = ApiCredential(api_token=SecretStr(jwt)) 39 | """Or you can use the login credential to get the jwt token""" 40 | _login_credential = Login.build( 41 | user_name=os.getenv("NOVELAI_USER"), 42 | password=os.getenv("NOVELAI_PASS") 43 | ) 44 | # await _login_credential.request() 45 | print(f"Action List:{enum_to_list(Action)}") 46 | try: 47 | if not os.path.exists(image_path): 48 | raise ValueError(f"Image not found: {image_path}") 49 | if not os.path.exists(reference_style_image_path): 50 | raise ValueError(f"Image not found: {reference_style_image_path}") 51 | if not os.path.exists(mask_path): 52 | raise ValueError(f"Image not found: {mask_path}") 53 | with open(image_path, "rb") as f: 54 | image = f.read() 55 | with open(reference_style_image_path, "rb") as f: 56 | reference_image = f.read() 57 | with open(mask_path, "rb") as f: 58 | mask = f.read() 59 | agent = GenerateImageInfer.build_infill( 60 | prompt=prompt, 61 | model=Model.NAI_DIFFUSION_3_INPAINTING, 62 | sampler=Sampler.K_DPMPP_SDE, 63 | image=image, 64 | mask=mask, 65 | strength=0.6, 66 | 67 | reference_image_multiple=[reference_image], 68 | reference_strength_multiple=[0.9], 69 | reference_information_extracted_multiple=[1], 70 | 71 | add_original_image=True, # This Not affect the vibe generation 72 | qualityToggle=True, 73 | ) 74 | print(f"charge: {agent.calculate_cost(is_opus=True)} if you are vip3") 75 | print(f"charge: {agent.calculate_cost(is_opus=False)} if you are not vip3") 76 | result = await agent.request( 77 | session=credential 78 | ) 79 | except APIError as e: 80 | print(f"Error: {e.message}") 81 | return None 82 | else: 83 | print("Meta in result.meta") 84 | _res: ImageGenerateResp 85 | file = result.files[0] 86 | with open(f"{pathlib.Path(__file__).stem}.png", "wb") as f: 87 | f.write(file[1]) 88 | 89 | 90 | load_dotenv() 91 | loop = asyncio.new_event_loop() 92 | loop.run_until_complete(generate()) 93 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "novelai-python" 3 | version = "0.7.11" 4 | description = "NovelAI Python Binding With Pydantic" 5 | authors = [ 6 | { name = "sudoskys", email = "coldlando@hotmail.com" }, 7 | ] 8 | dependencies = [ 9 | "elara>=0.5.5", 10 | "loguru>=0.7.2", 11 | "pydantic-settings>=2.1.0", 12 | "pydantic>=2.9.0", 13 | "httpx>=0.26.0", 14 | "shortuuid>=1.0.11", 15 | "Pillow>=10.2.0", 16 | "curl-cffi>=0.11.3", 17 | "fastapi>=0.109.0", 18 | "uvicorn[standard]>=0.27.0.post1", 19 | "numpy>=1.24.4", 20 | "argon2-cffi>=23.1.0", 21 | "opencv-python>=4.8.1.78", 22 | "fake-useragent>=1.4.0", 23 | "tenacity>=8.2.3", 24 | "sentencepiece>=0.2.0", 25 | "pynacl>=1.5.0", 26 | "ftfy>=6.2.0", 27 | "regex>=2023.12.25", 28 | "tokenizers>=0.15.2", 29 | "json-repair>=0.29.4", 30 | "robust-downloader>=0.0.2", 31 | "bchlib>=2.1.3", 32 | "arrow>=1.3.0", 33 | ] 34 | requires-python = ">=3.9" 35 | readme = "README.md" 36 | license = { text = "Apache License 2.0" } 37 | 38 | [project.urls] 39 | Repository = "https://github.com/LlmKira/novelai-python/" 40 | Issues = "https://github.com/LlmKira/novelai-python/issues" 41 | 42 | [project.optional-dependencies] 43 | testing = [ 44 | "pytest>=7.4.4", 45 | "pytest-asyncio>=0.23.4", 46 | ] 47 | [build-system] 48 | requires = ["pdm-backend"] 49 | build-backend = "pdm.backend" 50 | 51 | 52 | [tool.pdm] 53 | distribution = true 54 | 55 | [tool.pdm.dev-dependencies] 56 | dev = [ 57 | "pre-commit>=3.5.0", 58 | "wasmtime>=28.0.0", 59 | ] 60 | -------------------------------------------------------------------------------- /record/ai/augment_image/emotion/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/record/ai/augment_image/emotion/image.png -------------------------------------------------------------------------------- /record/ai/augment_image/emotion/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "req_type": "emotion", 3 | "prompt": "neutral;;", 4 | "defry": 0, 5 | "width": 1024, 6 | "height": 1024, 7 | "image": "Base64 Data" 8 | } -------------------------------------------------------------------------------- /record/ai/augment_image/export.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import pathlib 4 | from io import BytesIO 5 | 6 | from PIL import Image 7 | from loguru import logger 8 | 9 | 10 | def ignore(*args, **kwargs): 11 | pass 12 | 13 | 14 | def decode_base64_in_dict(data, current_path): 15 | if isinstance(data, dict): 16 | for k, v in data.items(): 17 | if isinstance(v, dict) or isinstance(v, list): 18 | decode_base64_in_dict(v, current_path) 19 | if isinstance(v, list): 20 | _new_list = [] 21 | for index, item in enumerate(v): 22 | if isinstance(item, str) and len(item) > 100: 23 | try: 24 | # Base64解码 25 | image_bytes = base64.b64decode(item) 26 | image = Image.open(BytesIO(image_bytes)) 27 | except Exception as e: 28 | ignore(e) 29 | else: 30 | logger.info(f"Decoding Base64 data in {k}") 31 | img_name = f"{current_path}/{index}-{k}.png" 32 | image.save(img_name) 33 | _new_list.append('Base64 Data') 34 | if _new_list: 35 | data[k] = _new_list 36 | if isinstance(v, str) and len(v) > 100: 37 | try: 38 | # Base64解码 39 | image_bytes = base64.b64decode(v) 40 | image = Image.open(BytesIO(image_bytes)) 41 | except Exception as e: 42 | ignore(e) 43 | else: 44 | logger.info(f"Decoding Base64 data in {k}") 45 | img_name = f"{current_path}/{k}.png" 46 | image.save(img_name) 47 | data[k] = 'Base64 Data' 48 | elif isinstance(data, list): 49 | for item in data: 50 | if isinstance(item, dict) or isinstance(item, list): 51 | decode_base64_in_dict(item, current_path) 52 | return data 53 | 54 | 55 | def handle_file(filename): 56 | filename_wo_ext = filename.stem 57 | pathlib.Path(filename_wo_ext).mkdir(parents=True, exist_ok=True) 58 | with open(filename, 'r') as file: 59 | json_data = json.load(file) 60 | # 取消 headers 里面 Authorization 字段,然后写回 61 | if 'headers' in json_data: 62 | if 'Authorization' in json_data['headers']: 63 | json_data['headers']['Authorization'] = 'Secret' 64 | # 写回原文件 65 | with open(filename, 'w') as file: 66 | json.dump(json_data, file, indent=2) 67 | if 'authorization' in json_data['headers']: 68 | json_data['headers']['authorization'] = 'Secret' 69 | # 写回原文件 70 | with open(filename, 'w') as file: 71 | json.dump(json_data, file, indent=2) 72 | request_data = json.loads(json_data.get("body", "")) 73 | request_data = decode_base64_in_dict(request_data, filename_wo_ext) 74 | # 写出包含替换字段的 JSON 文件回同名的文件夹 75 | with open(f"{filename_wo_ext}/schema.json", 'w') as jsonfile: 76 | json.dump(request_data, jsonfile, indent=2) 77 | 78 | 79 | def main(): 80 | # 列出当前文件夹内所有的 .json 文件 81 | json_files = pathlib.Path('.').glob('*.json') 82 | for file in json_files: 83 | logger.info(f"Handling {file}") 84 | handle_file(file) 85 | 86 | 87 | if __name__ == "__main__": 88 | main() 89 | -------------------------------------------------------------------------------- /record/ai/generate_image/enhance/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/record/ai/generate_image/enhance/image.png -------------------------------------------------------------------------------- /record/ai/generate_image/enhance/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": "1boy, leaf background, from behind, cowboy shot, purple eyes, big hair, blonde hair, long bangs, tiara, striped shirt, shorts, impossible clothes, prosthetic leg, back focus, best quality, amazing quality, very aesthetic, absurdres", 3 | "model": "nai-diffusion-3", 4 | "action": "img2img", 5 | "parameters": { 6 | "params_version": 1, 7 | "width": 256, 8 | "height": 384, 9 | "scale": 5, 10 | "sampler": "k_euler", 11 | "steps": 28, 12 | "n_samples": 1, 13 | "strength": 0.5, 14 | "noise": 0, 15 | "ucPreset": 0, 16 | "qualityToggle": true, 17 | "sm": false, 18 | "sm_dyn": false, 19 | "dynamic_thresholding": false, 20 | "controlnet_strength": 1, 21 | "legacy": false, 22 | "add_original_image": true, 23 | "uncond_scale": 1, 24 | "cfg_rescale": 0, 25 | "noise_schedule": "native", 26 | "legacy_v3_extend": false, 27 | "reference_information_extracted": 0.53, 28 | "reference_strength": 0.53, 29 | "seed": 500659744, 30 | "image": "Base64 Data", 31 | "extra_noise_seed": 500659744, 32 | "negative_prompt": "nsfw, lowres, {bad}, error, fewer, extra, missing, worst quality, jpeg artifacts, bad quality, watermark, unfinished, displeasing, chromatic aberration, signature, extra digits, artistic error, username, scan, [abstract], 12312331231" 33 | } 34 | } -------------------------------------------------------------------------------- /record/ai/generate_image/export.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import pathlib 4 | from io import BytesIO 5 | 6 | from PIL import Image 7 | from loguru import logger 8 | 9 | 10 | def ignore(*args, **kwargs): 11 | pass 12 | 13 | 14 | def decode_base64_in_dict(data, current_path): 15 | if isinstance(data, dict): 16 | for k, v in data.items(): 17 | if isinstance(v, dict) or isinstance(v, list): 18 | decode_base64_in_dict(v, current_path) 19 | if isinstance(v, list): 20 | _new_list = [] 21 | for index, item in enumerate(v): 22 | if isinstance(item, str) and len(item) > 100: 23 | try: 24 | # Base64解码 25 | image_bytes = base64.b64decode(item) 26 | image = Image.open(BytesIO(image_bytes)) 27 | except Exception as e: 28 | ignore(e) 29 | else: 30 | logger.info(f"Decoding Base64 data in {k}") 31 | img_name = f"{current_path}/{index}-{k}.png" 32 | image.save(img_name) 33 | _new_list.append('Base64 Data') 34 | if _new_list: 35 | data[k] = _new_list 36 | if isinstance(v, str) and len(v) > 100: 37 | try: 38 | # Base64解码 39 | image_bytes = base64.b64decode(v) 40 | image = Image.open(BytesIO(image_bytes)) 41 | except Exception as e: 42 | ignore(e) 43 | else: 44 | logger.info(f"Decoding Base64 data in {k}") 45 | img_name = f"{current_path}/{k}.png" 46 | image.save(img_name) 47 | data[k] = 'Base64 Data' 48 | elif isinstance(data, list): 49 | for item in data: 50 | if isinstance(item, dict) or isinstance(item, list): 51 | decode_base64_in_dict(item, current_path) 52 | return data 53 | 54 | 55 | def handle_file(filename): 56 | filename_wo_ext = filename.stem 57 | pathlib.Path(filename_wo_ext).mkdir(parents=True, exist_ok=True) 58 | with open(filename, 'r') as file: 59 | json_data = json.load(file) 60 | # 取消 headers 里面 Authorization 字段,然后写回 61 | if 'headers' in json_data: 62 | if 'Authorization' in json_data['headers']: 63 | json_data['headers']['Authorization'] = 'Secret' 64 | # 写回原文件 65 | with open(filename, 'w') as file: 66 | json.dump(json_data, file, indent=2) 67 | if 'authorization' in json_data['headers']: 68 | json_data['headers']['authorization'] = 'Secret' 69 | # 写回原文件 70 | with open(filename, 'w') as file: 71 | json.dump(json_data, file, indent=2) 72 | request_data = json.loads(json_data.get("body", "")) 73 | request_data = decode_base64_in_dict(request_data, filename_wo_ext) 74 | # 写出包含替换字段的 JSON 文件回同名的文件夹 75 | with open(f"{filename_wo_ext}/schema.json", 'w') as jsonfile: 76 | json.dump(request_data, jsonfile, indent=2) 77 | 78 | 79 | def main(): 80 | # 列出当前文件夹内所有的 .json 文件 81 | json_files = pathlib.Path('.').glob('*.json') 82 | for file in json_files: 83 | logger.info(f"Handling {file}") 84 | handle_file(file) 85 | 86 | 87 | if __name__ == "__main__": 88 | main() 89 | -------------------------------------------------------------------------------- /record/ai/generate_image/image2image.json: -------------------------------------------------------------------------------- 1 | { 2 | "credentials": "include", 3 | "headers": { 4 | "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0", 5 | "Accept": "*/*", 6 | "Content-Type": "application/json", 7 | "Authorization": "Secret", 8 | "Sec-Fetch-Dest": "empty", 9 | "Sec-Fetch-Mode": "cors", 10 | "Sec-Fetch-Site": "same-site", 11 | "Pragma": "no-cache", 12 | "Cache-Control": "no-cache" 13 | }, 14 | "referrer": "https://novelai.net/", 15 | "body": "{\"input\":\"1boy, leaf background, from behind, cowboy shot, purple eyes, big hair, blonde hair, long bangs, tiara, striped shirt, shorts, impossible clothes, prosthetic leg, back focus, best quality, amazing quality, very aesthetic, absurdres\",\"model\":\"nai-diffusion-3\",\"action\":\"img2img\",\"parameters\":{\"params_version\":1,\"width\":64,\"height\":64,\"scale\":5,\"sampler\":\"k_euler\",\"steps\":28,\"n_samples\":1,\"strength\":0.7,\"noise\":0.23,\"ucPreset\":0,\"qualityToggle\":true,\"sm\":false,\"sm_dyn\":false,\"dynamic_thresholding\":false,\"controlnet_strength\":1,\"legacy\":false,\"add_original_image\":false,\"uncond_scale\":1,\"cfg_rescale\":0,\"noise_schedule\":\"native\",\"legacy_v3_extend\":false,\"reference_information_extracted\":0.53,\"reference_strength\":0.53,\"seed\":1650641516,\"image\":\"iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAGkklEQVR4Xu2be1BUZRiHn0UnocwyxUtplo6TmEml5i11UoIUHVhRioKVxPuFvAsIKHJREAUNYVuXdQGBuC4bmq6aoqCO4V1GEa3RzGoEL2gmjMJ2lshR1goYXS6d89/unsv3Pef3vu/5vWc/Senvej3NbEtOzcDF2alWs5KIAEQFPJkQ+KHoHFi0pUfXDvxcWMArvfogqZUIn/xOJg+BO5fzmR72DRW/nkEhxJ9qshRPteb/AyAtfCnShaFIbp9Ee/E1Lq9z532ZjNN3OuI+bsiTv8X/cUaTK0AdPB/3ZRGgLyXhcBnX5TOQ2E7nSMZXKNM1PGPiWGgQAAPe7sKLQ2XsKaysAjBXCIGi3M107D+JthZmJlVBgwD4SwG3BAXcrQJgyAHnD22hnbUL7Z5t0bwBxIcsQOazDiqvknTUjOKNU6sAFOXFYdnPjZeauwIu6BTsuf8OFflxfLh4A9tmj+e1aSF8Hx+MX/QWzM1MmwRMHgIGfS+e5YGk50eEzZ/IencpPYcPYm9pD9bMn2BS+Rsu1iAATD7Lf7mgCEA0Q6IbFO2w2A8wZUPk0vH97DtzpSovS11ceF6iR6vJoLPVIN6z6mKUrxOTkqkw9KFamCP7RPro7/r7aI/8isOArvUuLCavAr+dP8nJi8VoVApWJ6VSFOdLnuRdjmUnEpuSTqsWjz4I2Y9zxNNzDpg9g92o4TUAlDE55jiqWYObDgDDSPX3buHknUVmuIzRY8bz7beZ3Dh/iItt+/Ju++cemYz9OCnbsjXV391Hqc3h7WEjUa5XIV/hiof8BOUHFagTVLSsBwaTK8AwxjVTJjJoXhjD+rz+EICDnH+hLwM7tK4BwIHyypb4hEUx8k1Llm7czlHd1/TrLCFUrsThs3mE+7izYMNxshUz64ygQQDk795NYlIsgcpkctbM5XJXG05sTWCtOo3nazQESq6VoK+4h+uURei+iWNTXgkpYaFMGmyGm1cwn0UeJnH+COykMnSa+KYBwDDKymt72HN7OKO6maHJ1CJP0KLLUv9ja8xvoiMr09JR1gDwdw6wc3QTjk9o/ABUa/2YvDBQaAjtY9e1odh2b4m+/DKLY4sInzXKaAKuHsvYEhvMkvGOhGYaA/h03SGSFn7QdBRwLC2SHKw5mxHN2uQ02ghJXx3wBRP819O6ugDYjHEgMTOLjuYSnKRSlvj54h/yFbr0aKMQGO82G+/pTqzKuE5mpGvjV4BhhOtWB9P+LVtk9gOqBrzMy5fg1UEPBh+1aiUzvPxpaQBy7wbeAev5YtlyOllUsvV0KXuTk7B70wzbT6ewJvsCpfmpBASuoD69pAZJgnW+TU/xABGAaIdFOyzaYdEOm9IOH9UqUewqqMrrgRsi6SCpZLmvD71GuuAyytoo38+cM4/K6m+DwiOwFJ4NHmz6cpannSPAuW+964TJq8CdG8UU3/qDKH8vVqiTORMznQtvTGOXPBB5ioZWNd4L2NrLUEQHkp8YyAeLNtH+Ya+gb6J2uOzmJbzii4j0/JAQZ0e8U7Mo3L+ZTu8J7wbNH303OG9REJHhvkjHTkCz9WtClSl0H2hDuuAEU6IW4rExj+LdCr6MT6Jbm7obYpMrwKDVIJkTnRxmMsXJhhtnd6A6cJNzh3KQK+U8/sWQHofPfdFuDiAg/iC56SpGdDfDLyKasW6+KEKmMs1fy1b1kjqHQoMAuPLjj6THBOGySsXdA7EkF1pwNncnsQnqvx5/a2wV104RcdySRTaWRl5gUtQR4ua+j52jYIezmpAd1v+ex86rg4jxdBaknUlJ4X6KOwyg90sWRgB0ykCGe/hhIblvBKDJ2eG1XlNZsHoTFb/tYF+ZDRGzncneZgCQyy+W/bBu9yzXS4pp284SSbUaZjpLiUk1tMWMATivyiHVx06ww58LDRF14w+Bou/iURfo+Sk3G4XQ4CiI92dveS9O6TKITU3HXGiKPmyHDTMaPWkl2+P8Hwvg41neOA62YveVV4n1/qjxAzCM8LudO2j9shUD+3QTOqR6duq2Y9nDmnd6vlI1gX3C52G2o6sTop4DQht9aG9Dy7ySgit3OXf0CL2FnqBV/yHsOl1M2aVj2I+zpz7/LWmQJFjn2/QUDxABiHZYtMOiHRbtsCnt8FNM6PU6tVgFxCogVoHaVwEn1znNbs2QIXGo5F/WKn80yzVDtZp59U4igOa4akxUQB0IiCEghkAzXDhZhwhADAExBMQQeDJrh+sSd41p3z8Bt4v8Lpz9IhoAAAAASUVORK5CYII=\",\"extra_noise_seed\":1650641516,\"negative_prompt\":\"nsfw, lowres, {bad}, error, fewer, extra, missing, worst quality, jpeg artifacts, bad quality, watermark, unfinished, displeasing, chromatic aberration, signature, extra digits, artistic error, username, scan, [abstract], 123123\"}}", 16 | "method": "POST", 17 | "mode": "cors" 18 | } -------------------------------------------------------------------------------- /record/ai/generate_image/image2image/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/record/ai/generate_image/image2image/image.png -------------------------------------------------------------------------------- /record/ai/generate_image/image2image/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": "1boy, leaf background, from behind, cowboy shot, purple eyes, big hair, blonde hair, long bangs, tiara, striped shirt, shorts, impossible clothes, prosthetic leg, back focus, best quality, amazing quality, very aesthetic, absurdres", 3 | "model": "nai-diffusion-3", 4 | "action": "img2img", 5 | "parameters": { 6 | "params_version": 1, 7 | "width": 64, 8 | "height": 64, 9 | "scale": 5, 10 | "sampler": "k_euler", 11 | "steps": 28, 12 | "n_samples": 1, 13 | "strength": 0.7, 14 | "noise": 0.23, 15 | "ucPreset": 0, 16 | "qualityToggle": true, 17 | "sm": false, 18 | "sm_dyn": false, 19 | "dynamic_thresholding": false, 20 | "controlnet_strength": 1, 21 | "legacy": false, 22 | "add_original_image": false, 23 | "uncond_scale": 1, 24 | "cfg_rescale": 0, 25 | "noise_schedule": "native", 26 | "legacy_v3_extend": false, 27 | "reference_information_extracted": 0.53, 28 | "reference_strength": 0.53, 29 | "seed": 1650641516, 30 | "image": "Base64 Data", 31 | "extra_noise_seed": 1650641516, 32 | "negative_prompt": "nsfw, lowres, {bad}, error, fewer, extra, missing, worst quality, jpeg artifacts, bad quality, watermark, unfinished, displeasing, chromatic aberration, signature, extra digits, artistic error, username, scan, [abstract], 123123" 33 | } 34 | } -------------------------------------------------------------------------------- /record/ai/generate_image/image2image_v3/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/record/ai/generate_image/image2image_v3/image.png -------------------------------------------------------------------------------- /record/ai/generate_image/image2image_v3/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": "2boys, 1girl, photorealistic, full body, rating:general, amazing quality, very aesthetic, absurdres", 3 | "model": "nai-diffusion-4-curated-preview", 4 | "action": "img2img", 5 | "parameters": { 6 | "params_version": 3, 7 | "width": 640, 8 | "height": 640, 9 | "scale": 6, 10 | "sampler": "k_euler_ancestral", 11 | "steps": 23, 12 | "n_samples": 1, 13 | "strength": 0.7, 14 | "noise": 0.2, 15 | "ucPreset": 0, 16 | "qualityToggle": true, 17 | "dynamic_thresholding": false, 18 | "controlnet_strength": 1, 19 | "legacy": false, 20 | "add_original_image": true, 21 | "cfg_rescale": 0, 22 | "noise_schedule": "karras", 23 | "legacy_v3_extend": false, 24 | "use_coords": true, 25 | "seed": 3634432471, 26 | "image": "Base64 Data", 27 | "characterPrompts": [ 28 | { 29 | "prompt": "girl, brown eyes, long hair, asymmetrical hair, light blue hair, flat chest, hooded cloak, hazmat suit, :3, hand to own mouth, corsage", 30 | "uc": "", 31 | "center": { 32 | "x": 0, 33 | "y": 0 34 | } 35 | }, 36 | { 37 | "prompt": "boy, dark skin, black eyes, long hair, single braid, mizu happi, trembling, flower trim", 38 | "uc": "", 39 | "center": { 40 | "x": 0, 41 | "y": 0 42 | } 43 | }, 44 | { 45 | "prompt": "boy, black eyes, half updo, short ponytai, diagonal bangs, hat, bicorne, animal costume, grin, under-rim eyewear", 46 | "uc": "", 47 | "center": { 48 | "x": 0, 49 | "y": 0 50 | } 51 | } 52 | ], 53 | "extra_noise_seed": 3634432471, 54 | "v4_prompt": { 55 | "caption": { 56 | "base_caption": "2boys, 1girl, photorealistic, full body, rating:general, amazing quality, very aesthetic, absurdres", 57 | "char_captions": [ 58 | { 59 | "char_caption": "girl, brown eyes, long hair, asymmetrical hair, light blue hair, flat chest, hooded cloak, hazmat suit, :3, hand to own mouth, corsage", 60 | "centers": [ 61 | { 62 | "x": 0, 63 | "y": 0 64 | } 65 | ] 66 | }, 67 | { 68 | "char_caption": "boy, dark skin, black eyes, long hair, single braid, mizu happi, trembling, flower trim", 69 | "centers": [ 70 | { 71 | "x": 0, 72 | "y": 0 73 | } 74 | ] 75 | }, 76 | { 77 | "char_caption": "boy, black eyes, half updo, short ponytai, diagonal bangs, hat, bicorne, animal costume, grin, under-rim eyewear", 78 | "centers": [ 79 | { 80 | "x": 0, 81 | "y": 0 82 | } 83 | ] 84 | } 85 | ] 86 | }, 87 | "use_coords": true, 88 | "use_order": true 89 | }, 90 | "v4_negative_prompt": { 91 | "caption": { 92 | "base_caption": "blurry, lowres, error, film grain, scan artifacts, worst quality, bad quality, jpeg artifacts, very displeasing, chromatic aberration, logo, dated, signature, multiple views, gigantic breasts", 93 | "char_captions": [ 94 | { 95 | "char_caption": "", 96 | "centers": [ 97 | { 98 | "x": 0, 99 | "y": 0 100 | } 101 | ] 102 | }, 103 | { 104 | "char_caption": "", 105 | "centers": [ 106 | { 107 | "x": 0, 108 | "y": 0 109 | } 110 | ] 111 | }, 112 | { 113 | "char_caption": "", 114 | "centers": [ 115 | { 116 | "x": 0, 117 | "y": 0 118 | } 119 | ] 120 | } 121 | ] 122 | } 123 | }, 124 | "negative_prompt": "blurry, lowres, error, film grain, scan artifacts, worst quality, bad quality, jpeg artifacts, very displeasing, chromatic aberration, logo, dated, signature, multiple views, gigantic breasts", 125 | "reference_image_multiple": [], 126 | "reference_information_extracted_multiple": [], 127 | "reference_strength_multiple": [], 128 | "deliberate_euler_ancestral_bug": false, 129 | "prefer_brownian": true 130 | } 131 | } -------------------------------------------------------------------------------- /record/ai/generate_image/inpaint/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/record/ai/generate_image/inpaint/image.png -------------------------------------------------------------------------------- /record/ai/generate_image/inpaint/mask.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/record/ai/generate_image/inpaint/mask.png -------------------------------------------------------------------------------- /record/ai/generate_image/inpaint/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": "1boy, leaf background, from behind, cowboy shot, purple eyes, big hair, blonde hair, long bangs, tiara, striped shirt, shorts, impossible clothes, prosthetic leg, back focus, best quality, amazing quality, very aesthetic, absurdres", 3 | "model": "nai-diffusion-3-inpainting", 4 | "action": "infill", 5 | "parameters": { 6 | "params_version": 1, 7 | "width": 128, 8 | "height": 192, 9 | "scale": 5, 10 | "sampler": "k_euler", 11 | "steps": 28, 12 | "n_samples": 1, 13 | "strength": 0.7, 14 | "noise": 0.23, 15 | "ucPreset": 0, 16 | "qualityToggle": true, 17 | "sm": false, 18 | "sm_dyn": false, 19 | "dynamic_thresholding": false, 20 | "controlnet_strength": 1, 21 | "legacy": false, 22 | "add_original_image": false, 23 | "uncond_scale": 1, 24 | "cfg_rescale": 0, 25 | "noise_schedule": "native", 26 | "legacy_v3_extend": false, 27 | "reference_information_extracted": 0.53, 28 | "reference_strength": 0.53, 29 | "seed": 3406990982, 30 | "image": "Base64 Data", 31 | "mask": "Base64 Data", 32 | "extra_noise_seed": 3406990982, 33 | "negative_prompt": "nsfw, lowres, {bad}, error, fewer, extra, missing, worst quality, jpeg artifacts, bad quality, watermark, unfinished, displeasing, chromatic aberration, signature, extra digits, artistic error, username, scan, [abstract], 12312331231" 34 | } 35 | } -------------------------------------------------------------------------------- /record/ai/generate_image/inpaint_v3/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/record/ai/generate_image/inpaint_v3/image.png -------------------------------------------------------------------------------- /record/ai/generate_image/inpaint_v3/mask.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/record/ai/generate_image/inpaint_v3/mask.png -------------------------------------------------------------------------------- /record/ai/generate_image/inpaint_v3/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": "2boys, 1girl, photorealistic, full body, best quality, amazing quality, very aesthetic, absurdres", 3 | "model": "nai-diffusion-3-inpainting", 4 | "action": "infill", 5 | "parameters": { 6 | "params_version": 3, 7 | "width": 640, 8 | "height": 640, 9 | "scale": 6, 10 | "sampler": "k_euler_ancestral", 11 | "steps": 23, 12 | "n_samples": 1, 13 | "strength": 0.7, 14 | "noise": 0.2, 15 | "ucPreset": 0, 16 | "qualityToggle": true, 17 | "sm": false, 18 | "sm_dyn": false, 19 | "dynamic_thresholding": false, 20 | "controlnet_strength": 1, 21 | "legacy": false, 22 | "add_original_image": true, 23 | "cfg_rescale": 0, 24 | "noise_schedule": "karras", 25 | "legacy_v3_extend": false, 26 | "skip_cfg_above_sigma": null, 27 | "use_coords": true, 28 | "seed": 1504970584, 29 | "image": "Base64 Data", 30 | "mask": "Base64 Data", 31 | "characterPrompts": [ 32 | { 33 | "prompt": "girl, brown eyes, long hair, asymmetrical hair, light blue hair, flat chest, hooded cloak, hazmat suit, :3, hand to own mouth, corsage", 34 | "uc": "", 35 | "center": { 36 | "x": 0, 37 | "y": 0 38 | } 39 | }, 40 | { 41 | "prompt": "boy, dark skin, black eyes, long hair, single braid, mizu happi, trembling, flower trim", 42 | "uc": "", 43 | "center": { 44 | "x": 0, 45 | "y": 0 46 | } 47 | }, 48 | { 49 | "prompt": "boy, black eyes, half updo, short ponytai, diagonal bangs, hat, bicorne, animal costume, grin, under-rim eyewear", 50 | "uc": "", 51 | "center": { 52 | "x": 0, 53 | "y": 0 54 | } 55 | } 56 | ], 57 | "extra_noise_seed": 1504970584, 58 | "negative_prompt": "nsfw, lowres, {bad}, error, fewer, extra, missing, worst quality, jpeg artifacts, bad quality, watermark, unfinished, displeasing, chromatic aberration, signature, extra digits, artistic error, username, scan, [abstract]", 59 | "reference_image_multiple": [], 60 | "reference_information_extracted_multiple": [], 61 | "reference_strength_multiple": [], 62 | "deliberate_euler_ancestral_bug": false, 63 | "prefer_brownian": true 64 | } 65 | } -------------------------------------------------------------------------------- /record/ai/generate_image/text2image.json: -------------------------------------------------------------------------------- 1 | { 2 | "credentials": "include", 3 | "headers": { 4 | "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0", 5 | "Accept": "*/*", 6 | "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", 7 | "Content-Type": "application/json", 8 | "Authorization": "Secret", 9 | "x-correlation-id": "00000", 10 | "x-initiated-at": "2024-12-21T00:00:00.473Z", 11 | "Sec-Fetch-Dest": "empty", 12 | "Sec-Fetch-Mode": "cors", 13 | "Sec-Fetch-Site": "same-site", 14 | "Priority": "u=0", 15 | "Pragma": "no-cache", 16 | "Cache-Control": "no-cache" 17 | }, 18 | "referrer": "https://novelai.net/", 19 | "body": "{\"input\":\"2boys, 1girl, photorealistic, full body, best quality, amazing quality, very aesthetic, absurdres\",\"model\":\"nai-diffusion-3\",\"action\":\"generate\",\"parameters\":{\"params_version\":3,\"width\":832,\"height\":1216,\"scale\":5,\"sampler\":\"k_euler_ancestral\",\"steps\":23,\"seed\":2222664127,\"n_samples\":1,\"ucPreset\":0,\"qualityToggle\":true,\"sm\":false,\"sm_dyn\":false,\"dynamic_thresholding\":false,\"controlnet_strength\":1,\"legacy\":false,\"add_original_image\":true,\"cfg_rescale\":0,\"noise_schedule\":\"karras\",\"legacy_v3_extend\":false,\"skip_cfg_above_sigma\":null,\"characterPrompts\":[],\"negative_prompt\":\"nsfw, lowres, {bad}, error, fewer, extra, missing, worst quality, jpeg artifacts, bad quality, watermark, unfinished, displeasing, chromatic aberration, signature, extra digits, artistic error, username, scan, [abstract]\",\"reference_image_multiple\":[],\"reference_information_extracted_multiple\":[],\"reference_strength_multiple\":[],\"deliberate_euler_ancestral_bug\":false,\"prefer_brownian\":true}}", 20 | "method": "POST", 21 | "mode": "cors" 22 | } -------------------------------------------------------------------------------- /record/ai/generate_image/text2image/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": "2boys, 1girl, photorealistic, full body, best quality, amazing quality, very aesthetic, absurdres", 3 | "model": "nai-diffusion-3", 4 | "action": "generate", 5 | "parameters": { 6 | "params_version": 3, 7 | "width": 832, 8 | "height": 1216, 9 | "scale": 5, 10 | "sampler": "k_euler_ancestral", 11 | "steps": 23, 12 | "seed": 2222664127, 13 | "n_samples": 1, 14 | "ucPreset": 0, 15 | "qualityToggle": true, 16 | "sm": false, 17 | "sm_dyn": false, 18 | "dynamic_thresholding": false, 19 | "controlnet_strength": 1, 20 | "legacy": false, 21 | "add_original_image": true, 22 | "cfg_rescale": 0, 23 | "noise_schedule": "karras", 24 | "legacy_v3_extend": false, 25 | "skip_cfg_above_sigma": null, 26 | "characterPrompts": [], 27 | "negative_prompt": "nsfw, lowres, {bad}, error, fewer, extra, missing, worst quality, jpeg artifacts, bad quality, watermark, unfinished, displeasing, chromatic aberration, signature, extra digits, artistic error, username, scan, [abstract]", 28 | "reference_image_multiple": [], 29 | "reference_information_extracted_multiple": [], 30 | "reference_strength_multiple": [], 31 | "deliberate_euler_ancestral_bug": false, 32 | "prefer_brownian": true 33 | } 34 | } -------------------------------------------------------------------------------- /record/ai/generate_image/text2image_v3.json: -------------------------------------------------------------------------------- 1 | { 2 | "credentials": "include", 3 | "headers": { 4 | "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0", 5 | "Accept": "*/*", 6 | "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", 7 | "Content-Type": "application/json", 8 | "Authorization": "Secret", 9 | "x-correlation-id": "555", 10 | "x-initiated-at": "2024-12-05T05:57:04.600Z", 11 | "Sec-Fetch-Dest": "empty", 12 | "Sec-Fetch-Mode": "cors", 13 | "Sec-Fetch-Site": "same-site", 14 | "Priority": "u=0", 15 | "Pragma": "no-cache", 16 | "Cache-Control": "no-cache" 17 | }, 18 | "referrer": "https://novelai.net/", 19 | "body": "{\"input\":\"2boys, 1girl, photorealistic, full body, rating:general, amazing quality, very aesthetic, absurdres\",\"model\":\"nai-diffusion-4-curated-preview\",\"action\":\"generate\",\"parameters\":{\"params_version\":3,\"width\":640,\"height\":640,\"scale\":6,\"sampler\":\"k_euler_ancestral\",\"steps\":23,\"n_samples\":1,\"ucPreset\":0,\"qualityToggle\":true,\"dynamic_thresholding\":false,\"controlnet_strength\":1,\"legacy\":false,\"add_original_image\":true,\"cfg_rescale\":0,\"noise_schedule\":\"karras\",\"legacy_v3_extend\":false,\"use_coords\":true,\"seed\":1407032209,\"characterPrompts\":[{\"prompt\":\"girl, brown eyes, long hair, asymmetrical hair, light blue hair, flat chest, hooded cloak, hazmat suit, :3, hand to own mouth, corsage\",\"uc\":\"\",\"center\":{\"x\":0,\"y\":0}},{\"prompt\":\"boy, dark skin, black eyes, long hair, single braid, mizu happi, trembling, flower trim\",\"uc\":\"\",\"center\":{\"x\":0,\"y\":0}},{\"prompt\":\"boy, black eyes, half updo, short ponytai, diagonal bangs, hat, bicorne, animal costume, grin, under-rim eyewear\",\"uc\":\"\",\"center\":{\"x\":0,\"y\":0}}],\"v4_prompt\":{\"caption\":{\"base_caption\":\"2boys, 1girl, photorealistic, full body, rating:general, amazing quality, very aesthetic, absurdres\",\"char_captions\":[{\"char_caption\":\"girl, brown eyes, long hair, asymmetrical hair, light blue hair, flat chest, hooded cloak, hazmat suit, :3, hand to own mouth, corsage\",\"centers\":[{\"x\":0,\"y\":0}]},{\"char_caption\":\"boy, dark skin, black eyes, long hair, single braid, mizu happi, trembling, flower trim\",\"centers\":[{\"x\":0,\"y\":0}]},{\"char_caption\":\"boy, black eyes, half updo, short ponytai, diagonal bangs, hat, bicorne, animal costume, grin, under-rim eyewear\",\"centers\":[{\"x\":0,\"y\":0}]}]},\"use_coords\":true,\"use_order\":true},\"v4_negative_prompt\":{\"caption\":{\"base_caption\":\"blurry, lowres, error, film grain, scan artifacts, worst quality, bad quality, jpeg artifacts, very displeasing, chromatic aberration, logo, dated, signature, multiple views, gigantic breasts\",\"char_captions\":[{\"char_caption\":\"\",\"centers\":[{\"x\":0,\"y\":0}]},{\"char_caption\":\"\",\"centers\":[{\"x\":0,\"y\":0}]},{\"char_caption\":\"\",\"centers\":[{\"x\":0,\"y\":0}]}]}},\"negative_prompt\":\"blurry, lowres, error, film grain, scan artifacts, worst quality, bad quality, jpeg artifacts, very displeasing, chromatic aberration, logo, dated, signature, multiple views, gigantic breasts\",\"reference_image_multiple\":[],\"reference_information_extracted_multiple\":[],\"reference_strength_multiple\":[],\"deliberate_euler_ancestral_bug\":false,\"prefer_brownian\":true}}", 20 | "method": "POST", 21 | "mode": "cors" 22 | } -------------------------------------------------------------------------------- /record/ai/generate_image/text2image_v3/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": "2boys, 1girl, photorealistic, full body, rating:general, amazing quality, very aesthetic, absurdres", 3 | "model": "nai-diffusion-4-curated-preview", 4 | "action": "generate", 5 | "parameters": { 6 | "params_version": 3, 7 | "width": 640, 8 | "height": 640, 9 | "scale": 6, 10 | "sampler": "k_euler_ancestral", 11 | "steps": 23, 12 | "n_samples": 1, 13 | "ucPreset": 0, 14 | "qualityToggle": true, 15 | "dynamic_thresholding": false, 16 | "controlnet_strength": 1, 17 | "legacy": false, 18 | "add_original_image": true, 19 | "cfg_rescale": 0, 20 | "noise_schedule": "karras", 21 | "legacy_v3_extend": false, 22 | "use_coords": true, 23 | "seed": 1407032209, 24 | "characterPrompts": [ 25 | { 26 | "prompt": "girl, brown eyes, long hair, asymmetrical hair, light blue hair, flat chest, hooded cloak, hazmat suit, :3, hand to own mouth, corsage", 27 | "uc": "", 28 | "center": { 29 | "x": 0, 30 | "y": 0 31 | } 32 | }, 33 | { 34 | "prompt": "boy, dark skin, black eyes, long hair, single braid, mizu happi, trembling, flower trim", 35 | "uc": "", 36 | "center": { 37 | "x": 0, 38 | "y": 0 39 | } 40 | }, 41 | { 42 | "prompt": "boy, black eyes, half updo, short ponytai, diagonal bangs, hat, bicorne, animal costume, grin, under-rim eyewear", 43 | "uc": "", 44 | "center": { 45 | "x": 0, 46 | "y": 0 47 | } 48 | } 49 | ], 50 | "v4_prompt": { 51 | "caption": { 52 | "base_caption": "2boys, 1girl, photorealistic, full body, rating:general, amazing quality, very aesthetic, absurdres", 53 | "char_captions": [ 54 | { 55 | "char_caption": "girl, brown eyes, long hair, asymmetrical hair, light blue hair, flat chest, hooded cloak, hazmat suit, :3, hand to own mouth, corsage", 56 | "centers": [ 57 | { 58 | "x": 0, 59 | "y": 0 60 | } 61 | ] 62 | }, 63 | { 64 | "char_caption": "boy, dark skin, black eyes, long hair, single braid, mizu happi, trembling, flower trim", 65 | "centers": [ 66 | { 67 | "x": 0, 68 | "y": 0 69 | } 70 | ] 71 | }, 72 | { 73 | "char_caption": "boy, black eyes, half updo, short ponytai, diagonal bangs, hat, bicorne, animal costume, grin, under-rim eyewear", 74 | "centers": [ 75 | { 76 | "x": 0, 77 | "y": 0 78 | } 79 | ] 80 | } 81 | ] 82 | }, 83 | "use_coords": true, 84 | "use_order": true 85 | }, 86 | "v4_negative_prompt": { 87 | "caption": { 88 | "base_caption": "blurry, lowres, error, film grain, scan artifacts, worst quality, bad quality, jpeg artifacts, very displeasing, chromatic aberration, logo, dated, signature, multiple views, gigantic breasts", 89 | "char_captions": [ 90 | { 91 | "char_caption": "", 92 | "centers": [ 93 | { 94 | "x": 0, 95 | "y": 0 96 | } 97 | ] 98 | }, 99 | { 100 | "char_caption": "", 101 | "centers": [ 102 | { 103 | "x": 0, 104 | "y": 0 105 | } 106 | ] 107 | }, 108 | { 109 | "char_caption": "", 110 | "centers": [ 111 | { 112 | "x": 0, 113 | "y": 0 114 | } 115 | ] 116 | } 117 | ] 118 | } 119 | }, 120 | "negative_prompt": "blurry, lowres, error, film grain, scan artifacts, worst quality, bad quality, jpeg artifacts, very displeasing, chromatic aberration, logo, dated, signature, multiple views, gigantic breasts", 121 | "reference_image_multiple": [], 122 | "reference_information_extracted_multiple": [], 123 | "reference_strength_multiple": [], 124 | "deliberate_euler_ancestral_bug": false, 125 | "prefer_brownian": true 126 | } 127 | } -------------------------------------------------------------------------------- /record/ai/generate_image/text2image_v4.json: -------------------------------------------------------------------------------- 1 | { 2 | "credentials": "include", 3 | "headers": { 4 | "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0", 5 | "Accept": "*/*", 6 | "Accept-Language": "zh-CN,en-US;q=0.7,en;q=0.3", 7 | "Content-Type": "application/json", 8 | "Authorization": "Secret", 9 | "x-correlation-id": "ABCD", 10 | "x-initiated-at": "2025-05-01T00:00:00.000Z", 11 | "Sec-GPC": "1", 12 | "Sec-Fetch-Dest": "empty", 13 | "Sec-Fetch-Mode": "cors", 14 | "Sec-Fetch-Site": "same-site", 15 | "Priority": "u=0", 16 | "Pragma": "no-cache", 17 | "Cache-Control": "no-cache" 18 | }, 19 | "referrer": "https://novelai.net/", 20 | "body": "{\"input\":\"1boy, 1girl, very aesthetic, location, masterpiece, no text, -0.8::feet::, rating:general\",\"model\":\"nai-diffusion-4-5-curated\",\"action\":\"generate\",\"parameters\":{\"params_version\":3,\"width\":832,\"height\":1216,\"scale\":4.5,\"sampler\":\"k_euler_ancestral\",\"steps\":23,\"n_samples\":1,\"ucPreset\":0,\"qualityToggle\":true,\"autoSmea\":false,\"dynamic_thresholding\":false,\"controlnet_strength\":1,\"legacy\":false,\"add_original_image\":true,\"cfg_rescale\":0,\"noise_schedule\":\"karras\",\"legacy_v3_extend\":false,\"skip_cfg_above_sigma\":null,\"use_coords\":false,\"legacy_uc\":false,\"normalize_reference_strength_multiple\":true,\"seed\":1688743194,\"characterPrompts\":[{\"prompt\":\"girl, tan, pink eyes, medium hair, braided ponytail, light blue hair, spiked hair, small breasts, mole under eye, hooded cloak, spread fingers, bodypaint\",\"uc\":\"lowres, aliasing, \",\"center\":{\"x\":0,\"y\":0},\"enabled\":true},{\"prompt\":\"boy, very dark skin, medium hair, asymmetrical hair, hair flaps, mole under eye, tuxedo, licking lips, prosthetic arm\",\"uc\":\"lowres, aliasing, \",\"center\":{\"x\":0,\"y\":0},\"enabled\":true}],\"v4_prompt\":{\"caption\":{\"base_caption\":\"1boy, 1girl, silver background, cowboy shot, love letter, very aesthetic, location, masterpiece, no text, -0.8::feet::, rating:general\",\"char_captions\":[{\"char_caption\":\"girl, tan, pink eyes, medium hair, braided ponytail, light blue hair, spiked hair, small breasts, mole under eye, hooded cloak, spread fingers, bodypaint\",\"centers\":[{\"x\":0,\"y\":0}]},{\"char_caption\":\"boy, very dark skin, medium hair, asymmetrical hair, hair flaps, mole under eye, tuxedo, licking lips, prosthetic arm\",\"centers\":[{\"x\":0,\"y\":0}]}]},\"use_coords\":false,\"use_order\":true},\"v4_negative_prompt\":{\"caption\":{\"base_caption\":\"blurry, lowres, upscaled, artistic error, film grain, scan artifacts, worst quality, bad quality, jpeg artifacts, very displeasing, chromatic aberration, halftone, multiple views, logo, too many watermarks, negative space, blank page\",\"char_captions\":[{\"char_caption\":\"lowres, aliasing, \",\"centers\":[{\"x\":0,\"y\":0}]},{\"char_caption\":\"lowres, aliasing, \",\"centers\":[{\"x\":0,\"y\":0}]}]},\"legacy_uc\":false},\"negative_prompt\":\"blurry, lowres, upscaled, artistic error, film grain, scan artifacts, worst quality, bad quality, jpeg artifacts, very displeasing, chromatic aberration, halftone, multiple views, logo, too many watermarks, negative space, blank page\",\"deliberate_euler_ancestral_bug\":false,\"prefer_brownian\":true}}", 21 | "method": "POST", 22 | "mode": "cors" 23 | } -------------------------------------------------------------------------------- /record/ai/generate_image/text2image_v4/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": "1boy, 1girl, silver background, cowboy shot, love letter, very aesthetic, location, masterpiece, no text, -0.8::feet::, rating:general", 3 | "model": "nai-diffusion-4-5-curated", 4 | "action": "generate", 5 | "parameters": { 6 | "params_version": 3, 7 | "width": 832, 8 | "height": 1216, 9 | "scale": 4.5, 10 | "sampler": "k_euler_ancestral", 11 | "steps": 23, 12 | "n_samples": 1, 13 | "ucPreset": 0, 14 | "qualityToggle": true, 15 | "autoSmea": false, 16 | "dynamic_thresholding": false, 17 | "controlnet_strength": 1, 18 | "legacy": false, 19 | "add_original_image": true, 20 | "cfg_rescale": 0, 21 | "noise_schedule": "karras", 22 | "legacy_v3_extend": false, 23 | "skip_cfg_above_sigma": null, 24 | "use_coords": false, 25 | "legacy_uc": false, 26 | "normalize_reference_strength_multiple": true, 27 | "seed": 168874300, 28 | "characterPrompts": [ 29 | { 30 | "prompt": "girl, tan, pink eyes, medium hair, braided ponytail, light blue hair, spiked hair, small breasts, mole under eye, hooded cloak, spread fingers, bodypaint", 31 | "uc": "lowres, aliasing, ", 32 | "center": { 33 | "x": 0, 34 | "y": 0 35 | }, 36 | "enabled": true 37 | }, 38 | { 39 | "prompt": "boy, very dark skin, medium hair, asymmetrical hair, hair flaps, mole under eye, tuxedo, licking lips, prosthetic arm", 40 | "uc": "lowres, aliasing, ", 41 | "center": { 42 | "x": 0, 43 | "y": 0 44 | }, 45 | "enabled": true 46 | } 47 | ], 48 | "v4_prompt": { 49 | "caption": { 50 | "base_caption": "1boy, 1girl, silver background, cowboy shot, love letter, very aesthetic, location, masterpiece, no text, -0.8::feet::, rating:general", 51 | "char_captions": [ 52 | { 53 | "char_caption": "girl, tan, pink eyes, medium hair, braided ponytail, light blue hair, spiked hair, small breasts, mole under eye, hooded cloak, spread fingers, bodypaint", 54 | "centers": [ 55 | { 56 | "x": 0, 57 | "y": 0 58 | } 59 | ] 60 | }, 61 | { 62 | "char_caption": "boy, very dark skin, medium hair, asymmetrical hair, hair flaps, mole under eye, tuxedo, licking lips, prosthetic arm", 63 | "centers": [ 64 | { 65 | "x": 0, 66 | "y": 0 67 | } 68 | ] 69 | } 70 | ] 71 | }, 72 | "use_coords": false, 73 | "use_order": true 74 | }, 75 | "v4_negative_prompt": { 76 | "caption": { 77 | "base_caption": "blurry, lowres, upscaled, artistic error, film grain, scan artifacts, worst quality, bad quality, jpeg artifacts, very displeasing, chromatic aberration, halftone, multiple views, logo, too many watermarks, negative space, blank page", 78 | "char_captions": [ 79 | { 80 | "char_caption": "lowres, aliasing, ", 81 | "centers": [ 82 | { 83 | "x": 0, 84 | "y": 0 85 | } 86 | ] 87 | }, 88 | { 89 | "char_caption": "lowres, aliasing, ", 90 | "centers": [ 91 | { 92 | "x": 0, 93 | "y": 0 94 | } 95 | ] 96 | } 97 | ] 98 | }, 99 | "legacy_uc": false 100 | }, 101 | "negative_prompt": "blurry, lowres, upscaled, artistic error, film grain, scan artifacts, worst quality, bad quality, jpeg artifacts, very displeasing, chromatic aberration, halftone, multiple views, logo, too many watermarks, negative space, blank page", 102 | "deliberate_euler_ancestral_bug": false, 103 | "prefer_brownian": true 104 | } 105 | } -------------------------------------------------------------------------------- /record/ai/generate_image/vibe_image/0-reference_image_multiple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/record/ai/generate_image/vibe_image/0-reference_image_multiple.png -------------------------------------------------------------------------------- /record/ai/generate_image/vibe_image/1-reference_image_multiple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/record/ai/generate_image/vibe_image/1-reference_image_multiple.png -------------------------------------------------------------------------------- /record/ai/generate_image/vibe_image/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": "no humans, scenery, bamboo forest, alley, train interior, night, road sign, bed sheet, tulip, best quality, amazing quality, very aesthetic, absurdres", 3 | "model": "nai-diffusion-3", 4 | "action": "generate", 5 | "parameters": { 6 | "params_version": 1, 7 | "width": 832, 8 | "height": 1216, 9 | "scale": 5, 10 | "sampler": "k_euler", 11 | "steps": 28, 12 | "seed": 248658061, 13 | "n_samples": 1, 14 | "ucPreset": 0, 15 | "qualityToggle": true, 16 | "sm": false, 17 | "sm_dyn": false, 18 | "dynamic_thresholding": false, 19 | "controlnet_strength": 1, 20 | "legacy": false, 21 | "add_original_image": true, 22 | "uncond_scale": 1, 23 | "cfg_rescale": 0, 24 | "noise_schedule": "native", 25 | "legacy_v3_extend": false, 26 | "negative_prompt": "nsfw, lowres, {bad}, error, fewer, extra, missing, worst quality, jpeg artifacts, bad quality, watermark, unfinished, displeasing, chromatic aberration, signature, extra digits, artistic error, username, scan, [abstract]", 27 | "reference_image_multiple": [ 28 | "Base64 Data", 29 | "Base64 Data" 30 | ], 31 | "reference_information_extracted_multiple": [ 32 | 0.89, 33 | 0.9 34 | ], 35 | "reference_strength_multiple": [ 36 | 0.6, 37 | 0.6 38 | ] 39 | } 40 | } -------------------------------------------------------------------------------- /record/ai/generate_image/vibe_img2img/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/record/ai/generate_image/vibe_img2img/image.png -------------------------------------------------------------------------------- /record/ai/generate_image/vibe_img2img/reference_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/record/ai/generate_image/vibe_img2img/reference_image.png -------------------------------------------------------------------------------- /record/ai/generate_image/vibe_img2img/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": "1boy, leaf background, from behind, cowboy shot, purple eyes, big hair, blonde hair, long bangs, tiara, striped shirt, shorts, impossible clothes, prosthetic leg, back focus, best quality, amazing quality, very aesthetic, absurdres", 3 | "model": "nai-diffusion-3", 4 | "action": "img2img", 5 | "parameters": { 6 | "params_version": 1, 7 | "width": 128, 8 | "height": 128, 9 | "scale": 5, 10 | "sampler": "k_euler", 11 | "steps": 28, 12 | "n_samples": 1, 13 | "strength": 0.7, 14 | "noise": 0, 15 | "ucPreset": 0, 16 | "qualityToggle": true, 17 | "sm": false, 18 | "sm_dyn": false, 19 | "dynamic_thresholding": false, 20 | "controlnet_strength": 1, 21 | "legacy": false, 22 | "add_original_image": false, 23 | "uncond_scale": 1, 24 | "cfg_rescale": 0, 25 | "noise_schedule": "native", 26 | "legacy_v3_extend": false, 27 | "reference_information_extracted": 0.41, 28 | "reference_strength": 0.59, 29 | "seed": 2544809556, 30 | "reference_image": "Base64 Data", 31 | "image": "Base64 Data", 32 | "extra_noise_seed": 2544809556, 33 | "negative_prompt": "nsfw, lowres, {bad}, error, fewer, extra, missing, worst quality, jpeg artifacts, bad quality, watermark, unfinished, displeasing, chromatic aberration, signature, extra digits, artistic error, username, scan, [abstract]" 34 | } 35 | } -------------------------------------------------------------------------------- /record/ai/generate_image/vibe_inpaint/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/record/ai/generate_image/vibe_inpaint/image.png -------------------------------------------------------------------------------- /record/ai/generate_image/vibe_inpaint/mask.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/record/ai/generate_image/vibe_inpaint/mask.png -------------------------------------------------------------------------------- /record/ai/generate_image/vibe_inpaint/reference_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/record/ai/generate_image/vibe_inpaint/reference_image.png -------------------------------------------------------------------------------- /record/ai/generate_image/vibe_inpaint/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "input": "1boy, leaf background, from behind, cowboy shot, purple eyes, big hair, blonde hair, long bangs, tiara, striped shirt, shorts, impossible clothes, prosthetic leg, back focus, best quality, amazing quality, very aesthetic, absurdres", 3 | "model": "nai-diffusion-3-inpainting", 4 | "action": "infill", 5 | "parameters": { 6 | "params_version": 1, 7 | "width": 128, 8 | "height": 128, 9 | "scale": 5, 10 | "sampler": "k_euler", 11 | "steps": 28, 12 | "n_samples": 1, 13 | "strength": 0.7, 14 | "noise": 0, 15 | "ucPreset": 0, 16 | "qualityToggle": true, 17 | "sm": false, 18 | "sm_dyn": false, 19 | "dynamic_thresholding": false, 20 | "controlnet_strength": 1, 21 | "legacy": false, 22 | "add_original_image": true, 23 | "uncond_scale": 1, 24 | "cfg_rescale": 0, 25 | "noise_schedule": "native", 26 | "legacy_v3_extend": false, 27 | "reference_information_extracted": 0.41, 28 | "reference_strength": 0.59, 29 | "seed": 747524388, 30 | "reference_image": "Base64 Data", 31 | "image": "Base64 Data", 32 | "mask": "Base64 Data", 33 | "extra_noise_seed": 747524388, 34 | "negative_prompt": "nsfw, lowres, {bad}, error, fewer, extra, missing, worst quality, jpeg artifacts, bad quality, watermark, unfinished, displeasing, chromatic aberration, signature, extra digits, artistic error, username, scan, [abstract]" 35 | } 36 | } -------------------------------------------------------------------------------- /record/ai/generate_stream/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "headers": { 3 | "accept": "*/*", 4 | "accept-language": "zh-CN,zh;q=0.9", 5 | "authorization": "Secret", 6 | "cache-control": "no-cache", 7 | "content-type": "application/json", 8 | "pragma": "no-cache", 9 | "sec-ch-ua": "\"Chromium\";v=\"123\", \"Not:A-Brand\";v=\"8\"", 10 | "sec-ch-ua-mobile": "?0", 11 | "sec-ch-ua-platform": "\"Linux\"", 12 | "sec-fetch-dest": "empty", 13 | "sec-fetch-mode": "cors", 14 | "sec-fetch-site": "same-site", 15 | "Referer": "https://novelai.net/", 16 | "Referrer-Policy": "strict-origin-when-cross-origin" 17 | }, 18 | "body": "{\"input\":\"iDwLAGgCngU+AXMBqhkLACIBOgHMAhkBoGUNADoBHQRLAtleSQFrU/gFDQDGAFIOngU+AXMBqhkiAToBzAIZAd5MDQDGACgAHQRLAmANSQHOAhcJ+AUNAHfB\",\"model\":\"hypebot\",\"parameters\":{\"temperature\":1,\"max_length\":50,\"min_length\":1,\"top_k\":0,\"top_p\":1,\"tail_free_sampling\":0.95,\"repetition_penalty\":1,\"repetition_penalty_range\":2048,\"repetition_penalty_slope\":0.18,\"repetition_penalty_frequency\":0,\"repetition_penalty_presence\":0,\"phrase_rep_pen\":\"off\",\"bad_words_ids\":[[58],[60],[90],[92],[685],[1391],[1782],[2361],[3693],[4083],[4357],[4895],[5512],[5974],[7131],[8183],[8351],[8762],[8964],[8973],[9063],[11208],[11709],[11907],[11919],[12878],[12962],[13018],[13412],[14631],[14692],[14980],[15090],[15437],[16151],[16410],[16589],[17241],[17414],[17635],[17816],[17912],[18083],[18161],[18477],[19629],[19779],[19953],[20520],[20598],[20662],[20740],[21476],[21737],[22133],[22241],[22345],[22935],[23330],[23785],[23834],[23884],[25295],[25597],[25719],[25787],[25915],[26076],[26358],[26398],[26894],[26933],[27007],[27422],[28013],[29164],[29225],[29342],[29565],[29795],[30072],[30109],[30138],[30866],[31161],[31478],[32092],[32239],[32509],[33116],[33250],[33761],[34171],[34758],[34949],[35944],[36338],[36463],[36563],[36786],[36796],[36937],[37250],[37913],[37981],[38165],[38362],[38381],[38430],[38892],[39850],[39893],[41832],[41888],[42535],[42669],[42785],[42924],[43839],[44438],[44587],[44926],[45144],[45297],[46110],[46570],[46581],[46956],[47175],[47182],[47527],[47715],[48600],[48683],[48688],[48874],[48999],[49074],[49082],[49146],[49946],[10221],[4841],[1427],[2602,834],[29343],[37405],[35780],[2602],[50256]],\"stop_sequences\":[[48585]],\"generate_until_sentence\":true,\"use_cache\":false,\"use_string\":false,\"return_full_text\":false,\"prefix\":\"vanilla\",\"logit_bias_exp\":[{\"sequence\":[8162],\"bias\":-0.12,\"ensure_sequence_finish\":false,\"generate_once\":false},{\"sequence\":[46256,224],\"bias\":-0.12,\"ensure_sequence_finish\":false,\"generate_once\":false}],\"order\":[0,1,2,3]}}", 19 | "method": "POST" 20 | } -------------------------------------------------------------------------------- /record/ai/generate_stream/export.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import pathlib 4 | from io import BytesIO 5 | 6 | from PIL import Image 7 | from loguru import logger 8 | 9 | 10 | def ignore(*args, **kwargs): 11 | pass 12 | 13 | 14 | def decode_base64_in_dict(data, current_path): 15 | if isinstance(data, dict): 16 | for k, v in data.items(): 17 | if isinstance(v, dict) or isinstance(v, list): 18 | decode_base64_in_dict(v, current_path) 19 | if isinstance(v, list): 20 | _new_list = [] 21 | for index, item in enumerate(v): 22 | if isinstance(item, str) and len(item) > 100: 23 | try: 24 | # Base64解码 25 | image_bytes = base64.b64decode(item) 26 | image = Image.open(BytesIO(image_bytes)) 27 | except Exception as e: 28 | ignore(e) 29 | else: 30 | logger.info(f"Decoding Base64 data in {k}") 31 | img_name = f"{current_path}/{index}-{k}.png" 32 | image.save(img_name) 33 | _new_list.append('Base64 Data') 34 | if _new_list: 35 | data[k] = _new_list 36 | if isinstance(v, str) and len(v) > 100: 37 | try: 38 | # Base64解码 39 | image_bytes = base64.b64decode(v) 40 | image = Image.open(BytesIO(image_bytes)) 41 | except Exception as e: 42 | ignore(e) 43 | else: 44 | logger.info(f"Decoding Base64 data in {k}") 45 | img_name = f"{current_path}/{k}.png" 46 | image.save(img_name) 47 | data[k] = 'Base64 Data' 48 | elif isinstance(data, list): 49 | for item in data: 50 | if isinstance(item, dict) or isinstance(item, list): 51 | decode_base64_in_dict(item, current_path) 52 | return data 53 | 54 | 55 | def handle_file(filename): 56 | filename_wo_ext = filename.stem 57 | pathlib.Path(filename_wo_ext).mkdir(parents=True, exist_ok=True) 58 | with open(filename, 'r') as file: 59 | json_data = json.load(file) 60 | # 取消 headers 里面 Authorization 字段,然后写回 61 | if 'headers' in json_data: 62 | if 'Authorization' in json_data['headers']: 63 | json_data['headers']['Authorization'] = 'Secret' 64 | # 写回原文件 65 | with open(filename, 'w') as file: 66 | json.dump(json_data, file, indent=2) 67 | if 'authorization' in json_data['headers']: 68 | json_data['headers']['authorization'] = 'Secret' 69 | # 写回原文件 70 | with open(filename, 'w') as file: 71 | json.dump(json_data, file, indent=2) 72 | request_data = json.loads(json_data.get("body", "")) 73 | request_data = decode_base64_in_dict(request_data, filename_wo_ext) 74 | # 写出包含替换字段的 JSON 文件回同名的文件夹 75 | with open(f"{filename_wo_ext}/schema.json", 'w') as jsonfile: 76 | json.dump(request_data, jsonfile, indent=2) 77 | 78 | 79 | def main(): 80 | # 列出当前文件夹内所有的 .json 文件 81 | json_files = pathlib.Path('.').glob('*.json') 82 | for file in json_files: 83 | logger.info(f"Handling {file}") 84 | handle_file(file) 85 | 86 | 87 | if __name__ == "__main__": 88 | main() 89 | -------------------------------------------------------------------------------- /src/novelai_python/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2023/11/18 上午12:18 3 | # @Author : sudoskys 4 | # @File : __init__.py 5 | from ._exceptions import ( 6 | NovelAiError, 7 | APIError, 8 | AuthError, 9 | ConcurrentGenerationError, 10 | SessionHttpError 11 | ) 12 | from .credential import JwtCredential, LoginCredential, ApiCredential 13 | from .sdk import AugmentImageInfer 14 | from .sdk import GenerateImageInfer, ImageGenerateResp 15 | from .sdk import Information, InformationResp 16 | from .sdk import LLM, LLMResp 17 | from .sdk import LLMStream, LLMStreamResp 18 | from .sdk import Login, LoginResp 19 | from .sdk import Subscription, SubscriptionResp 20 | from .sdk import SuggestTags, SuggestTagsResp 21 | from .sdk import Upscale, UpscaleResp 22 | from .sdk import VoiceGenerate, VoiceResponse 23 | 24 | __all__ = [ 25 | "LLM", 26 | "LLMResp", 27 | 28 | "LLMStream", 29 | "LLMStreamResp", 30 | 31 | "GenerateImageInfer", 32 | "ImageGenerateResp", 33 | 34 | "AugmentImageInfer", 35 | 36 | "VoiceGenerate", 37 | "VoiceResponse", 38 | 39 | "Upscale", 40 | "UpscaleResp", 41 | 42 | "Subscription", 43 | "SubscriptionResp", 44 | 45 | "Login", 46 | "LoginResp", 47 | 48 | "SuggestTags", 49 | "SuggestTagsResp", 50 | 51 | "Information", 52 | "InformationResp", 53 | 54 | "JwtCredential", 55 | "LoginCredential", 56 | "ApiCredential", 57 | 58 | "APIError", 59 | "SessionHttpError", 60 | "AuthError", 61 | "NovelAiError", 62 | "ConcurrentGenerationError" 63 | ] 64 | -------------------------------------------------------------------------------- /src/novelai_python/_enum.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Optional, Union 3 | 4 | 5 | class TextLLMModel(Enum): 6 | NEO_2B = "2.7B" 7 | J_6B = "6B" 8 | J_6B_V3 = "6B-v3" 9 | J_6B_V4 = "6B-v4" 10 | GENJI_PYTHON_6B = "genji-python-6b" 11 | GENJI_JP_6B = "genji-jp-6b" 12 | GENJI_JP_6B_V2 = "genji-jp-6b-v2" 13 | EUTERPE_V0 = "euterpe-v0" 14 | EUTERPE_V2 = "euterpe-v2" 15 | KRAKE_V1 = "krake-v1" 16 | KRAKE_V2 = "krake-v2" 17 | BLUE = "blue" 18 | RED = "red" 19 | GREEN = "green" 20 | PURPLE = "purple" 21 | PINK = "pink" 22 | YELLOW = "yellow" 23 | WHITE = "white" 24 | BLACK = "black" 25 | CASSANDRA = "cassandra" 26 | COMMENT_BOT = "hypebot" 27 | INFILL = "infillmodel" 28 | CLIO = "clio-v1" 29 | KAYRA = "kayra-v1" 30 | ERATO = "llama-3-erato-v1" 31 | 32 | 33 | class TextTokenizerGroup(object): 34 | GENJI = "genji_tokenizer.def" 35 | PILE = "pile_tokenizer.def" 36 | PILE_NAI = "pile_tokenizer.def" 37 | NAI_INLINE = "gpt2_tokenizer.def" 38 | NERDSTASH_V2 = "nerdstash_tokenizer_v2.def" 39 | NERDSTASH = "nerdstash_tokenizer.def" 40 | LLAMA3 = "llama3_tokenizer.def" 41 | GPT2 = "gpt2_tokenizer.def" 42 | CLIP = "clip_tokenizer.def" 43 | T5 = "t5_tokenizer.def" 44 | 45 | 46 | TextLLMModelTypeAlias = Union[TextLLMModel, str] 47 | 48 | TOKENIZER_MODEL_MAP = { 49 | TextLLMModel.GENJI_JP_6B_V2: TextTokenizerGroup.GENJI, 50 | TextLLMModel.CASSANDRA: TextTokenizerGroup.PILE, 51 | TextLLMModel.KRAKE_V2: TextTokenizerGroup.PILE, 52 | TextLLMModel.INFILL: TextTokenizerGroup.NAI_INLINE, 53 | TextLLMModel.KAYRA: TextTokenizerGroup.NERDSTASH_V2, 54 | TextLLMModel.BLUE: TextTokenizerGroup.NERDSTASH_V2, 55 | TextLLMModel.PINK: TextTokenizerGroup.NERDSTASH_V2, 56 | TextLLMModel.YELLOW: TextTokenizerGroup.NERDSTASH_V2, 57 | TextLLMModel.RED: TextTokenizerGroup.NERDSTASH_V2, 58 | TextLLMModel.GREEN: TextTokenizerGroup.NERDSTASH_V2, 59 | TextLLMModel.BLACK: TextTokenizerGroup.NERDSTASH_V2, 60 | TextLLMModel.CLIO: TextTokenizerGroup.NERDSTASH, 61 | TextLLMModel.PURPLE: TextTokenizerGroup.LLAMA3, 62 | TextLLMModel.WHITE: TextTokenizerGroup.LLAMA3, 63 | TextLLMModel.ERATO: TextTokenizerGroup.LLAMA3, 64 | } 65 | 66 | COLORS_LLM = [ 67 | TextLLMModel.BLUE, 68 | TextLLMModel.RED, 69 | TextLLMModel.GREEN, 70 | TextLLMModel.PURPLE, 71 | TextLLMModel.PINK, 72 | TextLLMModel.YELLOW, 73 | TextLLMModel.WHITE, 74 | TextLLMModel.BLACK, 75 | ] 76 | 77 | 78 | def get_llm_group(model: TextLLMModel) -> Optional[TextTokenizerGroup]: 79 | if isinstance(model, str): 80 | model = TextLLMModel(model) 81 | return TOKENIZER_MODEL_MAP.get(model, None) 82 | 83 | 84 | def get_tokenizer_model(model: TextLLMModel) -> str: 85 | if isinstance(model, str): 86 | model = TextLLMModel(model) 87 | group = TOKENIZER_MODEL_MAP.get(model, TextTokenizerGroup.GPT2) 88 | return group 89 | 90 | 91 | def get_tokenizer_model_url(model: TextLLMModel) -> str: 92 | model_name = get_tokenizer_model(model) 93 | if not model_name.endswith(".def"): 94 | model_name = f"{model_name}.def" 95 | return f"https://novelai.net/tokenizer/compressed/{model_name}?v=2&static=true" 96 | -------------------------------------------------------------------------------- /src/novelai_python/_exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/1/26 上午10:55 3 | # @Author : sudoskys 4 | # @File : _exceptions.py 5 | 6 | from typing import Any, Union, Dict 7 | 8 | 9 | class NovelAiError(Exception): 10 | """ 11 | NovelAiError is the base exception for all novelai_python errors. 12 | """ 13 | message: str 14 | 15 | def __init__(self, message: str) -> None: 16 | self.message = message 17 | 18 | @property 19 | def __dict__(self): 20 | return { 21 | "message": self.message, 22 | } 23 | 24 | 25 | class InvalidRequestHeader(NovelAiError): 26 | """ 27 | InvalidRequestHeader is raised when the request header is invalid. 28 | """ 29 | pass 30 | 31 | 32 | class SessionHttpError(NovelAiError): 33 | """ 34 | HTTPError is raised when a request to the API fails. 35 | """ 36 | pass 37 | 38 | 39 | class APIError(NovelAiError): 40 | """ 41 | APIError is raised when the API returns an error. 42 | """ 43 | request: Any 44 | code: Union[str, None] = None 45 | response: Union[Dict[str, Any], str] = None 46 | 47 | def __init__(self, 48 | message: str, 49 | request: Any, 50 | response: Union[Dict[str, Any], str], 51 | code: Union[str, None] 52 | ) -> None: 53 | super().__init__(message) 54 | self.request = request 55 | self.response = response 56 | self.code = code 57 | 58 | @property 59 | def __dict__(self): 60 | parent_dict = super().__dict__ 61 | parent_dict.update({ 62 | "request": self.request, 63 | "response": self.response, 64 | "code": self.code 65 | }) 66 | return parent_dict 67 | 68 | 69 | class ConcurrentGenerationError(APIError): 70 | """ 71 | ConcurrentGenerationError is raised when the API returns an error. 72 | """ 73 | 74 | pass 75 | 76 | 77 | class DataSerializationError(APIError): 78 | """ 79 | DataSerializationError is raised when the API data is not serializable. 80 | """ 81 | 82 | pass 83 | 84 | 85 | class AuthError(APIError): 86 | """ 87 | AuthError is raised when the API returns an error. 88 | """ 89 | 90 | pass 91 | -------------------------------------------------------------------------------- /src/novelai_python/_response/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/1/26 上午10:51 3 | # @Author : sudoskys 4 | # @File : __init__.py.py 5 | 6 | from .ai.generate_image import ImageGenerateResp 7 | from .user.information import InformationResp 8 | from .user.login import LoginResp 9 | from .user.subscription import SubscriptionResp 10 | 11 | __all__ = [ 12 | "ImageGenerateResp", 13 | "SubscriptionResp", 14 | "LoginResp", 15 | "InformationResp" 16 | ] 17 | -------------------------------------------------------------------------------- /src/novelai_python/_response/ai/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/1/26 上午11:20 3 | # @Author : sudoskys 4 | # @File : __init__.py 5 | 6 | -------------------------------------------------------------------------------- /src/novelai_python/_response/ai/generate.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from pydantic import BaseModel, ConfigDict 4 | 5 | from novelai_python._enum import get_tokenizer_model, TextLLMModel 6 | from novelai_python.tokenizer import NaiTokenizer 7 | from novelai_python.utils.encode import b64_to_tokens 8 | 9 | if TYPE_CHECKING: 10 | pass 11 | 12 | 13 | class LLMResp(BaseModel): 14 | """ 15 | response.json().get("output",None) 16 | """ 17 | output: str 18 | text: str 19 | model_config = ConfigDict(extra="ignore", arbitrary_types_allowed=False) 20 | 21 | @staticmethod 22 | def decode_token(token_str, model: TextLLMModel) -> str: 23 | dtype = 'uint32' if model in [TextLLMModel.ERATO] else 'uint16' 24 | return NaiTokenizer(model=get_tokenizer_model(model)).decode( 25 | b64_to_tokens(token_str, dtype=dtype) 26 | ) 27 | -------------------------------------------------------------------------------- /src/novelai_python/_response/ai/generate_image.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/1/26 上午11:16 3 | # @Author : sudoskys 4 | # @File : text2image.py 5 | 6 | from typing import Tuple, List 7 | 8 | from pydantic import BaseModel 9 | 10 | from ..schema import RespBase 11 | 12 | 13 | class RequestParams(BaseModel): 14 | endpoint: str 15 | raw_request: dict = None 16 | 17 | 18 | class ImageGenerateResp(RespBase): 19 | meta: RequestParams 20 | files: List[Tuple[str, bytes]] = None 21 | 22 | def query_params(self, key: str, default=None): 23 | if not isinstance(self.meta.raw_request.get("parameters"), dict): 24 | raise Exception("Resp parameters is not dict") 25 | return self.meta.raw_request.get("parameters").get(key, default) 26 | 27 | 28 | class SuggestTagsResp(RespBase): 29 | class Tag(BaseModel): 30 | tag: str 31 | count: int 32 | confidence: float 33 | 34 | tags: List[Tag] = None 35 | -------------------------------------------------------------------------------- /src/novelai_python/_response/ai/generate_stream.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Any 2 | 3 | from pydantic import BaseModel, ConfigDict 4 | 5 | from novelai_python._enum import get_tokenizer_model, TextLLMModel 6 | from novelai_python.tokenizer import NaiTokenizer 7 | from novelai_python.utils.encode import b64_to_tokens 8 | 9 | 10 | class LLMStreamResp(BaseModel): 11 | """ 12 | {"token":"LRI=","ptr":0,"final":false,"logprobs":null} 13 | """ 14 | token: str 15 | ptr: int 16 | final: bool 17 | logprobs: Optional[Any] 18 | text: Optional[str] = None 19 | model_config = ConfigDict(extra="ignore", arbitrary_types_allowed=False) 20 | 21 | @staticmethod 22 | def decode(token_str, model: TextLLMModel) -> str: 23 | dtype = 'uint32' if model in [TextLLMModel.ERATO] else 'uint16' 24 | return NaiTokenizer(model=get_tokenizer_model(model)).decode( 25 | b64_to_tokens(token_str, dtype=dtype) 26 | ) 27 | -------------------------------------------------------------------------------- /src/novelai_python/_response/ai/generate_voice.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, ConfigDict 2 | 3 | 4 | class VoiceResponse(BaseModel): 5 | meta: dict 6 | audio: bytes 7 | model_config = ConfigDict(extra="ignore", arbitrary_types_allowed=True) 8 | -------------------------------------------------------------------------------- /src/novelai_python/_response/ai/upscale.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/13 上午11:29 3 | # @Author : sudoskys 4 | # @File : upscale.py 5 | 6 | from typing import Tuple 7 | 8 | from pydantic import BaseModel 9 | 10 | from ..schema import RespBase 11 | 12 | 13 | class UpscaleResp(RespBase): 14 | class RequestParams(BaseModel): 15 | endpoint: str 16 | raw_request: dict = None 17 | 18 | meta: RequestParams 19 | files: Tuple[str, bytes] = None 20 | -------------------------------------------------------------------------------- /src/novelai_python/_response/schema.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/13 上午11:30 3 | # @Author : sudoskys 4 | # @File : schema.py 5 | 6 | 7 | from pydantic import BaseModel 8 | 9 | 10 | class RespBase(BaseModel): 11 | pass 12 | -------------------------------------------------------------------------------- /src/novelai_python/_response/user/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/7 上午9:57 3 | # @Author : sudoskys 4 | # @File : __init__.py.py 5 | 6 | -------------------------------------------------------------------------------- /src/novelai_python/_response/user/information.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/8 下午3:10 3 | # @Author : sudoskys 4 | # @File : information.py 5 | 6 | 7 | from pydantic import Field 8 | 9 | from ..schema import RespBase 10 | 11 | 12 | class InformationResp(RespBase): 13 | emailVerified: bool = Field(..., description="Email verification status") 14 | emailVerificationLetterSent: bool = Field(..., description="Email verification letter sent status") 15 | trialActivated: bool = Field(..., description="Trial activation status") 16 | trialActionsLeft: int = Field(..., description="Number of trial actions left") 17 | trialImagesLeft: int = Field(..., description="Number of trial images left") 18 | accountCreatedAt: int = Field(..., description="Account creation time") 19 | -------------------------------------------------------------------------------- /src/novelai_python/_response/user/login.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/7 上午11:57 3 | # @Author : sudoskys 4 | # @File : login.py 5 | 6 | 7 | from ..schema import RespBase 8 | 9 | 10 | class LoginResp(RespBase): 11 | accessToken: str 12 | -------------------------------------------------------------------------------- /src/novelai_python/_response/user/subscription.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/7 上午9:57 3 | # @Author : sudoskys 4 | # @File : subscription.py 5 | 6 | from typing import Optional, Dict, Any, List 7 | 8 | from pydantic import BaseModel, Field 9 | 10 | from ..schema import RespBase 11 | 12 | 13 | class TrainingSteps(BaseModel): 14 | fixedTrainingStepsLeft: int 15 | purchasedTrainingSteps: int 16 | 17 | 18 | class ImageGenerationLimit(BaseModel): 19 | resolution: int 20 | maxPrompts: int 21 | 22 | 23 | class Perks(BaseModel): 24 | maxPriorityActions: int 25 | startPriority: int 26 | moduleTrainingSteps: int 27 | unlimitedMaxPriority: bool 28 | voiceGeneration: bool 29 | imageGeneration: bool 30 | unlimitedImageGeneration: bool 31 | unlimitedImageGenerationLimits: List[ImageGenerationLimit] 32 | contextTokens: int 33 | 34 | 35 | class SubscriptionResp(RespBase): 36 | tier: int = Field(..., description="Subscription tier") 37 | active: bool = Field(..., description="Subscription status") 38 | expiresAt: int = Field(..., description="Subscription expiration time") 39 | perks: Perks = Field(..., description="Subscription perks") 40 | paymentProcessorData: Optional[Dict[Any, Any]] 41 | trainingStepsLeft: TrainingSteps = Field(..., description="Training steps left") 42 | accountType: int = Field(..., description="Account type") 43 | 44 | @property 45 | def is_active(self): 46 | return self.active 47 | 48 | @property 49 | def anlas_left(self): 50 | return self.trainingStepsLeft.fixedTrainingStepsLeft + self.trainingStepsLeft.purchasedTrainingSteps 51 | 52 | @property 53 | def is_unlimited_image_generation(self): 54 | return self.perks.unlimitedImageGeneration and self.perks.imageGeneration 55 | 56 | @property 57 | def get_tier_name(self): 58 | if self.tier == 0: 59 | return "Paper" 60 | elif self.tier == 1: 61 | return "Tablet" 62 | elif self.tier == 2: 63 | return "Scroll" 64 | elif self.tier == 3: 65 | return "Opus" 66 | else: 67 | return "Unknown" 68 | 69 | @property 70 | def limit_perks(self): 71 | perks = [] 72 | if not self.perks.imageGeneration: 73 | perks.append("imageGeneration") 74 | if not self.perks.voiceGeneration: 75 | perks.append("voiceGeneration") 76 | if not self.perks.unlimitedImageGeneration: 77 | perks.append("unlimitedImageGeneration") 78 | if not self.perks.unlimitedMaxPriority: 79 | perks.append("unlimitedMaxPriority") 80 | return perks 81 | -------------------------------------------------------------------------------- /src/novelai_python/credential/ApiToken.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/8 下午3:05 3 | # @Author : sudoskys 4 | # @File : ApiToken.py 5 | 6 | 7 | import arrow 8 | import shortuuid 9 | from curl_cffi.requests import AsyncSession 10 | from loguru import logger 11 | from pydantic import SecretStr, Field, field_validator 12 | 13 | from ._base import CredentialBase 14 | 15 | 16 | class ApiCredential(CredentialBase): 17 | """ 18 | ApiCredential is the base class for all credential. 19 | """ 20 | api_token: SecretStr = Field(None, description="api token") 21 | x_correlation_id: str = shortuuid.uuid()[0:6] 22 | 23 | async def get_session(self, timeout: int = 180, update_headers: dict = None): 24 | headers = { 25 | "Authorization": f"Bearer {self.api_token.get_secret_value()}", 26 | "Content-Type": "application/json", 27 | "x-correlation-id": self.x_correlation_id, 28 | "x-initiated-at": f"{arrow.utcnow().isoformat()}Z", 29 | } 30 | 31 | if update_headers: 32 | assert isinstance(update_headers, dict), "update_headers must be a dict" 33 | headers.update(update_headers) 34 | 35 | return AsyncSession(timeout=timeout, headers=headers, impersonate="chrome136") 36 | 37 | @field_validator('api_token') 38 | def check_api_token(cls, v: SecretStr): 39 | if not v.get_secret_value().startswith("pst"): 40 | logger.warning("api token should start with `pst-`") 41 | return v -------------------------------------------------------------------------------- /src/novelai_python/credential/JwtToken.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/1/26 上午11:04 3 | # @Author : sudoskys 4 | # @File : JwtToken.py 5 | 6 | 7 | import arrow 8 | import shortuuid 9 | from curl_cffi.requests import AsyncSession 10 | from loguru import logger 11 | from pydantic import SecretStr, Field, field_validator 12 | 13 | from ._base import CredentialBase 14 | 15 | 16 | class JwtCredential(CredentialBase): 17 | """ 18 | JwtCredential is the base class for all credential. 19 | """ 20 | jwt_token: SecretStr = Field(None, description="jwt token") 21 | x_correlation_id: str = shortuuid.uuid()[0:6] 22 | 23 | async def get_session(self, timeout: int = 180, update_headers: dict = None): 24 | headers = { 25 | "Authorization": f"Bearer {self.jwt_token.get_secret_value()}", 26 | "Content-Type": "application/json", 27 | "x-correlation-id": self.x_correlation_id, 28 | "x-initiated-at": f"{arrow.utcnow().isoformat()}Z", 29 | } 30 | 31 | if update_headers: 32 | assert isinstance(update_headers, dict), "update_headers must be a dict" 33 | headers.update(update_headers) 34 | 35 | return AsyncSession(timeout=timeout, headers=headers, impersonate="chrome136") 36 | 37 | @field_validator('jwt_token') 38 | def check_jwt_token(cls, v: SecretStr): 39 | if not v.get_secret_value().startswith("ey"): 40 | logger.warning("jwt_token should start with ey") 41 | return v -------------------------------------------------------------------------------- /src/novelai_python/credential/UserAuth.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/7 下午12:14 3 | # @Author : sudoskys 4 | # @File : UserAuth.py 5 | import time 6 | from typing import Optional 7 | 8 | import arrow 9 | import shortuuid 10 | from curl_cffi.requests import AsyncSession 11 | from pydantic import SecretStr, Field 12 | 13 | from ._base import CredentialBase 14 | 15 | 16 | class LoginCredential(CredentialBase): 17 | """ 18 | JwtCredential is the base class for all credential. 19 | """ 20 | username: str = Field(None, description="username") 21 | password: SecretStr = Field(None, description="password") 22 | session_headers: dict = Field(default_factory=dict) 23 | update_at: Optional[int] = None 24 | x_correlation_id: str = shortuuid.uuid()[0:6] 25 | 26 | async def get_session(self, timeout: int = 180, update_headers: dict = None): 27 | headers = { 28 | "Authorization": "Bearer ", 29 | "Content-Type": "application/json", 30 | "x-correlation-id": self.x_correlation_id, 31 | "x-initiated-at": f"{arrow.utcnow().isoformat()}Z", 32 | } 33 | 34 | # 30 天有效期 35 | if not self.session_headers or int(time.time()) - self.update_at > 29 * 24 * 60 * 60: 36 | from ..sdk import Login 37 | resp = await Login.build(user_name=self.username, password=self.password.get_secret_value()).request() 38 | headers["Authorization"] = f"Bearer {resp.accessToken}" 39 | self.session_headers = headers 40 | self.update_at = int(time.time()) 41 | else: 42 | headers.update(self.session_headers) 43 | 44 | if update_headers: 45 | headers.update(update_headers) 46 | 47 | return AsyncSession(timeout=timeout, headers=headers, impersonate="chrome136") 48 | -------------------------------------------------------------------------------- /src/novelai_python/credential/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/1/26 上午10:51 3 | # @Author : sudoskys 4 | # @File : __init__.py.py 5 | 6 | from pydantic import SecretStr 7 | 8 | from .ApiToken import ApiCredential 9 | from .JwtToken import JwtCredential 10 | from .UserAuth import LoginCredential 11 | from ._base import CredentialBase 12 | 13 | __all__ = [ 14 | "JwtCredential", 15 | "LoginCredential", 16 | "CredentialBase", 17 | "ApiCredential", 18 | "SecretStr" 19 | ] 20 | -------------------------------------------------------------------------------- /src/novelai_python/credential/_base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/7 下午12:14 3 | # @Author : sudoskys 4 | # @File : _shema.py 5 | 6 | from curl_cffi.requests import AsyncSession 7 | from fake_useragent import UserAgent 8 | from pydantic import BaseModel 9 | 10 | FAKE_UA = UserAgent() 11 | 12 | 13 | class CredentialBase(BaseModel): 14 | _session: AsyncSession = None 15 | """会话""" 16 | 17 | async def get_session(self, timeout: int = 180, update_headers: dict = None): 18 | raise NotImplementedError 19 | -------------------------------------------------------------------------------- /src/novelai_python/sdk/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/1/26 上午10:51 3 | # @Author : sudoskys 4 | # @File : __init__.py.py 5 | 6 | 7 | from .ai.augment_image import AugmentImageInfer # noqa 401 8 | from .ai.generate import LLM, LLMResp # noqa 401 9 | from .ai.generate_image import GenerateImageInfer, ImageGenerateResp # noqa 401 10 | from .ai.generate_image.suggest_tags import SuggestTags, SuggestTagsResp # noqa 401 11 | from .ai.generate_stream import LLMStream, LLMStreamResp # noqa 401 12 | from .ai.generate_voice import VoiceGenerate, VoiceResponse # noqa 401 13 | from .ai.upscale import Upscale, UpscaleResp # noqa 401 14 | from .user.information import Information, InformationResp # noqa 401 15 | from .user.login import Login, LoginResp # noqa 401 16 | from .user.subscription import Subscription, SubscriptionResp # noqa 401 17 | -------------------------------------------------------------------------------- /src/novelai_python/sdk/ai/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 GM Development Department 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. 22 | -------------------------------------------------------------------------------- /src/novelai_python/sdk/ai/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/1/26 上午11:20 3 | # @Author : sudoskys 4 | # @File : __init__.py.py 5 | 6 | -------------------------------------------------------------------------------- /src/novelai_python/sdk/ai/augment_image/_enum.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class ReqType(Enum): 5 | """ 6 | typing.Literal["bg-removal", "colorize", "lineart", "sketch", "emotion", "declutter", "declutter-keep-bubbles"] 7 | """ 8 | BG_REMOVAL = "bg-removal" 9 | COLORIZE = "colorize" 10 | LINEART = "lineart" 11 | SKETCH = "sketch" 12 | EMOTION = "emotion" 13 | DECLUTTER = "declutter" 14 | DECLUTTER_KEEP_BUBBLES = "declutter-keep-bubbles" 15 | 16 | 17 | class Moods(Enum): 18 | """ 19 | The mood of the character in the image 20 | """ 21 | Neutral = "neutral" 22 | Happy = "happy" 23 | Saf = "sad" 24 | Angry = "angry" 25 | Scared = "scared" 26 | Surprised = "surprised" 27 | Tired = "tired" 28 | Excited = "excited" 29 | Nervous = "nervous" 30 | Thinking = "thinking" 31 | Confused = "confused" 32 | Shy = "shy" 33 | Disgusted = "disgusted" 34 | Smug = "smug" 35 | Bored = "bored" 36 | Laughing = "laughing" 37 | Irritated = "irritated" 38 | Aroused = "aroused" 39 | Embarrassed = "embarrassed" 40 | Worried = "worried" 41 | Love = "love" 42 | Determined = "determined" 43 | Hurt = "hurt" 44 | Playful = "playful" 45 | -------------------------------------------------------------------------------- /src/novelai_python/sdk/ai/generate_image/schema.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import List, Union 3 | 4 | from pydantic import BaseModel, Field 5 | 6 | 7 | class CharacterPosition(BaseModel): 8 | x: Union[float, int] = Field(0, le=0.9, ge=0, description="X") 9 | """ 10 | 0 Auto 11 | 0.1 0.3 0.5 0.7 0.9 12 | """ 13 | y: Union[float, int] = Field(0, le=0.9, ge=0, description="Y") 14 | """ 15 | 0 Auto 16 | 0.1 0.3 0.5 0.7 0.9 17 | """ 18 | 19 | 20 | class PositionMap(Enum): 21 | AUTO = CharacterPosition(x=0, y=0) 22 | A1 = CharacterPosition(x=0.1, y=0.1) 23 | A2 = CharacterPosition(x=0.1, y=0.3) 24 | A3 = CharacterPosition(x=0.1, y=0.5) 25 | A4 = CharacterPosition(x=0.1, y=0.7) 26 | A5 = CharacterPosition(x=0.1, y=0.9) 27 | B1 = CharacterPosition(x=0.3, y=0.1) 28 | B2 = CharacterPosition(x=0.3, y=0.3) 29 | B3 = CharacterPosition(x=0.3, y=0.5) 30 | B4 = CharacterPosition(x=0.3, y=0.7) 31 | B5 = CharacterPosition(x=0.3, y=0.9) 32 | C1 = CharacterPosition(x=0.5, y=0.1) 33 | C2 = CharacterPosition(x=0.5, y=0.3) 34 | C3 = CharacterPosition(x=0.5, y=0.5) 35 | C4 = CharacterPosition(x=0.5, y=0.7) 36 | C5 = CharacterPosition(x=0.5, y=0.9) 37 | D1 = CharacterPosition(x=0.7, y=0.1) 38 | D2 = CharacterPosition(x=0.7, y=0.3) 39 | D3 = CharacterPosition(x=0.7, y=0.5) 40 | D4 = CharacterPosition(x=0.7, y=0.7) 41 | D5 = CharacterPosition(x=0.7, y=0.9) 42 | E1 = CharacterPosition(x=0.9, y=0.1) 43 | E2 = CharacterPosition(x=0.9, y=0.3) 44 | E3 = CharacterPosition(x=0.9, y=0.5) 45 | E4 = CharacterPosition(x=0.9, y=0.7) 46 | E5 = CharacterPosition(x=0.9, y=0.9) 47 | 48 | 49 | class Character(BaseModel): 50 | prompt: str = Field(None, description="Prompt") 51 | uc: str = Field("", description="Negative Prompt") 52 | center: Union[PositionMap, CharacterPosition] = Field(PositionMap.AUTO, description="Center") 53 | """Center""" 54 | enabled: bool = Field(True, description="Enabled") 55 | 56 | @classmethod 57 | def build(cls, 58 | prompt: str, 59 | uc: str = '', 60 | center: Union[PositionMap, CharacterPosition] = PositionMap.AUTO 61 | ) -> "Character": 62 | """ 63 | Build CharacterPrompt from prompt 64 | :param prompt: The main prompt 65 | :param uc: The negative prompt 66 | :param center: The center 67 | :return: 68 | """ 69 | return cls(prompt=prompt, uc=uc, center=center) 70 | 71 | 72 | class CharCaption(BaseModel): 73 | char_caption: str = Field('', description="Character Caption") 74 | centers: List[Union[PositionMap, CharacterPosition]] = Field([PositionMap.AUTO], description="Center") 75 | """Center""" 76 | 77 | 78 | class Caption(BaseModel): 79 | base_caption: str = Field("", description="Main Prompt") 80 | """Main Prompt""" 81 | char_captions: List[CharCaption] = Field(default_factory=list, description="Character Captions") 82 | """Character Captions""" 83 | 84 | 85 | class V4Prompt(BaseModel): 86 | caption: Caption = Field(default_factory=Caption, description="Caption") 87 | use_coords: bool = Field(True, description="Use Coordinates") 88 | use_order: bool = Field(True, description="Use Order") 89 | 90 | @classmethod 91 | def build_from_character_prompts(cls, 92 | base_caption: str, 93 | character_prompts: List[Character], 94 | use_coords: bool = True, 95 | use_order: bool = True 96 | ) -> "V4Prompt": 97 | """ 98 | Build V4Prompt from character prompts 99 | :param prompt: The main prompt 100 | :param character_prompts: List of character prompts 101 | :param use_coords: Whether to use coordinates 102 | :param use_order: Whether to use order 103 | :return: 104 | """ 105 | caption = Caption(base_caption=base_caption) 106 | for character_prompt in character_prompts: 107 | char_caption = CharCaption(char_caption=character_prompt.prompt, centers=[character_prompt.center]) 108 | caption.char_captions.append(char_caption) 109 | return cls(caption=caption, use_coords=use_coords, use_order=use_order) 110 | 111 | 112 | class V4NegativePrompt(BaseModel): 113 | caption: Caption = Field(default_factory=Caption, description="Caption") 114 | legacy_uc: bool = False 115 | 116 | @classmethod 117 | def build_from_character_prompts(cls, 118 | negative_prompt: str, 119 | character_prompts: List[Character], 120 | legacy_uc: bool 121 | ) -> "V4NegativePrompt": 122 | """ 123 | Build V4NegativePrompt from character prompts 124 | :param negative_prompt: The main prompt 125 | :param character_prompts: List of character prompts 126 | :param legacy_uc: Legacy Uc 127 | :return: 128 | """ 129 | caption = Caption(base_caption=negative_prompt) 130 | for character_prompt in character_prompts: 131 | char_caption = CharCaption(char_caption=character_prompt.uc, centers=[character_prompt.center]) 132 | caption.char_captions.append(char_caption) 133 | return cls(caption=caption, legacy_uc=legacy_uc) 134 | -------------------------------------------------------------------------------- /src/novelai_python/sdk/ai/generate_image/suggest_tags.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/13 下午8:09 3 | # @Author : sudoskys 4 | # @File : suggest-tags.py 5 | 6 | from typing import Optional, Union 7 | from urllib.parse import urlparse 8 | 9 | import curl_cffi 10 | import httpx 11 | from curl_cffi.requests import AsyncSession 12 | from loguru import logger 13 | from pydantic import PrivateAttr 14 | 15 | from novelai_python.sdk.ai._enum import Model 16 | from ...schema import ApiBaseModel 17 | from ...._exceptions import APIError, AuthError, SessionHttpError 18 | from ...._response.ai.generate_image import SuggestTagsResp 19 | from ....credential import CredentialBase 20 | from ....utils import try_jsonfy 21 | 22 | 23 | class SuggestTags(ApiBaseModel): 24 | _endpoint: str = PrivateAttr("https://api.novelai.net") 25 | model: Model = Model.NAI_DIFFUSION_3 26 | prompt: str = "landscape" 27 | 28 | @property 29 | def endpoint(self): 30 | return self._endpoint 31 | 32 | @endpoint.setter 33 | def endpoint(self, value): 34 | self._endpoint = value 35 | 36 | @property 37 | def base_url(self): 38 | return f"{self.endpoint.strip('/')}/ai/generate-image/suggest-tags" 39 | 40 | async def request(self, 41 | session: Union[AsyncSession, CredentialBase], 42 | *, 43 | override_headers: Optional[dict] = None 44 | ) -> SuggestTagsResp: 45 | """ 46 | Request to get user subscription information 47 | :param override_headers: 48 | :param session: 49 | :return: 50 | """ 51 | # Data Build 52 | request_data = self.model_dump(mode="json", exclude_none=True) 53 | if isinstance(session, AsyncSession): 54 | pass 55 | elif isinstance(session, CredentialBase): 56 | pass 57 | # Header 58 | if override_headers: 59 | session.headers.clear() 60 | session.headers.update(override_headers) 61 | try: 62 | assert hasattr(session, "get"), "session must have get method." 63 | response = await session.get( 64 | url=self.base_url + "?" + "&".join([f"{k}={v}" for k, v in request_data.items()]) 65 | ) 66 | if ( 67 | "application/json" not in response.headers.get('Content-Type') 68 | or response.status_code != 200 69 | ): 70 | error_message = await self.handle_error_response(response=response, request_data=request_data) 71 | status_code = error_message.get("statusCode", response.status_code) 72 | message = error_message.get("message", "Unknown error") 73 | if status_code in [400, 401, 402]: 74 | # 400 : validation error 75 | # 401 : unauthorized 76 | # 402 : payment required 77 | # 409 : conflict 78 | raise AuthError(message, request=request_data, code=status_code, response=error_message) 79 | if status_code in [500]: 80 | # An unknown error occured. 81 | raise APIError(message, request=request_data, code=status_code, response=error_message) 82 | raise APIError(message, request=request_data, code=status_code, response=error_message) 83 | return SuggestTagsResp.model_validate(response.json()) 84 | except curl_cffi.requests.errors.RequestsError as exc: 85 | logger.exception(exc) 86 | raise SessionHttpError("An AsyncSession RequestsError occurred, maybe SSL error. Try again later!") 87 | except httpx.HTTPError as exc: 88 | logger.exception(exc) 89 | raise SessionHttpError("An HTTPError occurred, maybe SSL error. Try again later!") 90 | except APIError as e: 91 | raise e 92 | except Exception as e: 93 | logger.opt(exception=e).exception("An Unexpected error occurred") 94 | raise e 95 | -------------------------------------------------------------------------------- /src/novelai_python/sdk/ai/generate_image/tokenizer.py: -------------------------------------------------------------------------------- 1 | from novelai_python._enum import TextTokenizerGroup 2 | from novelai_python.sdk.ai.generate_image import Model 3 | from novelai_python.tokenizer import NaiTokenizer 4 | 5 | 6 | def get_prompt_tokenizer(model: Model): 7 | """ 8 | Only for IMAGE prompt length calculation 9 | :param model: 10 | :return: 11 | """ 12 | if model in [ 13 | Model.CUSTOM, 14 | Model.NAI_DIFFUSION_4_5_CURATED, 15 | Model.NAI_DIFFUSION_4_5_CURATED_INPAINTING, 16 | Model.NAI_DIFFUSION_4_CURATED_PREVIEW, 17 | Model.NAI_DIFFUSION_4_CURATED_INPAINTING, 18 | Model.NAI_DIFFUSION_4_FULL, 19 | Model.NAI_DIFFUSION_4_FULL_INPAINTING, 20 | ]: 21 | return NaiTokenizer(TextTokenizerGroup.T5) 22 | return NaiTokenizer(TextTokenizerGroup.CLIP) 23 | -------------------------------------------------------------------------------- /src/novelai_python/sdk/ai/generate_voice/_enum.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class Speaker(BaseModel): 7 | """ 8 | Speaker for /ai/generated_voice 9 | """ 10 | sid: int = -1 11 | seed: str = "kurumuz12" 12 | name: str 13 | category: str 14 | 15 | @property 16 | def version(self): 17 | return "v2" if self.sid == -1 else "v1" 18 | 19 | 20 | class VoiceSpeakerV1(Enum): 21 | """ 22 | Speaker for /ai/generated_voice 23 | """ 24 | Cyllene = Speaker(sid=17, name="Cyllene", category="female") 25 | Leucosia = Speaker(sid=95, name="Leucosia", category="female") 26 | Crina = Speaker(sid=44, name="Crina", category="female") 27 | Hespe = Speaker(sid=80, name="Hespe", category="female") 28 | Ida = Speaker(sid=106, name="Ida", category="female") 29 | Alseid = Speaker(sid=6, name="Alseid", category="male") 30 | Daphnis = Speaker(sid=10, name="Daphnis", category="male") 31 | Echo = Speaker(sid=16, name="Echo", category="male") 32 | Thel = Speaker(sid=41, name="Thel", category="male") 33 | Nomios = Speaker(sid=77, name="Nomios", category="male") 34 | # SeedInput = Speaker(sid=-1, name="Seed Input", category="custom") 35 | 36 | 37 | class VoiceSpeakerV2(Enum): 38 | """ 39 | Speaker for /ai/generated_voice 40 | """ 41 | Ligeia = Speaker(name="Ligeia", category="unisex", seed="Anananan") 42 | Aini = Speaker(name="Aini", category="female", seed="Aini") 43 | Orea = Speaker(name="Orea", category="female", seed="Orea") 44 | Claea = Speaker(name="Claea", category="female", seed="Claea") 45 | Lim = Speaker(name="Lim", category="female", seed="Lim") 46 | Aurae = Speaker(name="Aurae", category="female", seed="Aurae") 47 | Naia = Speaker(name="Naia", category="female", seed="Naia") 48 | Aulon = Speaker(name="Aulon", category="male", seed="Aulon") 49 | Elei = Speaker(name="Elei", category="male", seed="Elei") 50 | Ogma = Speaker(name="Ogma", category="male", seed="Ogma") 51 | Raid = Speaker(name="Raid", category="male", seed="Raid") 52 | Pega = Speaker(name="Pega", category="male", seed="Pega") 53 | Lam = Speaker(name="Lam", category="male", seed="Lam") 54 | -------------------------------------------------------------------------------- /src/novelai_python/sdk/schema.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/10 上午11:15 3 | # @Author : sudoskys 4 | # @File : schema.py 5 | 6 | from abc import ABC, abstractmethod 7 | from typing import Optional, Union 8 | 9 | from curl_cffi.requests import AsyncSession 10 | from loguru import logger 11 | from pydantic import BaseModel, PrivateAttr 12 | 13 | from ..credential import CredentialBase 14 | from ..utils import try_jsonfy 15 | 16 | 17 | class ApiBaseModel(BaseModel, ABC): 18 | _endpoint: Optional[str] = PrivateAttr() 19 | 20 | @property 21 | @abstractmethod 22 | def base_url(self): 23 | logger.error("ApiBaseModel.base_url must be overridden") 24 | return f"{self.endpoint.strip('/')}/need-to-override" 25 | 26 | @property 27 | def endpoint(self): 28 | return self._endpoint 29 | 30 | @endpoint.setter 31 | def endpoint(self, value): 32 | self._endpoint = value 33 | 34 | @staticmethod 35 | def ensure_session_has_post_method(session): 36 | if not hasattr(session, "post"): 37 | raise AttributeError("SESSION_MUST_HAVE_POST_METHOD") 38 | 39 | @staticmethod 40 | def ensure_session_has_get_method(session): 41 | if not hasattr(session, "get"): 42 | raise AttributeError("SESSION_MUST_HAVE_GET_METHOD") 43 | 44 | async def handle_error_response( 45 | self, 46 | response, 47 | request_data: dict, 48 | content_hint: str = "Response content too long", 49 | max_content_length: int = 50 50 | ) -> dict: 51 | """ 52 | Common method to handle error response 53 | :param response: HTTP response 54 | :param request_data: request data 55 | :param content_hint: hint for content too long 56 | :param max_content_length: max content length 57 | :return: dict of error message 58 | """ 59 | logger.debug( 60 | f"\n[novelai-python] Unexpected response:\n" 61 | f" - URL : {self.base_url}\n" 62 | f" - Content-Type: {response.headers.get('Content-Type', 'N/A')}\n" 63 | f" - Status : {response.status_code}\n" 64 | ) 65 | try: 66 | # 尝试解析 JSON 响应 67 | error_message = response.json() 68 | except Exception as e: 69 | # 如果解析 JSON 失败,则记录日志,并尝试显示短内容 70 | logger.warning(f"Failed to parse error response: {e}") 71 | error_message = { 72 | "statusCode": response.status_code, 73 | "message": try_jsonfy(response.content) 74 | if len(response.content) < max_content_length 75 | else content_hint, 76 | } 77 | # 日志记录解析出的错误消息 78 | logger.trace(f"Parsed error message: {error_message}") 79 | return error_message 80 | 81 | @abstractmethod 82 | async def request(self, 83 | session: Union[AsyncSession, CredentialBase], 84 | *, 85 | override_headers: Optional[dict] = None 86 | ): 87 | raise NotImplementedError() 88 | -------------------------------------------------------------------------------- /src/novelai_python/sdk/user/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/7 上午10:03 3 | # @Author : sudoskys 4 | # @File : __init__.py.py 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/novelai_python/sdk/user/information.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/8 下午3:09 3 | # @Author : sudoskys 4 | # @File : information.py 5 | 6 | from typing import Optional, Union 7 | from urllib.parse import urlparse 8 | 9 | import curl_cffi 10 | import httpx 11 | from curl_cffi.requests import AsyncSession 12 | from loguru import logger 13 | from pydantic import PrivateAttr 14 | 15 | from ..schema import ApiBaseModel 16 | from ..._exceptions import APIError, AuthError, SessionHttpError 17 | from ..._response.user.information import InformationResp 18 | from ...credential import CredentialBase 19 | 20 | 21 | class Information(ApiBaseModel): 22 | _endpoint: Optional[str] = PrivateAttr("https://api.novelai.net") 23 | 24 | @property 25 | def base_url(self): 26 | return f"{self.endpoint.strip('/')}/user/information" 27 | 28 | @property 29 | def endpoint(self): 30 | return self._endpoint 31 | 32 | @endpoint.setter 33 | def endpoint(self, value): 34 | self._endpoint = value 35 | 36 | async def request(self, 37 | session: Union[AsyncSession, CredentialBase], 38 | *, 39 | override_headers: Optional[dict] = None 40 | ) -> InformationResp: 41 | """ 42 | Request to get user subscription information 43 | :param override_headers: 44 | :param session: 45 | :return: 46 | """ 47 | request_data = {} 48 | async with session if isinstance(session, AsyncSession) else await session.get_session() as sess: 49 | # Header 50 | if override_headers: 51 | sess.headers.clear() 52 | sess.headers.update(override_headers) 53 | logger.debug("Information") 54 | try: 55 | self.ensure_session_has_get_method(sess) 56 | response = await sess.get( 57 | self.base_url 58 | ) 59 | if "application/json" not in response.headers.get('Content-Type') or response.status_code != 200: 60 | error_message = await self.handle_error_response(response=response, request_data=request_data) 61 | status_code = error_message.get("statusCode", response.status_code) 62 | message = error_message.get("message", "Unknown error") 63 | if status_code in [400, 401, 402]: 64 | # 400 : validation error 65 | # 401 : unauthorized 66 | # 402 : payment required 67 | # 409 : conflict 68 | raise AuthError(message, request=request_data, code=status_code, response=error_message) 69 | if status_code in [500]: 70 | # An unknown error occured. 71 | raise APIError(message, request=request_data, code=status_code, response=error_message) 72 | raise APIError(message, request=request_data, code=status_code, response=error_message) 73 | 74 | return InformationResp.model_validate(response.json()) 75 | except curl_cffi.requests.errors.RequestsError as exc: 76 | logger.exception(exc) 77 | raise SessionHttpError("An AsyncSession RequestsError occurred, maybe SSL error. Try again later!") 78 | except httpx.HTTPError as exc: 79 | logger.exception(exc) 80 | raise SessionHttpError("An HTTPError occurred, maybe SSL error. Try again later!") 81 | except APIError as e: 82 | raise e 83 | except Exception as e: 84 | logger.opt(exception=e).exception("An Unexpected error occurred") 85 | raise e 86 | -------------------------------------------------------------------------------- /src/novelai_python/sdk/user/login.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/7 上午11:46 3 | # @Author : sudoskys 4 | # @File : login.py 5 | 6 | import json 7 | from typing import Optional, Union 8 | 9 | import curl_cffi 10 | import httpx 11 | from curl_cffi.requests import AsyncSession 12 | from loguru import logger 13 | from pydantic import PrivateAttr, Field 14 | 15 | from ..schema import ApiBaseModel 16 | from ..._exceptions import APIError, SessionHttpError 17 | from ..._response.user.login import LoginResp 18 | from ...credential import CredentialBase 19 | from ...utils import encode_access_key 20 | 21 | 22 | class Login(ApiBaseModel): 23 | _endpoint: str = PrivateAttr("https://api.novelai.net") 24 | key: str = Field(..., description="User's key") 25 | 26 | @property 27 | def endpoint(self): 28 | return self._endpoint 29 | 30 | @endpoint.setter 31 | def endpoint(self, value): 32 | self._endpoint = value 33 | 34 | @property 35 | def base_url(self): 36 | return f"{self.endpoint.strip('/')}/user/login" 37 | 38 | @property 39 | def session(self): 40 | return AsyncSession(timeout=180, headers={ 41 | "Content-Type": "application/json", 42 | "Origin": "https://novelai.net", 43 | "Referer": "https://novelai.net/", 44 | }, impersonate="chrome110") 45 | 46 | @classmethod 47 | def build(cls, *, user_name: str, password: str): 48 | """ 49 | From username and password to build a Login instance 50 | :param user_name: 51 | :param password: 52 | :return: 53 | """ 54 | return cls(key=encode_access_key(user_name, password)) 55 | 56 | async def request(self, 57 | session: Union[AsyncSession, CredentialBase] = None, 58 | *, 59 | override_headers: Optional[dict] = None, 60 | ) -> LoginResp: 61 | """ 62 | Request to get user access token 63 | :return: 64 | """ 65 | # Data Build 66 | request_data = self.model_dump(mode="json", exclude_none=True) 67 | 68 | async with session if isinstance(session, AsyncSession) else self.session as sess: 69 | # Header 70 | if override_headers: 71 | sess.headers.clear() 72 | sess.headers.update(override_headers) 73 | logger.debug("Fetching login-credential") 74 | try: 75 | self.ensure_session_has_post_method(sess) 76 | response = await sess.post( 77 | self.base_url, 78 | data=json.dumps(request_data).encode("utf-8") 79 | ) 80 | if "application/json" not in response.headers.get('Content-Type') or response.status_code != 201: 81 | error_message = await self.handle_error_response(response=response, request_data=request_data) 82 | status_code = error_message.get("statusCode", response.status_code) 83 | message = error_message.get("message", "Unknown error") 84 | if status_code in [400, 401]: 85 | # 400 : A validation error occured. 86 | # 401 : Access Key is incorrect. 87 | raise APIError(message, request=request_data, code=status_code, response=error_message) 88 | if status_code in [500]: 89 | # An unknown error occured. 90 | raise APIError(message, request=request_data, code=status_code, response=error_message) 91 | raise APIError(message, request=request_data, code=status_code, response=error_message) 92 | return LoginResp.model_validate(response.json()) 93 | except curl_cffi.requests.errors.RequestsError as exc: 94 | logger.exception(exc) 95 | raise SessionHttpError("An AsyncSession RequestsError occurred, maybe SSL error. Try again later!") 96 | except httpx.HTTPError as exc: 97 | logger.exception(exc) 98 | raise SessionHttpError("An HTTPError occurred, maybe SSL error. Try again later!") 99 | except APIError as e: 100 | raise e 101 | except Exception as e: 102 | logger.opt(exception=e).exception("An Unexpected error occurred") 103 | raise e 104 | -------------------------------------------------------------------------------- /src/novelai_python/sdk/user/subscription.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/7 上午10:04 3 | # @Author : sudoskys 4 | # @File : subscription.py.py 5 | 6 | from typing import Optional, Union 7 | from urllib.parse import urlparse 8 | 9 | import curl_cffi 10 | import httpx 11 | from curl_cffi.requests import AsyncSession 12 | from loguru import logger 13 | from pydantic import PrivateAttr 14 | 15 | from ..schema import ApiBaseModel 16 | from ..._exceptions import APIError, AuthError, SessionHttpError 17 | from ..._response.user.subscription import SubscriptionResp 18 | from ...credential import CredentialBase 19 | 20 | 21 | class Subscription(ApiBaseModel): 22 | _endpoint: str = PrivateAttr("https://api.novelai.net") 23 | 24 | @property 25 | def base_url(self): 26 | return f"{self.endpoint.strip('/')}/user/subscription" 27 | 28 | @property 29 | def endpoint(self): 30 | return self._endpoint 31 | 32 | @endpoint.setter 33 | def endpoint(self, value): 34 | self._endpoint = value 35 | 36 | async def request(self, 37 | session: Union[AsyncSession, CredentialBase], 38 | *, 39 | override_headers: Optional[dict] = None 40 | ) -> SubscriptionResp: 41 | """ 42 | Request to get user subscription information 43 | :param override_headers: 44 | :param session: 45 | :return: 46 | """ 47 | # Data Build 48 | request_data = {} 49 | async with session if isinstance(session, AsyncSession) else await session.get_session() as sess: 50 | # Header 51 | if override_headers: 52 | sess.headers.clear() 53 | sess.headers.update(override_headers) 54 | logger.debug("Subscription") 55 | try: 56 | self.ensure_session_has_get_method(sess) 57 | response = await sess.get( 58 | url=self.base_url, 59 | ) 60 | if "application/json" not in response.headers.get('Content-Type') or response.status_code != 200: 61 | error_message = await self.handle_error_response(response=response, request_data=request_data) 62 | status_code = error_message.get("statusCode", response.status_code) 63 | message = error_message.get("message", "Unknown error") 64 | if status_code in [400, 401, 402]: 65 | # 400 : validation error 66 | # 401 : unauthorized 67 | # 402 : payment required 68 | # 409 : conflict 69 | raise AuthError(message, request=request_data, code=status_code, response=error_message) 70 | if status_code in [500]: 71 | # An unknown error occured. 72 | raise APIError(message, request=request_data, code=status_code, response=error_message) 73 | raise APIError(message, request=request_data, code=status_code, response=error_message) 74 | return SubscriptionResp.model_validate(response.json()) 75 | except curl_cffi.requests.errors.RequestsError as exc: 76 | logger.exception(exc) 77 | raise SessionHttpError("An AsyncSession RequestsError occurred, maybe SSL error. Try again later!") 78 | except httpx.HTTPError as exc: 79 | logger.exception(exc) 80 | raise SessionHttpError("An HTTPError occurred, maybe SSL error. Try again later!") 81 | except APIError as e: 82 | raise e 83 | except Exception as e: 84 | logger.opt(exception=e).exception("An Unexpected error occurred") 85 | raise e 86 | -------------------------------------------------------------------------------- /src/novelai_python/tokenizer/clip_simple_tokenizer.py: -------------------------------------------------------------------------------- 1 | # MIT:https://github.com/openai/CLIP 2 | import html 3 | import os 4 | from dataclasses import dataclass 5 | from functools import lru_cache 6 | from typing import List 7 | 8 | import ftfy 9 | import regex as re 10 | 11 | 12 | @dataclass 13 | class EncodingResult: 14 | ids: List[int] 15 | tokens: List[str] 16 | 17 | 18 | @lru_cache() 19 | def default_bpe(): 20 | return os.path.join(os.path.dirname(os.path.abspath(__file__)), "bpe_simple_vocab_16e6.txt.gz") 21 | 22 | 23 | @lru_cache() 24 | def bytes_to_unicode(): 25 | """ 26 | Returns list of utf-8 byte and a corresponding list of unicode strings. 27 | The reversible bpe codes work on unicode strings. 28 | This means you need a large # of unicode characters in your vocab if you want to avoid UNKs. 29 | When you're at something like a 10B token dataset you end up needing around 5K for decent coverage. 30 | This is a signficant percentage of your normal, say, 32K bpe vocab. 31 | To avoid that, we want lookup tables between utf-8 bytes and unicode strings. 32 | And avoids mapping to whitespace/control characters the bpe code barfs on. 33 | """ 34 | bs = list(range(ord("!"), ord("~") + 1)) + list(range(ord("¡"), ord("¬") + 1)) + list(range(ord("®"), ord("ÿ") + 1)) 35 | cs = bs[:] 36 | n = 0 37 | for b in range(2 ** 8): 38 | if b not in bs: 39 | bs.append(b) 40 | cs.append(2 ** 8 + n) 41 | n += 1 42 | cs = [chr(n) for n in cs] 43 | return dict(zip(bs, cs)) 44 | 45 | 46 | def get_pairs(word): 47 | """Return set of symbol pairs in a word. 48 | Word is represented as tuple of symbols (symbols being variable-length strings). 49 | """ 50 | pairs = set() 51 | prev_char = word[0] 52 | for char in word[1:]: 53 | pairs.add((prev_char, char)) 54 | prev_char = char 55 | return pairs 56 | 57 | 58 | def basic_clean(text): 59 | text = ftfy.fix_text(text) 60 | text = html.unescape(html.unescape(text)) 61 | return text.strip() 62 | 63 | 64 | def whitespace_clean(text): 65 | text = re.sub(r'\s+', ' ', text) 66 | text = text.strip() 67 | return text 68 | 69 | 70 | class SimpleTokenizer(object): 71 | def __init__(self, bpe_model_content: str = default_bpe()): 72 | self.byte_encoder = bytes_to_unicode() 73 | self.byte_decoder = {v: k for k, v in self.byte_encoder.items()} 74 | # merges = gzip.open(bpe_path).read().decode("utf-8").split("\n") 75 | merges = bpe_model_content.split('\n') 76 | merges = merges[1:49152 - 256 - 2 + 1] 77 | merges = [tuple(merge.split()) for merge in merges] 78 | vocab = list(bytes_to_unicode().values()) 79 | vocab = vocab + [v + '' for v in vocab] 80 | for merge in merges: 81 | vocab.append(''.join(merge)) 82 | vocab.extend(['<|startoftext|>', '<|endoftext|>']) 83 | self.encoder = dict(zip(vocab, range(len(vocab)))) 84 | self.decoder = {v: k for k, v in self.encoder.items()} 85 | self.bpe_ranks = dict(zip(merges, range(len(merges)))) 86 | self.cache = {'<|startoftext|>': '<|startoftext|>', '<|endoftext|>': '<|endoftext|>'} 87 | self.pat = re.compile( 88 | r"""<\|startoftext\|>|<\|endoftext\|>|'s|'t|'re|'ve|'m|'ll|'d|[\p{L}]+|[\p{N}]|[^\s\p{L}\p{N}]+""", 89 | re.IGNORECASE) 90 | 91 | def bpe(self, token): 92 | if token in self.cache: 93 | return self.cache[token] 94 | word = tuple(token[:-1]) + (token[-1] + '',) 95 | pairs = get_pairs(word) 96 | 97 | if not pairs: 98 | return token + '' 99 | 100 | while True: 101 | bigram = min(pairs, key=lambda pair: self.bpe_ranks.get(pair, float('inf'))) 102 | if bigram not in self.bpe_ranks: 103 | break 104 | first, second = bigram 105 | new_word = [] 106 | i = 0 107 | while i < len(word): 108 | # noinspection PyBroadException 109 | try: 110 | j = word.index(first, i) 111 | new_word.extend(word[i:j]) 112 | i = j 113 | except Exception: 114 | new_word.extend(word[i:]) 115 | break 116 | 117 | if word[i] == first and i < len(word) - 1 and word[i + 1] == second: 118 | new_word.append(first + second) 119 | i += 2 120 | else: 121 | new_word.append(word[i]) 122 | i += 1 123 | new_word = tuple(new_word) 124 | word = new_word 125 | if len(word) == 1: 126 | break 127 | else: 128 | pairs = get_pairs(word) 129 | word = ' '.join(word) 130 | self.cache[token] = word 131 | return word 132 | 133 | def encode(self, text): 134 | bpe_tokens = [] 135 | ids = [] 136 | text = whitespace_clean(basic_clean(text)).lower() 137 | for token in re.findall(self.pat, text): 138 | token = ''.join(self.byte_encoder[b] for b in token.encode('utf-8')) 139 | bpe_result = self.bpe(token).split(' ') 140 | bpe_tokens.extend(bpe_result) 141 | ids.extend(self.encoder[bpe_token] for bpe_token in bpe_result) 142 | return EncodingResult(ids=ids, tokens=[token.replace('', ' ') for token in bpe_tokens]) 143 | 144 | def decode(self, tokens): 145 | text = ''.join([self.decoder[token] for token in tokens]) 146 | text = bytearray([self.byte_decoder[c] for c in text]).decode('utf-8', errors="replace").replace('', ' ') 147 | return text 148 | 149 | def get_vocab(self): 150 | return self.encoder 151 | 152 | 153 | if __name__ == '__main__': 154 | tokenizer = SimpleTokenizer() 155 | print(len(tokenizer.get_vocab())) 156 | -------------------------------------------------------------------------------- /src/novelai_python/tokenizer/nerdstash_v1.model: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/src/novelai_python/tokenizer/nerdstash_v1.model -------------------------------------------------------------------------------- /src/novelai_python/tokenizer/nerdstash_v2.model: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/src/novelai_python/tokenizer/nerdstash_v2.model -------------------------------------------------------------------------------- /src/novelai_python/tokenizer/novelai.model: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/src/novelai_python/tokenizer/novelai.model -------------------------------------------------------------------------------- /src/novelai_python/tokenizer/novelai_v2.model: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/src/novelai_python/tokenizer/novelai_v2.model -------------------------------------------------------------------------------- /src/novelai_python/tool/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /src/novelai_python/tool/image_metadata/lsb_extractor.py: -------------------------------------------------------------------------------- 1 | # MIT: https://github.com/NovelAI/novelai-image-metadata/blob/main/nai_meta.py 2 | import gzip 3 | import json 4 | 5 | import numpy as np 6 | from PIL import Image 7 | 8 | 9 | class LSBExtractor(object): 10 | def __init__(self, data): 11 | self.data = data 12 | self.rows, self.cols, self.dim = data.shape 13 | self.bits = 0 14 | self.byte = 0 15 | self.row = 0 16 | self.col = 0 17 | 18 | def _extract_next_bit(self): 19 | """ 20 | Extract the next bit from the pixel's least significant bit (LSB). 21 | Returns `True` if a bit was successfully extracted, `False` 22 | if we have reached the end of the data. 23 | """ 24 | if self.row < self.rows and self.col < self.cols: 25 | bit = self.data[self.row, self.col, self.dim - 1] & 1 26 | self.bits += 1 27 | self.byte <<= 1 28 | self.byte |= bit 29 | self.row += 1 30 | if self.row == self.rows: 31 | self.row = 0 32 | self.col += 1 33 | return True 34 | return False 35 | 36 | def get_one_byte(self): 37 | """ 38 | Extract one byte (8 bits) from the image data using LSB. 39 | Returns the byte if successfully extracted, otherwise `None` if the data ends prematurely. 40 | """ 41 | while self.bits < 8: 42 | if not self._extract_next_bit(): 43 | # If we run out of data before completing a byte, return None 44 | if self.bits == 0: 45 | return None 46 | 47 | # If we have partial bits, pad remaining bits with 0s and return 48 | self.byte <<= (8 - self.bits) 49 | padded_byte = bytearray([self.byte]) 50 | self.bits = 0 51 | self.byte = 0 52 | return padded_byte 53 | 54 | byte = bytearray([self.byte]) 55 | self.bits = 0 56 | self.byte = 0 57 | return byte 58 | 59 | def get_next_n_bytes(self, n): 60 | """ 61 | Extract the next `n` bytes sequentially from the image data. 62 | Stops if not enough data is available. 63 | """ 64 | bytes_list = bytearray() 65 | for _ in range(n): 66 | byte = self.get_one_byte() 67 | if byte is None: # Stop if we run out of data 68 | break 69 | bytes_list.extend(byte) 70 | return bytes_list 71 | 72 | def read_32bit_integer(self): 73 | """ 74 | Attempt to read a 32-bit integer (4 bytes). 75 | Returns the integer value if successfully extracted, otherwise `None` 76 | if insufficient data is available. 77 | """ 78 | bytes_list = self.get_next_n_bytes(4) 79 | if len(bytes_list) == 4: 80 | integer_value = int.from_bytes(bytes_list, byteorder='big') 81 | return integer_value 82 | else: 83 | return None 84 | 85 | 86 | class ImageLsbDataExtractor(object): 87 | def __init__(self): 88 | self.magic = "stealth_pngcomp" 89 | 90 | def extract_data(self, image: Image.Image, get_fec: bool = False) -> tuple: 91 | """ 92 | Get the data from the image 93 | :param image: Pillow Image object 94 | :param get_fec: bool 95 | :return: json_data, fec_data 96 | """ 97 | image = np.array(image.copy().convert("RGBA")) 98 | try: 99 | if not (image.shape[-1] == 4 and len(image.shape) == 3): 100 | raise AssertionError('image format error, maybe image already be modified') 101 | reader = LSBExtractor(image) 102 | read_magic = reader.get_next_n_bytes(len(self.magic)).decode("utf-8") 103 | if not (self.magic == read_magic): 104 | raise AssertionError('magic number mismatch') 105 | read_len = reader.read_32bit_integer() // 8 106 | json_data = reader.get_next_n_bytes(read_len) 107 | json_data = json.loads(gzip.decompress(json_data).decode("utf-8")) 108 | if "Comment" in json_data: 109 | json_data["Comment"] = json.loads(json_data["Comment"]) 110 | 111 | if not get_fec: 112 | return json_data, None 113 | 114 | fec_len = reader.read_32bit_integer() 115 | fec_data = None 116 | if fec_len != 0xffffffff: 117 | fec_data = reader.get_next_n_bytes(fec_len // 8) 118 | 119 | return json_data, fec_data 120 | except FileNotFoundError: 121 | # 无法找到文件 122 | raise Exception(f"The file {image} does not exist.") 123 | except json.JSONDecodeError as e: 124 | # 无法解析JSON数据 125 | raise Exception(f"Failed to decode JSON data from image: {image}. Error: {str(e)}") 126 | except AssertionError as err: 127 | # 魔数不匹配 128 | raise Exception(f"Failed to extract data from image: {image}. Error: {str(err)}") 129 | except Exception as e: 130 | # 从图像中提取数据时发生意外错误 131 | raise Exception(f"Unexpected error happened when extracting data from image: {image}. Error: {str(e)}") 132 | -------------------------------------------------------------------------------- /src/novelai_python/tool/image_metadata/lsb_injector.py: -------------------------------------------------------------------------------- 1 | # MIT: https://github.com/NovelAI/novelai-image-metadata/blob/main/nai_meta_writer.py 2 | import json 3 | import gzip 4 | import numpy as np 5 | from PIL import Image 6 | from PIL.PngImagePlugin import PngInfo 7 | 8 | 9 | class LSBInjector: 10 | def __init__(self, data): 11 | self.data = data 12 | self.rows, self.cols, self.dim = data.shape 13 | self.bits = 0 14 | self.byte = 0 15 | self.row = 0 16 | self.col = 0 17 | 18 | def put_byte(self, byte): 19 | for i in range(8): 20 | bit = (byte & 0x80) >> 7 21 | self.data[self.row, self.col, self.dim - 1] &= 0xfe 22 | self.data[self.row, self.col, self.dim - 1] |= bit 23 | self.row += 1 24 | if self.row == self.rows: 25 | self.row = 0 26 | self.col += 1 27 | assert self.col < self.cols 28 | byte <<= 1 29 | 30 | def put_32bit_integer(self, integer_value): 31 | bytes_list = integer_value.to_bytes(4, byteorder='big') 32 | for byte in bytes_list: 33 | self.put_byte(byte) 34 | 35 | def put_bytes(self, bytes_list): 36 | for byte in bytes_list: 37 | self.put_byte(byte) 38 | 39 | def put_string(self, string): 40 | self.put_bytes(string.encode('utf-8')) 41 | 42 | 43 | def serialize_metadata(metadata: PngInfo) -> bytes: 44 | # Extract metadata from PNG chunks 45 | data = { 46 | k: v 47 | for k, v in [ 48 | data[1] 49 | .decode("latin-1" if data[0] == b"tEXt" else "utf-8") 50 | .split("\x00" if data[0] == b"tEXt" else "\x00\x00\x00\x00\x00") 51 | for data in metadata.chunks 52 | if data[0] == b"tEXt" or data[0] == b"iTXt" 53 | ] 54 | } 55 | # Save space by getting rid of reduntant metadata (Title is static) 56 | if "Title" in data: 57 | del data["Title"] 58 | # Encode and compress data using gzip 59 | data_encoded = json.dumps(data) 60 | return gzip.compress(bytes(data_encoded, "utf-8")) 61 | 62 | 63 | def inject_data(image: Image.Image, data: PngInfo) -> Image.Image: 64 | image = image.convert('RGBA') 65 | pixels = np.array(image) 66 | injector = LSBInjector(pixels) 67 | injector.put_string("stealth_pngcomp") 68 | data = serialize_metadata(data) 69 | injector.put_32bit_integer(len(data) * 8) 70 | injector.put_bytes(data) 71 | return Image.fromarray(injector.data) 72 | -------------------------------------------------------------------------------- /src/novelai_python/tool/paint_mask/__init__.py: -------------------------------------------------------------------------------- 1 | import cv2 as cv 2 | import numpy as np 3 | 4 | 5 | def create_mask_from_sketch( 6 | original_img_bytes: bytes, 7 | sketch_img_bytes: bytes, 8 | min_block_size: int = 15, 9 | jagged_edges: bool = True, 10 | output_format: str = '.png' 11 | ) -> bytes: 12 | """ 13 | Function to create a mask from original and sketch images input as bytes. Returns BytesIO object. 14 | 15 | :param original_img_bytes: Bytes corresponding to the original image. 16 | :param sketch_img_bytes: Bytes corresponding to the sketch image. 17 | :param min_block_size: The minimum size of the pixel blocks. Default is 1, i.e., no pixelation. 18 | :param jagged_edges: If set to True, the edges of the resulting mask will be more jagged. 19 | :param output_format: Format of the output image. Defaults to '.png'. It could also be '.jpg' 20 | :return: bytes corresponding to the resultant mask 21 | """ 22 | 23 | # Load images 24 | ori_img = cv.imdecode(np.frombuffer(original_img_bytes, np.uint8), cv.IMREAD_COLOR) 25 | sketch_img = cv.imdecode(np.frombuffer(sketch_img_bytes, np.uint8), cv.IMREAD_COLOR) 26 | 27 | # Check if images have the same size 28 | if ori_img.shape != sketch_img.shape: 29 | raise ValueError("Images must have the same size.") 30 | 31 | # Calculate difference between the original and sketch images 32 | diff_img = cv.absdiff(ori_img, sketch_img) 33 | 34 | # Convert the difference image to grayscale 35 | diff_gray = cv.cvtColor(diff_img, cv.COLOR_BGR2GRAY) 36 | 37 | # Threshold to create the mask 38 | _, thresh = cv.threshold(diff_gray, 10, 255, cv.THRESH_BINARY) 39 | 40 | # Create bigger kernel for morphological operations 41 | if jagged_edges: 42 | # Use a bigger kernel for dilation to create larger 'step' effect at the edges 43 | kernel = np.ones((7, 7), np.uint8) 44 | else: 45 | kernel = np.ones((3, 3), np.uint8) 46 | 47 | # Perform morphological opening to remove small noise 48 | opening = cv.morphologyEx(thresh, cv.MORPH_OPEN, kernel, iterations=2) 49 | 50 | # Perform morphological dilation 51 | dilation = cv.dilate(opening, kernel, iterations=2) 52 | 53 | # Perform morphological closing to connect separated areas 54 | closing = cv.morphologyEx(dilation, cv.MORPH_CLOSE, kernel, iterations=3) 55 | 56 | # Further remove noise with a Gaussian filter 57 | smooth = cv.GaussianBlur(closing, (5, 5), 0) 58 | 59 | if min_block_size > 1: 60 | # Resize to smaller image, then resize back to original size to create a pixelated effect 61 | small = cv.resize(smooth, (smooth.shape[1] // min_block_size, smooth.shape[0] // min_block_size), 62 | interpolation=cv.INTER_LINEAR) 63 | smooth = cv.resize(small, (smooth.shape[1], smooth.shape[0]), interpolation=cv.INTER_NEAREST) 64 | 65 | if jagged_edges: 66 | # Apply additional thresholding to create sharper, jagged edges 67 | _, smooth = cv.threshold(smooth, 128, 255, cv.THRESH_BINARY) 68 | 69 | # Convert image to BytesIO object 70 | is_success, buffer = cv.imencode(output_format, smooth) 71 | if is_success: 72 | output_io = buffer.tobytes() 73 | else: 74 | raise ValueError("Error during conversion of image to BytesIO object") 75 | 76 | return output_io 77 | -------------------------------------------------------------------------------- /src/novelai_python/tool/random_prompt/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from typing import List, Literal 3 | 4 | from novelai_python.tool.random_prompt.generate_scene_composition import generate_scene_composition, \ 5 | generate_appearance, Conditions 6 | from novelai_python.tool.random_prompt.generate_scene_tags import generate_scene_tags, generate_character_traits 7 | from novelai_python.tool.random_prompt.generate_tags import generate_tags, get_holiday_themed_tags 8 | 9 | 10 | class RandomPromptGenerator(object): 11 | 12 | def __init__(self, **kwargs): 13 | pass 14 | 15 | @staticmethod 16 | def generate_scene_composition() -> List[str]: 17 | return generate_scene_composition() 18 | 19 | @staticmethod 20 | def generate_scene_tags() -> list: 21 | return generate_scene_tags() 22 | 23 | @staticmethod 24 | def generate_common_tags(nsfw: bool = False) -> str: 25 | return generate_tags(nsfw) 26 | 27 | @staticmethod 28 | def get_holiday_themed_tags() -> str: 29 | return get_holiday_themed_tags() 30 | 31 | @staticmethod 32 | def generate_character( 33 | tags: List[str], 34 | gender: Literal['m', 'f', 'o'], 35 | additional_tags: str = None, 36 | character_limit: int = 1 37 | ) -> str: 38 | """ 39 | Generate a character based on the given tags 40 | :param tags: given tags 41 | :param gender: the gender of the character 42 | :param additional_tags: nothing 43 | :param character_limit: num of characters 44 | :return: random character prompt 45 | """ 46 | return generate_appearance( 47 | tags=Conditions(tags=tags), 48 | gender=gender, 49 | additional_tags=additional_tags, 50 | character_limit=character_limit 51 | ) 52 | 53 | @staticmethod 54 | def generate_character_traits( 55 | gender: Literal['m', 'f', 'o'], 56 | portrait_type: Literal[ 57 | "half-length portrait", 58 | "three-quarter length portrait", 59 | "full-length portrait", 60 | ], 61 | level: int 62 | ) -> tuple: 63 | """ 64 | Generate character traits 65 | :param gender: one of 'm', 'f', 'o' 66 | :param portrait_type: one of "half-length portrait", "three-quarter length portrait", "full-length portrait" 67 | :param level: level of generate depth 68 | :return: tags(generated tags), flags(removed categories) 69 | """ 70 | return generate_character_traits( 71 | gender=gender, 72 | portrait_type=portrait_type, 73 | level=level 74 | ) 75 | 76 | 77 | if __name__ == '__main__': 78 | gen = RandomPromptGenerator() 79 | for i in range(1): 80 | s = RandomPromptGenerator() 81 | print(s.generate_common_tags(nsfw=False)) 82 | print(s.generate_scene_tags()) 83 | print(s.generate_scene_composition()) 84 | print(s.get_holiday_themed_tags()) 85 | print(s.generate_character( 86 | tags=["vampire", "werewolf"], 87 | gender="f", 88 | additional_tags="", 89 | character_limit=1, 90 | )) 91 | print(s.generate_character_traits( 92 | gender="f", 93 | portrait_type="half-length portrait", 94 | level=1 95 | )) 96 | -------------------------------------------------------------------------------- /src/novelai_python/utils/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/1/26 上午10:51 3 | # @Author : sudoskys 4 | # @File : __init__.py.py 5 | 6 | import json 7 | from typing import Union 8 | 9 | from loguru import logger 10 | 11 | from .encode import encode_access_key # noqa 401 12 | 13 | 14 | def try_jsonfy(obj: Union[str, dict, list, tuple], default_when_error=None): 15 | """ 16 | try to jsonfy object 17 | :param obj: 18 | :param default_when_error: 19 | :return: 20 | """ 21 | try: 22 | return json.loads(obj) 23 | except Exception as e: 24 | logger.trace(f"Decode Error {obj} {e}") 25 | if default_when_error is None: 26 | return f"Decode Error {type(obj)}" 27 | else: 28 | return default_when_error 29 | -------------------------------------------------------------------------------- /src/novelai_python/utils/encode.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/7 上午11:41 3 | import base64 4 | from base64 import urlsafe_b64encode 5 | from hashlib import blake2b 6 | 7 | import argon2 8 | import numpy as np 9 | 10 | 11 | # https://github.com/HanaokaYuzu/NovelAI-API/blob/master/src/novelai/utils.py#L12 12 | def encode_access_key(username: str, password: str) -> str: 13 | """ 14 | Generate hashed access key from the user's username and password using the blake2 and argon2 algorithms. 15 | :param username: str (plaintext) 16 | :param password: str (plaintext) 17 | :return: str 18 | """ 19 | pre_salt = f"{password[:6]}{username}novelai_data_access_key" 20 | 21 | blake = blake2b(digest_size=16) 22 | blake.update(pre_salt.encode()) 23 | salt = blake.digest() 24 | 25 | raw = argon2.low_level.hash_secret_raw( 26 | secret=password.encode(encoding="utf-8"), 27 | salt=salt, 28 | time_cost=2, 29 | memory_cost=int(2000000 / 1024), 30 | parallelism=1, 31 | hash_len=64, 32 | type=argon2.low_level.Type.ID, 33 | ) 34 | hashed = urlsafe_b64encode(raw).decode() 35 | 36 | return hashed[:64] 37 | 38 | 39 | import hashlib 40 | import hmac 41 | 42 | 43 | def sign_message(message, key): 44 | # 使用 HMAC 算法对消息进行哈希签名 45 | hmac_digest = hmac.new(key.encode(), message.encode(), hashlib.sha256).digest() 46 | signed_hash = base64.b64encode(hmac_digest).decode() 47 | return signed_hash 48 | 49 | 50 | def encode_base64(data): 51 | byte_data = data.encode("UTF-8") 52 | encoded_data = base64.b64encode(byte_data) 53 | return encoded_data.decode("UTF-8") 54 | 55 | 56 | # 解码 57 | def decode_base64(encoded_data): 58 | byte_data = encoded_data.encode('UTF-8') 59 | decoded_data = base64.b64decode(byte_data) 60 | return decoded_data.decode("UTF-8") 61 | 62 | 63 | def b64_to_tokens(encoded_str, dtype='uint32'): 64 | """ 65 | big-endian 66 | 将 Base64 编码的字符串解码为 tokens 数组。 67 | :param encoded_str: 从 Base64 解码的字符串 68 | :param dtype: 解码的数组类型,可以是 'uint32' 或 'uint16' 69 | :return: 解码后的整数数组 70 | """ 71 | # NOTE: 72 | byte_data = base64.b64decode(encoded_str) 73 | if dtype == 'uint32': 74 | array_data = np.frombuffer(byte_data, dtype=np.uint32) 75 | elif dtype == 'uint16': 76 | array_data = np.frombuffer(byte_data, dtype=np.uint16) 77 | else: 78 | raise ValueError('Unsupported dtype') 79 | return array_data.tolist() 80 | 81 | 82 | def tokens_to_b64(tokens, dtype='uint32'): 83 | """ 84 | big-endian 85 | 将给定的 token 数组编码为 Base64 字符串。 86 | :param tokens: 输入的整数数组 87 | :param dtype: 输出的数组类型,可以是 'uint32' 或 'uint16' 88 | :return: base64 编码字符串 89 | """ 90 | # 根据 dtype 确定 numpy 数组数据类型 91 | if dtype == 'uint32': 92 | array_data = np.array(tokens, dtype=np.uint32) 93 | elif dtype == 'uint16': 94 | array_data = np.array(tokens, dtype=np.uint16) 95 | else: 96 | raise ValueError('Unsupported dtype') 97 | byte_data = array_data.tobytes() 98 | base64_str = base64.b64encode(byte_data).decode('utf-8') 99 | return base64_str 100 | -------------------------------------------------------------------------------- /src/novelai_python/utils/useful.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/9 下午10:41 3 | # @Author : sudoskys 4 | # @File : useful.py 5 | 6 | import collections 7 | import random 8 | from typing import List, Union 9 | 10 | 11 | def enum_to_list(enum_): 12 | return list(map(lambda x: x.value, enum_._member_map_.values())) 13 | 14 | 15 | class QueSelect(object): 16 | def __init__(self, data: List[str]): 17 | """ 18 | A queue selector 19 | :param data: 20 | """ 21 | self.data = collections.deque(data) 22 | self.used = collections.deque() 23 | self.users = {} 24 | 25 | def get(self, user_id: Union[int, str]) -> str: 26 | user_id = str(user_id) 27 | if user_id not in self.users: 28 | self.users[user_id] = {'data': self.data.copy(), 'used': collections.deque()} 29 | 30 | user_data = self.users[user_id]['data'] 31 | user_used = self.users[user_id]['used'] 32 | 33 | if len(user_data) == 0: 34 | user_data, user_used = user_used, user_data 35 | # 随机掉 User data 36 | if len(user_data) > 2: 37 | random.shuffle(user_data) 38 | self.users[user_id]['data'] = user_data 39 | self.users[user_id]['used'] = user_used 40 | 41 | selected = user_data.popleft() 42 | user_used.append(selected) 43 | 44 | return selected 45 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlmKira/novelai-python/f68526a8739fde1e2f4573a4f1233f98fd937357/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_generate_voice.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from unittest.mock import AsyncMock 3 | 4 | import pytest 5 | from curl_cffi.requests import AsyncSession 6 | 7 | from src.novelai_python.sdk.ai.generate_voice import VoiceGenerate, VoiceResponse 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_request(): 12 | # Arrange 13 | mock_response = mock.MagicMock() 14 | mock_response.content = b'audio_content' 15 | mock_response.headers = {'Content-Type': 'audio/mpeg'} 16 | mock_response.status_code = 200 17 | mock_response.json = AsyncMock(return_value={"statusCode": 200, "message": "Success"}) 18 | 19 | # 使用 AsyncMock 模拟异步方法 20 | session = mock.MagicMock(spec=AsyncSession) 21 | session.post = AsyncMock(return_value=mock_response) 22 | session.headers = {} 23 | 24 | # Mock '__aenter__' 和 '__aexit__',以兼容异步上下文管理器 25 | session.__aenter__ = AsyncMock(return_value=session) 26 | session.__aexit__ = AsyncMock(return_value=None) 27 | 28 | # 创建 VoiceGenerate 对象 29 | voice_generate = VoiceGenerate( 30 | text="Hello, world!", 31 | voice=-1, 32 | seed="seed", 33 | opus=False, 34 | version="v2" 35 | ) 36 | 37 | # Act 38 | result = await voice_generate.request(session=session, override_headers=None) 39 | 40 | # Assert 41 | session.post.assert_called_once_with( 42 | url=voice_generate.base_url, 43 | json=voice_generate.model_dump(mode="json", exclude_none=True) 44 | ) 45 | assert isinstance(result, VoiceResponse) 46 | assert result.audio == b'audio_content' -------------------------------------------------------------------------------- /tests/test_random_prompt.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/1/27 上午10:41 3 | # @Author : sudoskys 4 | # @File : test_random_prompt.py 5 | 6 | 7 | from novelai_python.tool.random_prompt import RandomPromptGenerator 8 | 9 | 10 | def test_generate_scene_tags(): 11 | generator = RandomPromptGenerator() 12 | result = generator.generate_scene_tags() 13 | assert len(result) > 0 14 | 15 | 16 | def test_generate_scene_composition(): 17 | generator = RandomPromptGenerator() 18 | result1 = generator.generate_scene_composition() 19 | result2 = generator.generate_scene_composition() 20 | assert result1 != result2 21 | 22 | 23 | def test_generate_common_tags_non_nsfw(): 24 | generator = RandomPromptGenerator() 25 | result = generator.generate_common_tags(nsfw=False) 26 | assert 'nsfw' not in result 27 | 28 | 29 | def test_generate_common_tags_nsfw(): 30 | generator = RandomPromptGenerator() 31 | result = generator.generate_common_tags(nsfw=True) 32 | assert 'nsfw' in result 33 | 34 | 35 | def test_generate_character(): 36 | generator = RandomPromptGenerator() 37 | result = generator.generate_character( 38 | tags=["vampire", "werewolf"], 39 | gender="f", 40 | additional_tags="", 41 | character_limit=1, 42 | ) 43 | assert isinstance(result, list) 44 | 45 | 46 | def test_generate_character_traits(): 47 | generator = RandomPromptGenerator() 48 | result = generator.generate_character_traits( 49 | gender="f", 50 | portrait_type="half-length portrait", 51 | level=3 52 | ) 53 | assert len(result) > 0 54 | 55 | 56 | def test_get_holiday_themed_tags(): 57 | generator = RandomPromptGenerator() 58 | result_m = generator.get_holiday_themed_tags() 59 | result_f = generator.get_holiday_themed_tags() 60 | result_o = generator.get_holiday_themed_tags() 61 | assert len({result_m, result_f, result_o}) != 1 62 | -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/1/22 下午11:50 3 | # @Author : sudoskys 4 | # @File : test_server.py 5 | 6 | 7 | from novelai_python import GenerateImageInfer 8 | from novelai_python.sdk.ai.generate_image import Model 9 | 10 | 11 | def test_nai(): 12 | try: 13 | gen = GenerateImageInfer.build_generate( 14 | prompt="1girl", 15 | steps=29, 16 | model=Model.NAI_DIFFUSION_3, 17 | ) 18 | gen.validate_charge() 19 | except Exception as e: 20 | print(e) 21 | assert 1 == 1, e 22 | else: 23 | assert 1 == 2, "should raise error" 24 | -------------------------------------------------------------------------------- /tests/test_server_run.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/1/30 下午11:52 3 | # @Author : sudoskys 4 | # @File : test_server_run.py 5 | 6 | 7 | from unittest.mock import patch 8 | 9 | from fastapi.testclient import TestClient 10 | 11 | from src.novelai_python.server import app, get_session 12 | 13 | client = TestClient(app) 14 | 15 | 16 | def test_health_check(): 17 | response = client.get("/health") 18 | assert response.status_code == 200 19 | assert response.json() == {"status": "ok"} 20 | 21 | 22 | @patch('src.novelai_python.server.Subscription') 23 | def test_subscription_without_api_key(mock_subscription): 24 | mock_subscription.return_value.request.return_value.model_dump.return_value = {"status": "subscribed"} 25 | response = client.get("/user/subscription") 26 | assert response.status_code == 403 27 | 28 | 29 | @patch('src.novelai_python.server.GenerateImageInfer') 30 | def test_generate_image_without_api_key(mock_generate_image): 31 | mock_generate_image.return_value.request.return_value = {"status": "image generated"} 32 | response = client.post("/ai/generate-image") 33 | assert response.status_code == 403 34 | 35 | 36 | def test_get_session_new_token(): 37 | token = "new_token" 38 | session = get_session(token) 39 | assert session.jwt_token.get_secret_value() == token 40 | 41 | 42 | def test_get_session_existing_token(): 43 | token = "existing_token" 44 | get_session(token) 45 | session = get_session(token) 46 | assert session.jwt_token.get_secret_value() == token 47 | -------------------------------------------------------------------------------- /tests/test_tokenizer.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from novelai_python._enum import TextTokenizerGroup 4 | from novelai_python.tokenizer import NaiTokenizer 5 | 6 | 7 | class TestNaiTokenizer(unittest.TestCase): 8 | 9 | def setUp(self): 10 | self.text = "the quick brown fox jumps over the lazy dog" 11 | self.tokenizer_groups = [ 12 | TextTokenizerGroup.CLIP, 13 | TextTokenizerGroup.NERDSTASH, 14 | TextTokenizerGroup.LLAMA3, 15 | TextTokenizerGroup.T5, 16 | TextTokenizerGroup.NAI_INLINE, 17 | TextTokenizerGroup.PILE_NAI, 18 | ] 19 | 20 | def test_tokenizer_encode_decode(self): 21 | for group in self.tokenizer_groups: 22 | with self.subTest(group=group): 23 | tokenizer = NaiTokenizer(group) 24 | encoded_tokens = tokenizer.encode(self.text) 25 | decoded_text = tokenizer.decode(encoded_tokens) 26 | self.assertIsInstance(encoded_tokens, list) 27 | self.assertIsInstance(decoded_text, str) 28 | self.assertEqual(decoded_text.strip(), self.text) 29 | 30 | 31 | if __name__ == '__main__': 32 | unittest.main() 33 | -------------------------------------------------------------------------------- /tests/test_upscale.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/13 下午12:03 3 | # @Author : sudoskys 4 | # @File : test_upscale.py 5 | 6 | # -*- coding: utf-8 -*- 7 | # @Time : 2024/2/14 下午4:20 8 | # @Author : sudoskys 9 | # @File : test_upscale.py 10 | 11 | from unittest import mock 12 | from unittest.mock import AsyncMock, Mock 13 | 14 | import pytest 15 | from curl_cffi.requests import AsyncSession 16 | 17 | from novelai_python import APIError, Upscale, AuthError 18 | 19 | 20 | @pytest.mark.asyncio 21 | async def test_validation_error_during_upscale(): 22 | validation_error_response = mock.Mock() 23 | validation_error_response.headers = {"Content-Type": "application/json"} 24 | validation_error_response.status_code = 400 25 | validation_error_response.json = Mock(return_value={ 26 | "statusCode": 400, 27 | "message": "A validation error occurred." 28 | }) 29 | session = mock.MagicMock(spec=AsyncSession) 30 | session.post = mock.AsyncMock(return_value=validation_error_response) 31 | session.headers = {} 32 | 33 | session.__aenter__ = AsyncMock(return_value=session) 34 | session.__aexit__ = AsyncMock(return_value=None) 35 | 36 | upscale = Upscale(image="base64_encoded_image", height=100, width=100) 37 | with pytest.raises(AuthError) as e: 38 | await upscale.request(session=session) 39 | assert e.type is AuthError 40 | expected_message = 'A validation error occurred.' 41 | assert expected_message == str(e.value) 42 | 43 | 44 | @pytest.mark.asyncio 45 | async def test_unauthorized_error_during_upscale(): 46 | unauthorized_error_response = mock.Mock() 47 | unauthorized_error_response.headers = {"Content-Type": "application/json"} 48 | unauthorized_error_response.status_code = 401 49 | unauthorized_error_response.json = Mock(return_value={ 50 | "statusCode": 401, 51 | "message": "Unauthorized." 52 | } 53 | ) 54 | session = mock.MagicMock(spec=AsyncSession) 55 | session.post = mock.AsyncMock(return_value=unauthorized_error_response) 56 | session.headers = {} 57 | session.__aenter__ = AsyncMock(return_value=session) 58 | session.__aexit__ = AsyncMock(return_value=None) 59 | 60 | upscale = Upscale(image="base64_encoded_image", height=100, width=100) 61 | with pytest.raises(APIError) as e: 62 | await upscale.request(session=session) 63 | assert e.type is AuthError 64 | expected_message = 'Unauthorized.' 65 | assert expected_message == str(e.value) 66 | 67 | 68 | @pytest.mark.asyncio 69 | async def test_unknown_error_during_upscale(): 70 | unknown_error_response = mock.Mock() 71 | unknown_error_response.headers = {"Content-Type": "application/json"} 72 | unknown_error_response.status_code = 500 73 | unknown_error_response.json = Mock(return_value={ 74 | "statusCode": 500, 75 | "message": "Unknown error occurred." 76 | } 77 | ) 78 | session = mock.MagicMock(spec=AsyncSession) 79 | session.post = mock.AsyncMock(return_value=unknown_error_response) 80 | session.headers = {} 81 | session.__aenter__ = AsyncMock(return_value=session) 82 | session.__aexit__ = AsyncMock(return_value=None) 83 | 84 | upscale = Upscale(image="base64_encoded_image", height=100, width=100) 85 | with pytest.raises(APIError) as e: 86 | await upscale.request(session=session) 87 | expected_message = 'Unknown error occurred.' 88 | assert expected_message == str(e.value) 89 | -------------------------------------------------------------------------------- /tests/test_user_information.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/8 下午3:17 3 | # @Author : sudoskys 4 | # @File : test_information.py 5 | 6 | from unittest import mock 7 | 8 | import pytest 9 | from curl_cffi.requests import AsyncSession 10 | 11 | from novelai_python import Information, InformationResp, AuthError, APIError 12 | 13 | 14 | def test_endpoint_setter(): 15 | info = Information() 16 | assert info.endpoint == "https://api.novelai.net" 17 | new_endpoint = "https://api.testai.net" 18 | info.endpoint = new_endpoint 19 | assert info.endpoint == new_endpoint 20 | 21 | 22 | @pytest.mark.asyncio 23 | async def test_request_method_successful(): 24 | successful_response = mock.Mock() 25 | successful_response.headers = {"Content-Type": "application/json"} 26 | successful_response.status_code = 200 27 | successful_response.json = mock.Mock(return_value={ 28 | "emailVerified": True, 29 | "emailVerificationLetterSent": True, 30 | "trialActivated": True, 31 | "trialActionsLeft": 0, 32 | "trialImagesLeft": 0, 33 | "accountCreatedAt": 0 34 | }) 35 | session = mock.MagicMock(spec=AsyncSession) 36 | session.get = mock.AsyncMock(return_value=successful_response) 37 | session.headers = {} 38 | 39 | session.__aenter__ = mock.AsyncMock(return_value=session) 40 | session.__aexit__ = mock.AsyncMock(return_value=None) 41 | 42 | info = Information() 43 | resp = await info.request(session) 44 | assert isinstance(resp, InformationResp) 45 | 46 | 47 | @pytest.mark.asyncio 48 | async def test_request_method_unauthorized_error(): 49 | auth_response = mock.Mock() 50 | auth_response.headers = {"Content-Type": "application/json"} 51 | auth_response.status_code = 401 52 | auth_response.json = mock.Mock( 53 | return_value={"statusCode": 401, "message": "Access Token is incorrect."} 54 | ) 55 | 56 | session = mock.MagicMock(spec=AsyncSession) 57 | session.headers = {} 58 | session.get = mock.AsyncMock(return_value=auth_response) 59 | 60 | session.__aenter__ = mock.AsyncMock(return_value=session) 61 | session.__aexit__ = mock.AsyncMock(return_value=None) 62 | 63 | info = Information() 64 | with pytest.raises(AuthError) as e: 65 | await info.request(session) 66 | assert e.type is AuthError 67 | expected_message = 'Access Token is incorrect.' 68 | assert expected_message == str(e.value) 69 | 70 | 71 | @pytest.mark.asyncio 72 | async def test_request_method_unknown_error(): 73 | unknown_error_response = mock.Mock() 74 | unknown_error_response.headers = {"Content-Type": "application/json"} 75 | unknown_error_response.status_code = 500 76 | unknown_error_response.json = mock.Mock(return_value={"statusCode": 500, "message": "An unknown error occurred."}) 77 | 78 | session = mock.MagicMock(spec=AsyncSession) 79 | session.headers = {} 80 | session.get = mock.AsyncMock(return_value=unknown_error_response) 81 | 82 | session.__aenter__ = mock.AsyncMock(return_value=session) 83 | session.__aexit__ = mock.AsyncMock(return_value=None) 84 | 85 | info = Information() 86 | with pytest.raises(APIError) as e: 87 | await info.request(session) 88 | expected_message = 'An unknown error occurred.' 89 | assert expected_message == str(e.value) 90 | -------------------------------------------------------------------------------- /tests/test_user_login.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/8 下午4:20 3 | # @Author : sudoskys 4 | # @File : test_login.py 5 | 6 | from unittest import mock 7 | 8 | import pytest 9 | from curl_cffi.requests import AsyncSession 10 | 11 | from novelai_python import APIError, Login, LoginResp 12 | 13 | 14 | @pytest.mark.asyncio 15 | async def test_successful_user_login(): 16 | successful_login_response = mock.Mock() 17 | successful_login_response.headers = {"Content-Type": "application/json"} 18 | successful_login_response.status_code = 201 19 | successful_login_response.json = mock.Mock(return_value={ 20 | "accessToken": "string" 21 | } 22 | ) 23 | session = mock.MagicMock(spec=AsyncSession) 24 | session.post = mock.AsyncMock(return_value=successful_login_response) 25 | session.headers = {} 26 | 27 | session.__aenter__ = mock.AsyncMock(return_value=session) 28 | session.__aexit__ = mock.AsyncMock(return_value=None) 29 | 30 | login = Login(key="encoded_key") 31 | resp = await login.request( 32 | session=session 33 | ) 34 | assert isinstance(resp, LoginResp) 35 | assert resp.accessToken == "string" 36 | 37 | 38 | @pytest.mark.asyncio 39 | async def test_validation_error_during_login(): 40 | validation_error_response = mock.Mock() 41 | validation_error_response.headers = {"Content-Type": "application/json"} 42 | validation_error_response.status_code = 400 43 | validation_error_response.json = mock.Mock(return_value={ 44 | "statusCode": 400, 45 | "message": "A validation error occurred." 46 | } 47 | ) 48 | session = mock.MagicMock(spec=AsyncSession) 49 | session.post = mock.AsyncMock(return_value=validation_error_response) 50 | session.headers = {} 51 | 52 | session.__aenter__ = mock.AsyncMock(return_value=session) 53 | session.__aexit__ = mock.AsyncMock(return_value=None) 54 | 55 | login = Login(key="encoded_key") 56 | with pytest.raises(APIError) as e: 57 | await login.request(session=session) 58 | assert e.type is APIError 59 | expected_message = 'A validation error occurred.' 60 | assert expected_message == str(e.value) 61 | 62 | 63 | @pytest.mark.asyncio 64 | async def test_incorrect_access_key_during_login(): 65 | incorrect_key_response = mock.Mock() 66 | incorrect_key_response.headers = {"Content-Type": "application/json"} 67 | incorrect_key_response.status_code = 401 68 | incorrect_key_response.json = mock.Mock(return_value={ 69 | "statusCode": 401, 70 | "message": "Access Key is incorrect." 71 | } 72 | ) 73 | session = mock.MagicMock(spec=AsyncSession) 74 | session.post = mock.AsyncMock(return_value=incorrect_key_response) 75 | session.headers = {} 76 | 77 | session.__aenter__ = mock.AsyncMock(return_value=session) 78 | session.__aexit__ = mock.AsyncMock(return_value=None) 79 | 80 | login = Login(key="encoded_key") 81 | with pytest.raises(APIError) as e: 82 | await login.request(session=session) 83 | assert e.type is APIError 84 | expected_message = 'Access Key is incorrect.' 85 | assert expected_message == str(e.value) 86 | 87 | 88 | @pytest.mark.asyncio 89 | async def test_unknown_error_during_login(): 90 | unknown_error_response = mock.Mock() 91 | unknown_error_response.headers = {"Content-Type": "application/json"} 92 | unknown_error_response.status_code = 500 93 | unknown_error_response.json = mock.Mock(return_value={ 94 | "statusCode": 500, 95 | "message": "key must be longer than or equal to 64 characters" 96 | } 97 | ) 98 | session = mock.MagicMock(spec=AsyncSession) 99 | session.post = mock.AsyncMock(return_value=unknown_error_response) 100 | session.headers = {} 101 | 102 | session.__aenter__ = mock.AsyncMock(return_value=session) 103 | session.__aexit__ = mock.AsyncMock(return_value=None) 104 | 105 | login = Login(key="encoded_key") 106 | with pytest.raises(APIError) as e: 107 | await login.request( 108 | session=session 109 | ) 110 | expected_message = 'key must be longer than or equal to 64 characters' 111 | assert expected_message == str(e.value) 112 | -------------------------------------------------------------------------------- /tests/test_user_subscription.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/2/8 下午4:31 3 | # @Author : sudoskys 4 | # @File : test_subscription.py 5 | 6 | from unittest import mock 7 | 8 | import pytest 9 | from curl_cffi.requests import AsyncSession 10 | 11 | from novelai_python import APIError, Subscription, SubscriptionResp, AuthError 12 | 13 | 14 | @pytest.mark.asyncio 15 | async def test_successful_subscription_request(): 16 | successful_response = mock.Mock() 17 | successful_response.headers = {"Content-Type": "application/json"} 18 | successful_response.status_code = 200 19 | successful_response.json = mock.Mock(return_value={ 20 | "tier": 3, 21 | "active": True, 22 | "expiresAt": 1708646400, 23 | "perks": { 24 | "maxPriorityActions": 1000, 25 | "startPriority": 10, 26 | "moduleTrainingSteps": 10000, 27 | "unlimitedMaxPriority": True, 28 | "voiceGeneration": True, 29 | "imageGeneration": True, 30 | "unlimitedImageGeneration": True, 31 | "unlimitedImageGenerationLimits": [ 32 | { 33 | "resolution": 4194304, 34 | "maxPrompts": 0 35 | }, 36 | { 37 | "resolution": 1048576, 38 | "maxPrompts": 1 39 | } 40 | ], 41 | "contextTokens": 8192 42 | }, 43 | "paymentProcessorData": None, 44 | "trainingStepsLeft": { 45 | "fixedTrainingStepsLeft": 8825, 46 | "purchasedTrainingSteps": 0 47 | }, 48 | "accountType": 0 49 | } 50 | ) 51 | session = mock.MagicMock(spec=AsyncSession) 52 | session.get = mock.AsyncMock(return_value=successful_response) 53 | session.headers = {} 54 | 55 | session.__aenter__ = mock.AsyncMock(return_value=session) 56 | session.__aexit__ = mock.AsyncMock(return_value=None) 57 | 58 | subscription = Subscription() 59 | resp = await subscription.request(session) 60 | assert isinstance(resp, SubscriptionResp) 61 | 62 | 63 | @pytest.mark.asyncio 64 | async def test_incorrect_access_token_subscription_request(): 65 | incorrect_token_response = mock.Mock() 66 | incorrect_token_response.headers = {"Content-Type": "application/json"} 67 | incorrect_token_response.status_code = 401 68 | incorrect_token_response.json = mock.Mock(return_value={ 69 | "statusCode": 401, 70 | "message": "Access Token is incorrect." 71 | } 72 | ) 73 | session = mock.MagicMock(spec=AsyncSession) 74 | session.get = mock.AsyncMock(return_value=incorrect_token_response) 75 | session.headers = {} 76 | 77 | session.__aenter__ = mock.AsyncMock(return_value=session) 78 | session.__aexit__ = mock.AsyncMock(return_value=None) 79 | 80 | subscription = Subscription() 81 | with pytest.raises(AuthError) as e: 82 | await subscription.request(session) 83 | assert e.type is AuthError 84 | expected_message = 'Access Token is incorrect.' 85 | assert expected_message == str(e.value) 86 | 87 | 88 | @pytest.mark.asyncio 89 | async def test_unknown_error_subscription_request(): 90 | unknown_error_response = mock.Mock() 91 | unknown_error_response.headers = {"Content-Type": "application/json"} 92 | unknown_error_response.status_code = 500 93 | unknown_error_response.json = mock.Mock(return_value={ 94 | "statusCode": 500, 95 | "message": "An unknown error occurred." 96 | } 97 | ) 98 | session = mock.MagicMock(spec=AsyncSession) 99 | session.get = mock.AsyncMock(return_value=unknown_error_response) 100 | session.headers = {} 101 | 102 | session.__aenter__ = mock.AsyncMock(return_value=session) 103 | session.__aexit__ = mock.AsyncMock(return_value=None) 104 | 105 | subscription = Subscription() 106 | with pytest.raises(APIError) as e: 107 | await subscription.request(session) 108 | assert e.type is APIError 109 | expected_message = 'An unknown error occurred.' 110 | assert expected_message == str(e.value) 111 | --------------------------------------------------------------------------------