Skip to main content

banner


PyPI version Downloads

Declaration (2025/06/04)

I have nothing to do with NovelAI.

Due to various considerations, such as NovelAI's confusing project architecture and "can't learn anything from it", this project will be in a non-active maintenance state. If you have any questions, please raise an issue.

This project is designed to be a sample project for me to write Python SDK. It turns out that Pydantic is very powerful.

I will invest more time in open source projects that are more beneficial to society.

✨ NovelAI api python sdk with Pydantic, modern and user-friendly.

The goal of this repository is to use Pydantic to build legitimate requests to access the NovelAI API service.

Python >= 3.12 is required (tested on 3.12 – 3.15).

📰 News

  • NovelAI Diffusion V5 family: nai-diffusion-5-full, -full-medium (fast, 14 steps), -curated and their -inpainting variants, with the web client's quality presets (standard / light / none) and negative presets. Every model uses params_version 4 (ucPresetId / qualityPresetId) and the multipart/form-data body the web client sends.
  • Streaming: async for event in gen.request_stream(session) on /ai/generate-image-stream yields intermediate previews and the final image per sample.
  • New: character reference (V4.5), EncodeVibe, transparent background (V5), Upscale on the image host, UserData, TrialStatus, Priority, CreatePersistentToken, the OpenAI-compatible text API (/oa/v1), and GenerateImageInfer.from_metadata() to rebuild a request from an image's metadata.
  • Fewer dependencies: pydantic, curl_cffi, msgpack, Pillow, numpy, pynacl and kitoken. httpx, opencv, argon2-cffi, ftfy/regex, bchlib and tokenizers are gone (bchlib has no Python 3.14 wheel; a NumPy BCH codec replaces it. tokenizers came with 14 more packages, huggingface-hub, httpx2 and PyYAML among them, and PyYAML has no Python 3.15 wheel).
  • Optional local image budget (limiter=True, see below).
  • Optional metrics (pip install "novelai-python[metrics]"): every request counted and timed, Anlas and the Opus usage bar as gauges, GET /metrics on the bundled server. RequestPacer keeps a random gap between requests counted from the end of the previous one; retries after HTTP 500 back off with jitter.

⬆️ Upgrading from 0.8

  • tokenizers is replaced by kitoken. NaiTokenizer.tokenizer is now a kitoken.Kitoken; encode, decode, tokenize_text and total_tokens keep their signatures. CLIP and T5 keep their 0.8.1 ids; the BPE tokenizers now follow novelai.net, so some ids change (see below).
  • Erato uses llama3nai_tokenizer.def, the file novelai.net loads for it (128257 tokens, NovelAI's own special tokens). TextTokenizerGroup.LLAMA3 points there; NaiTokenizer("llama3_tokenizer") still loads the stock Llama 3 file if you need the old ids.
  • The BPE tokenizers follow the site: nerdstash keeps newlines, tabs and digits; special tokens match longest-first. Known differences remain for some genji Japanese text and one pile whitespace case.
  • Python 3.15 is supported (requires-python is still >=3.12).
  • New optional extras: pip install "novelai-python[metrics]" for Prometheus / VictoriaMetrics metrics; RequestPacer and the local image limiter stay opt-in.
  • Deprecated: SubscriptionResp.get_tier_name and .limit_perks now raise DeprecationWarning; use tier_name and disabled_perks.

⬆️ Upgrading from 0.7

Some changes do not raise an error, so check these when you upgrade:

  • HTTP(S)_PROXY is ignored. Sessions use trust_env=False, so a system proxy no longer routes NovelAI traffic by accident. Pass proxy="http://user:pass@host:port" to the credential (ApiCredential, LoginCredential, ...) or to Login; tokenizer downloads and tag suggestions read NOVELAI_PROXY.

  • The default model is V5 (nai-diffusion-5-full) in GenerateImageInfer and build_generate.

  • Images come back as PNG by default (image_format), with the metadata in text chunks; set parameters.image_format = ImageFormat.WEBP for smaller files.

  • auto_text (V5 only, on by default): quoted text in the prompt is appended as a , teXt: ... block, as novelai.net does. Pass auto_text=False to send the prompt untouched.

  • Image and user endpoints moved to image.novelai.net; api.novelai.net rejects third-party clients there.

  • Browser identity: requests carry the headers of Chrome 155 on Windows, as the web client sends them. Accept-Language is English (en-US,en;q=0.9) unless the credential says otherwise: ApiCredential(..., accept_language="en-sg") (see ACCEPT_LANGUAGES) or a full header value.

  • The image budget is off by default. Pass limiter=True to request() / request_stream() for the process-wide budget (NOVELAI_DAILY_IMAGE_LIMIT, default 2500 per 24h, saved to ~/.novelai_python/image_limit.json), or a DailyImageLimiter of your own. The budget is shared by all tokens and by every limiter, in any process, that uses the same state file.

  • Server: POST /ai/augment-image takes the image as the request body (application/octet-stream); the other fields stay query parameters. Send Authorization: Bearer <token> or the bare token.

  • New Model. No Restrictions. V4.5 Full Is Here.

  • NovelAI Diffusion V4.5 Curated is Here!

  • Image Generation Model Release — NovelAI Anime Diffusion V4 Curated Preview (EN)

  • Tutorial: Creating Consistent Characters with NovelAI Diffusion Anime [Female]

📦 Usage

pip install -U novelai-python

All API users must adhere to the NovelAI Terms of Service: https://novelai.net/terms.

More examples can be found in the playground directory, read code as documentation.

import asyncio
import os

from pydantic import SecretStr

from novelai_python import GenerateImageInfer, ImageGenerateResp, ApiCredential
from novelai_python.sdk.ai.generate_image import Model, Character, Sampler, UCPreset
from novelai_python.sdk.ai.generate_image.schema import PositionMap

session = ApiCredential(api_token=SecretStr(os.getenv("NOVELAI_JWT")))  # pst-***
# For security reasons, storing user credentials in plaintext is strongly discouraged.

prompt = "2girls, fisheye, closeup, from above"


async def main():
    gen = GenerateImageInfer.build_generate(
        prompt=prompt,
        model=Model.NAI_DIFFUSION_5_FULL,  # or NAI_DIFFUSION_4_5_FULL
        character_prompts=[
            Character(
                prompt="1girl, head tilt, short hair, black hair, grey eyes, small breasts, looking at viewer",
                uc="red hair",
                center=PositionMap.B2
            ),
            Character(
                prompt="1girl, fox ears, fox tail, white hair, white tail, white ears",
                uc="black hair",
                center=PositionMap.D2
            )
        ],
        sampler=Sampler.K_EULER_ANCESTRAL,
        ucPreset=UCPreset.TYPE0,
        # Recommended, using preset negative_prompt depends on selected model
        qualityToggle=True,
        decrisp_mode=False,
        variety_boost=True,
        furry_mode=False,
        # Checkbox in novelai.net
    )
    cost = gen.calculate_cost(is_opus=True)
    print(f"charge: {cost} if you are vip3")
    resp = await gen.request(session=session)  # raises DailyLimitExceeded once the local budget is spent
    resp: ImageGenerateResp
    print(resp.meta)
    print(resp.save("./output"))  # auto-download every returned image

    # streaming previews (V5 family)
    async for event in gen.request_stream(session=session):
        if event.is_final:
            open(f"./output/sample_{event.samp_ix}.webp", "wb").write(event.image)
        elif event.event_type == "intermediate":
            print("preview", event.samp_ix, "step", event.step_ix, len(event.image), "bytes")


loop = asyncio.get_event_loop()
loop.run_until_complete(main())

🔋 Anlas / limit monitoring

from novelai_python import Subscription, UserData, TrialStatus, get_default_limiter

sub = await Subscription().request(session=session)
print(sub.anlas_left, sub.tier_name, sub.is_unlimited_image_generation)

data = await UserData().request(session=session)  # subscription + information in one call
print(data.anlas_left)

trial = await TrialStatus().request(session=session)
print(trial.trialImagesLeft)

# only when requests opt in with limiter=True
print(get_default_limiter().status())  # {'limit': 2500, 'used': ..., 'remaining': ..., 'reset_at': ...}

sub.usage is the Opus V5 usage bar: percent, purchasedPercent (bought extra, 0-300), isNegative, timeUntilNextPercent. Priority().request(session) returns the task priority balance; CreatePersistentToken(overwrite=True).request(jwt_session) creates a pst-... API key.

📈 Metrics (Prometheus / VictoriaMetrics)

pip install "novelai-python[metrics]"

Every request the SDK sends is counted and timed; /user/subscription and /user/data responses also refresh per-account gauges (Anlas, subscription, the Opus usage bar). Nothing is collected without the extra, and NOVELAI_METRICS=0 turns it off with the extra installed.

Metric Type Labels Meaning
novelai_requests_total counter endpoint, model, status requests by outcome: the HTTP status; timeout, dns, proxy, connect, ssl, reset, cancelled or transport for a request without an HTTP answer (from the curl error code); stream_error for an error frame inside a 200 stream
novelai_request_duration_seconds histogram endpoint, model wall time of one request, the proxy included, until the response or the whole stream is read
novelai_requests_in_flight gauge endpoint requests sent and not answered yet
novelai_last_success_timestamp_seconds gauge endpoint when the endpoint last answered with a status below 400
novelai_images_total counter model images received
novelai_stream_frames_total counter model frames of /ai/generate-image-stream, previews included
novelai_retries_total, novelai_retry_wait_seconds counter, histogram endpoint, none retries after HTTP 500 and the backoff slept before them
novelai_pacing_wait_seconds histogram none time a request waited for the gap after the previous one (RequestPacer)
novelai_local_limit_remaining_images gauge none images left in the local DailyImageLimiter, read at scrape time; -1 before one is used
novelai_account_anlas gauge account, kind Anlas left: kind="fixed" (monthly, reset at renewal) and kind="purchased"; their sum is what can be spent
novelai_subscription_tier, _active, _expires_timestamp_seconds gauge account tier (0 Paper, 1 Tablet, 2 Scroll, 3 Opus), 1 while active, when it expires
novelai_opus_usage_ratio, _negative, _refill_ratio_per_second gauge account the Opus V5 usage bar (0.42 = 42 %), 1 while it is below zero and V5 costs Anlas, how fast it refills
novelai_opus_images_left gauge account estimated free V5 images the usage bar still covers
novelai_account_updated_timestamp_seconds gauge account when the account gauges were last refreshed
novelai_sdk_info info version, impersonate SDK version and the browser whose TLS fingerprint it uses

The names follow the Prometheus naming conventions that VictoriaMetrics recommends: base units (seconds, ratios), _total on counters, _timestamp_seconds on timestamps, and one metric with a label where the sum means something (sum by (account) (novelai_account_anlas)). Every label has a small fixed set of values; prompts, seeds, job ids and emails never become label values. The account label is credential.metrics_label when set, otherwise the first 8 hex digits of a SHA-256 of the username or token (a bare AsyncSession is session), and at most 50 accounts are tracked per process (NovelAIMetrics(max_accounts=...)). A request that never went out, because the login or the local image budget refused it, is not counted.

The metrics live in a registry of the SDK's own, so nothing changes on your application's /metrics unless you ask. That registry leaves out the *_created series client_python adds to every counter and histogram: they double the series count and VictoriaMetrics does not use them (NovelAIMetrics(created_series=True) keeps them). Expose it for a scrape, which is the better default, or push from a short-lived script:

import prometheus_client
from novelai_python import NovelAIMetrics, get_metrics, set_metrics

metrics = get_metrics()
app.add_route("/metrics", metrics.asgi_app())  # FastAPI / Starlette: gzip, OpenMetrics, ?name[]= filters
metrics.start_http_server(9108, addr="10.0.0.5")  # a port of its own; certfile= / keyfile= for HTTPS
metrics.write_textfile("/var/lib/node_exporter/novelai.prom")  # the textfile collector
metrics.push_to_victoriametrics("http://victoria:8428", extra_labels={"job": "nai", "instance": "worker-1"})

# or show them on the /metrics your application already has (before the first request):
set_metrics(NovelAIMetrics(registry=prometheus_client.REGISTRY))

A push is gzip-compressed and goes to /api/v1/import/prometheus; VictoriaMetrics does not report a bad line in the response, it counts it in its own vm_rows_invalid_total. With several worker processes (gunicorn), use client_python's multiprocess mode (PROMETHEUS_MULTIPROC_DIR): the SDK's gauges say how processes combine (livesum for requests in flight, max for timestamps, mostrecent for account state).

The bundled server (python -m novelai_python.server) serves GET /metrics with the process metrics (process_*, python_info, python_gc_*) included. Set NOVELAI_METRICS_TOKEN to require Authorization: Bearer <token>, or keep it on a private network. A vmagent / Prometheus scrape job for it:

scrape_configs:
  - job_name: novelai-sdk
    scrape_interval: 15s
    static_configs:
      - targets: ["127.0.0.1:7888"]
    authorization:            # only with NOVELAI_METRICS_TOKEN
      type: Bearer
      credentials_file: /etc/vmagent/novelai-sdk.token

A labelled series appears with its first event. Prometheus' rate() misses that first increment (MetricsQL's increase() counts it), so pre-create the label sets you alert on: get_metrics().requests.labels("/ai/generate-image", "nai-diffusion-5-full", "timeout").

MetricsQL / PromQL that pays off:

sum(rate(novelai_requests_total{status!~"2.."}[5m])) by (endpoint, status)          # errors per second, by kind
sum(rate(novelai_requests_total{status=~"timeout|proxy|dns|connect"}[5m]))           # the network, not NovelAI
histogram_quantile(0.95, sum(rate(novelai_request_duration_seconds_bucket[5m])) by (le, model))  # p95 by model
sum by (account) (novelai_account_anlas) < 500                                        # Anlas running out
novelai_opus_usage_negative == 1                                                      # V5 now costs Anlas
time() - novelai_last_success_timestamp_seconds{endpoint="/ai/generate-image"} > 1800 # nothing worked for 30 min
time() - novelai_account_updated_timestamp_seconds > 3600                             # the balance is not polled

⏱️ Pacing: a gap between requests

NovelAI answers 429 Concurrent generation is locked when a request follows the previous one too closely, and back-to-back requests do not look like a person at a keyboard. A RequestPacer keeps a random gap between requests, counted from the end of the previous request: the first request goes out at once, a request after a long pause goes out at once, and only a request that follows another one closely waits for what is left of the gap. Requests through one pacer run one at a time, like one account generates one image at a time.

from novelai_python import RequestPacer, Subscription

pacer = RequestPacer(min_gap=2, max_gap=8)  # seconds; one pacer per account
await gen.request(session, pacer=pacer)  # waits only if the previous request ended < 2-8 s ago
await gen.request(session, pacer=True)  # the process-wide pacer: NOVELAI_REQUEST_GAP="1,10" (default), "0" = off

async with pacer.slot():  # any other request
    await Subscription().request(session)

Pacing is off unless asked for. Retries after HTTP 500 are jittered as well: random(1..3 s) * 2 ** (retry - 1).

🖼️ Transparent background, character reference, vibes

from novelai_python import CharacterReference, EncodeVibe, GenerateImageInfer
from novelai_python.sdk.ai._enum import Model

# V5: "Transparent BG" of the web client (tag before the quality suffix + straight alpha)
gen = GenerateImageInfer.build_generate("1girl, standing", transparent_background=True)

# V4.5: Precise Reference (character / style / costume / character&style / delta)
gen = GenerateImageInfer.build_generate(
    "1girl", model=Model.NAI_DIFFUSION_4_5_FULL,
    character_references=[CharacterReference(image=open("ref.png", "rb").read(), fidelity=0.8)],
)

# V4 / V4.5 vibe transfer takes encoded vibes: encode once, reuse the result
vibe = await EncodeVibe(image=open("vibe.png", "rb").read(), information_extracted=0.7).request(session)
gen = GenerateImageInfer.build_generate(
    "1girl", model=Model.NAI_DIFFUSION_4_5_FULL,
    reference_image_multiple=[vibe.vibe_b64], reference_strength_multiple=[0.6],
    reference_information_extracted_multiple=[0.7],
)

V5 has no vibe transfer and no character reference; it takes up to 32 characters (V4/V4.5: 6).

Environment:

variable default meaning
NOVELAI_DAILY_IMAGE_LIMIT 2500 images per 24h window, 0 disables
NOVELAI_LIMIT_STATE_FILE ~/.novelai_python/image_limit.json where the counter is persisted

📦 LLM

import asyncio
import os

from pydantic import SecretStr

from novelai_python import APIError, LoginCredential
from novelai_python.sdk.ai.generate import TextLLMModel, LLM, get_default_preset, AdvanceLLMSetting
from novelai_python.sdk.ai.generate._enum import get_model_preset

username = os.getenv("NOVELAI_USER", None)
assert username is not None
# credential = JwtCredential(jwt_token=SecretStr(jwt))
login_credential = LoginCredential(
    username=os.getenv("NOVELAI_USER"),
    password=SecretStr(os.getenv("NOVELAI_PASS"))
)


async def chat(prompt: str):
    try:
        model = TextLLMModel.ERATO  # llama3
        parameters = get_default_preset(model).parameters
        agent = LLM.build(
            prompt=prompt,
            model=model,
            # parameters=None,  # Auto Select or get from preset
            parameters=get_model_preset(TextLLMModel.ERATO).get_all_presets()[0].parameters,  # Select from enum preset
            advanced_setting=AdvanceLLMSetting(
                min_length=1,
                max_length=None,  # Auto
            )
        )
        # NOTE:parameter > advanced_setting, which logic in generate/__init__.py
        # If you not pass the parameter, it will use the default preset.
        # So if you want to set the generation params, you should pass your own params.
        # Only if you want to use some params not affect the generation, you can use advanced_setting.
        result = await agent.request(session=login_credential)
    except APIError as e:
        raise Exception(f"Error: {e.message}")
    print(f"Result: \n{result.text}")


loop = asyncio.get_event_loop()
loop.run_until_complete(chat("Hello"))

OpenAI-compatible text API (text.novelai.net/oa/v1), responses in OpenAI format:

from novelai_python import OAIChatCompletion, OAIChatMessage, OAIModels

models = await OAIModels().request(session)
req = OAIChatCompletion(model=models.data[0].id, messages=[OAIChatMessage(role="user", content="Hello")])
print((await req.request(session))["choices"][0]["message"]["content"])
async for chunk in req.stream(session):
    print(chunk["choices"][0]["delta"].get("content", ""), end="")

📦 Random Prompt

from novelai_python.tool.random_prompt import RandomPromptGenerator

generator = RandomPromptGenerator()
for i in range(10):
    print(generator.generate_common_tags(nsfw=False))
    print(generator.generate_scene_tags())
    print(generator.generate_scene_composition())
    print(generator.get_holiday_themed_tags())

📦 Run A Server

pip install "novelai-python[server]"
python3 -m novelai_python.server -h '127.0.0.1' -p 7888

Routes: /health, /limit, /user/*, /ai/* (docs at /docs) and GET /metrics when the metrics extra is installed (NOVELAI_METRICS_TOKEN protects it with a bearer token).

📦 Tokenizer

from novelai_python._enum import get_tokenizer_model, TextLLMModel, TextTokenizerGroup
from novelai_python.tokenizer import NaiTokenizer

# Through llm model name to get the tokenizer
tokenizer_package = NaiTokenizer(get_tokenizer_model(TextLLMModel.ERATO))
# Directly use the tokenizer
clip_tokenizer = NaiTokenizer(TextTokenizerGroup.CLIP)

# Tokenize a text
t_text = "a fox jumped over the lazy dog"
encode_tokens = tokenizer_package.encode(t_text)
print(tokenizer_package.tokenize_text(t_text))
print(f"Tokenized text: {encode_tokens}")
print(tokenizer_package.decode(tokenizer_package.encode(t_text)))

NaiTokenizer.tokenizer is a kitoken.Kitoken (a tokenizers.Tokenizer up to 0.8.1). For the BPE text models the methods above follow the tokenizer in the novelai.net web client; genji still differs on some Japanese text (a token produced by more than one merge) and pile on one whitespace case. decode() leaves out control tokens such as <|endoftext|>.

🔨 Roadmap

  • tool.random_prompt
  • tool.paint_mask
  • tool.image_metadata
  • tokenizer
  • /ai/generate-image
  • /user/subscription
  • /user/login
  • /user/information
  • /ai/upscale
  • /ai/generate-image/suggest-tags
  • /ai/generate-voice
  • /ai/generate-stream
  • /ai/generate
  • /ai/augment-image
  • /ai/annotate-image
  • /ai/classify
  • /ai/generate-prompt

GenerateImageInfer.calculate_cost is correct in most cases, but please request account information to get accurate consumption information.

This repo is maintained by me personally now. If you have any questions, please feel free to open an issue.

🚫 About Nsfw

You might need some solutions for identifying NSFW content and adding a mosaic to prevent operational mishaps.

https://dghs-imgutils.deepghs.org/main/api_doc/detect/nudenet.html

https://dghs-imgutils.deepghs.org/main/api_doc/operate/censor.html

🙏 Acknowledgements

novelai-api

NovelAI-API

Metadata

Release files for novelai-python 0.9.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for novelai-python 0.9.0
File Size Uploaded Provenance
novelai_python-0.9.0.tar.gz 1.6 MB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for novelai-python 0.9.0
File Interpreter ABI Platform
novelai_python-0.9.0-py3-none-any.whl Python 3 none any Details

Total release size: 1.9 MB

Release files / novelai_python-0.9.0.tar.gz

Download URL novelai_python-0.9.0.tar.gz
Size 1.6 MB
Tags Source
SHA-256 checksum
How to use checksums
f9675ce6e6fe2465328a251c72df8d3f0b1c065a6ffbdb2630244e76e6b1b180
BLAKE2b-256 checksum
How to use checksums
d4311677a247ebfe8d0e08f95ed3f7f5d9275f531834b44331aa18b653188a55
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via pdm/2.29.3 CPython/3.12.15 Linux/6.17.0-1022-azure

Release files / novelai_python-0.9.0-py3-none-any.whl

Download URL novelai_python-0.9.0-py3-none-any.whl
Size 274.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ff76e194dccdc11f9ccf9ce55fea0e92c05281543f87e02b1cd2f7923673cb6a
BLAKE2b-256 checksum
How to use checksums
4e39e3f64935f6fc63bf99e340cc710f05731b19ed51b272930737b9082d91f7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via pdm/2.29.3 CPython/3.12.15 Linux/6.17.0-1022-azure

Security for novelai-python 0.9.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.9.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page