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),-curatedand their-inpaintingvariants, with the web client's quality presets (standard/light/none) and negative presets. Every model usesparams_version4 (ucPresetId/qualityPresetId) and themultipart/form-databody the web client sends. - Streaming:
async for event in gen.request_stream(session)on/ai/generate-image-streamyields intermediate previews and the final image per sample. - New: character reference (V4.5),
EncodeVibe, transparent background (V5),Upscaleon the image host,UserData,TrialStatus,Priority,CreatePersistentToken, the OpenAI-compatible text API (/oa/v1), andGenerateImageInfer.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,
bchlibandtokenizersare gone (bchlibhas no Python 3.14 wheel; a NumPy BCH codec replaces it.tokenizerscame 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 /metricson the bundled server.RequestPacerkeeps 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
tokenizersis replaced bykitoken.NaiTokenizer.tokenizeris now akitoken.Kitoken;encode,decode,tokenize_textandtotal_tokenskeep 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.LLAMA3points 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-pythonis still>=3.12). - New optional extras:
pip install "novelai-python[metrics]"for Prometheus / VictoriaMetrics metrics;RequestPacerand the local image limiter stay opt-in. - Deprecated:
SubscriptionResp.get_tier_nameand.limit_perksnow raiseDeprecationWarning; usetier_nameanddisabled_perks.
⬆️ Upgrading from 0.7
Some changes do not raise an error, so check these when you upgrade:
-
HTTP(S)_PROXYis ignored. Sessions usetrust_env=False, so a system proxy no longer routes NovelAI traffic by accident. Passproxy="http://user:pass@host:port"to the credential (ApiCredential,LoginCredential, ...) or toLogin; tokenizer downloads and tag suggestions readNOVELAI_PROXY. -
The default model is V5 (
nai-diffusion-5-full) inGenerateImageInferandbuild_generate. -
Images come back as PNG by default (
image_format), with the metadata in text chunks; setparameters.image_format = ImageFormat.WEBPfor smaller files. -
auto_text(V5 only, on by default): quoted text in the prompt is appended as a, teXt: ...block, as novelai.net does. Passauto_text=Falseto send the prompt untouched. -
Image and user endpoints moved to
image.novelai.net;api.novelai.netrejects 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")(seeACCEPT_LANGUAGES) or a full header value. -
The image budget is off by default. Pass
limiter=Truetorequest()/request_stream()for the process-wide budget (NOVELAI_DAILY_IMAGE_LIMIT, default 2500 per 24h, saved to~/.novelai_python/image_limit.json), or aDailyImageLimiterof 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-imagetakes the image as the request body (application/octet-stream); the other fields stay query parameters. SendAuthorization: Bearer <token>or the bare token. -
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
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)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| novelai_python-0.9.0.tar.gz | 1.6 MB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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.