Format follows Keep a Changelog; versioning follows Semantic Versioning.
-
The five MCP tools lost their
convilyn_prefix.3.x 4.0.0 convilyn_convertconvertconvilyn_capabilitiescapabilitiesconvilyn_pdfpdfconvilyn_understandunderstandconvilyn_quotaquotaThe host already namespaces every MCP tool by server, so a plugin install exposed
mcp__plugin_convilyn_convilyn__convilyn_convert—convilynthree times in one identifier, the last of which told the model nothing the namespace had not already said.Migration. Anywhere you named a tool — a permission rule, an
allowed-toolslist, a subagenttoolsfield, a hook matcher — dropconvilyn_from the tool segment and leave the host's namespace alone:mcp__plugin_convilyn_convilyn__convilyn_convert -> mcp__plugin_convilyn_convilyn__convert mcp__convilyn__convilyn_convert -> mcp__convilyn__convertNothing else about the tools changed: same inputs, same behaviour, same results. A saved prompt that names a tool needs the one-word edit above; a caller that only uses the Python API or the CLI is unaffected.
Why it was renamed rather than left alone. The previous release argued for keeping the prefix precisely because renaming is breaking. That settles the mechanism — a major version — not the question; a public surface does not get to stay wrong permanently because correcting it costs a major.
Why there is no deprecation window, which is a documented departure from this project's usual policy: a deprecated MCP tool has to stay registered to keep working, so a window would have shipped ten tools for at least one minor release. That doubles a catalogue whose small size is the property worth protecting — every description is re-sent to the model on every turn — and exceeds the description budget this package holds itself to. MCP also has no deprecation channel a client acts on, so the "warning" could only be prose in a description the caller pays for every turn.
docs/STABILITY.mdcarries the carve-out and the full reasoning.
-
convertnow publishes a realoutputSchema, so a host can validate what it returns instead of being told "any object".Annotated[CallToolResult, Envelope]keeps the return type — soisErrorstays ours — and derives the published schema fromEnvelope.It is the only one, and the reason is narrow: every field in
ConvertEnvelopeis declared under the name it is sent under, so the schema the library derives and the payload the tool sends cannot disagree.quotais deliberately excluded —CostEstimatecarries camelCase aliases, and a schema derived from it advertisesestimatedMicroUwhile the payload sendsestimated_micro_u, which every client that validates output schemas rejects.pdfandcapabilitieshave multiple success shapes that a top-level union cannot express through this path, andunderstand'sresultis by construction the caller's own JSON Schema output.A modelled shape converts silent payload drift into a loud failure. That is the point, and it is a new failure mode — worth taking on the one tool whose shape is stable and alias-free, not on the four where it is not.
-
A refused call now sets
isErroron the wire, in addition to the{"ok": false, "error", "hint"}body it already returned. A host keying off the protocol flag previously saw an unbroken run of successes while the model was reading refusals. The body is unchanged — the flag was added, not traded for it.A refusal is what sets it, not merely
ok: false. A batch where one file of twelve failed returnsok: falsewith eleven good rows — a call that did its job, andconvert's contract is explicit that a failed file is a result rather than an exception. Flagging it as a tool execution error told the host the call would have failed when it had not. It also matters more than cosmetically:isErrorexempts a result from output-schema validation, so a rule keyed onokalone would skip validation on exactly the payload most likely to drift. -
pdfoperation: "info"now returns a bounded sample instead of the whole text layer. It used to return everything: a 19-page spec measured 36,660 characters — roughly 9,200 tokens from a single call, and doubled on the wire because the payload rides in both the text block andstructuredContent. The description called it "the cheapest way to learn whether a PDF has a text layer"; it was the most expensive way. Defaults are now 4,000 characters (≈2 pages) from the first 20 pages, both overridable per call withmax_charsandmax_pages.The result says when it clipped —
text_truncated,text_chars,pages_read— and carries ahintnaming the two narrower calls: a page range for part of the document, orconvertfor all of it at zero tokens. Silent truncation would be worse than the original problem: a model cannot tell a short document from a clipped one, and would answer about the part it received. Measured on a 40-page document: 43,629 characters before, 4,000 after.pagesalready accepted"1-5"/"1-3,7,10-12"and was wired end to end — it was simply never mentioned forinfo, so nothing the model reads suggested a 26× cheaper call existed. -
The five MCP tools now declare their behaviour to the host. Every tool carries
annotationsand a human-readabletitle:capabilitiesandquotaare marked read-only,understandandquotaas reaching the network. A client could not previously auto-approve the read-only tools, because nothing said which ones they were. -
pdf'soperationis now a real schemaenum, so the eight valid values are visible before a call instead of only in the error hint. Every tool parameter also carries a description.convert'stostays deliberately open: its value set is what the local machine can write. -
convertnow documents that it returns output PATHS, not the converted text. That is the basis of the batch being free, and it was stated nowhere the model reads; the skill's own wording implied the opposite.
-
The spend-approval prompt showed a price that was not the price. Before uploading your files and charging you,
understandasked for approval with a figure — and the figure was "about $1.00" on every call, for every file, for every account. It came from pricing an empty chat-Builder tool palette:(0 tools × 20 iterations) + (50,000 µU × 20). It never varied with file count, size, page count, schema, tier or balance, and it described an operation you were not running.Three things were wrong at once: the wrong operation (that estimator "knows nothing about which workflow you intend to run"), the wrong unit (insured pre-margin µU, which understates the charge — on one measured run the quote was 120.1 credits against 403–419 actually charged), and no correction afterwards, since the charge is not yet reported back on a finished run.
The prompt now states that the amount is unknown rather than guessing it, and says why. It is not silently omitted: an approval screen with no cost line reads as "free". The blocking network round-trip that fetched the constant is gone with it.
quota's description no longer suggests using it to price a run — it prices a tool palette you pass explicitly, and with no arguments it returns a constant. -
A corrupt PDF told you to install a package you already have.
PdfOperationErrorsubclassesLocalError, so a singleexcept LocalErrorattacheduv add "convilyn[pdf]"to every PDF failure. With pypdf installed, a damaged file returnedStream has ended unexpectedlyalongside advice to install pypdf. Only a genuinely missing dependency carries that hint now. -
convilyn agent installregistered an MCP server the host could not start. Both destinations wrote the bare nameconvilynas the command, and an MCP host spawns its servers with an environment whosePATHneed not contain the directory the package was installed into. The install reported success and the five tools never appeared — a CLI that works in your terminal and nothing at all in your assistant. Measured on Windows 11 with auv tool install: bare namefailed, the same config with an explicitenv.PATHconnected, an absolute pathconnected. Both destinations now carry the absolute path of theconvilynbelonging to the interpreter that ran the install.Two things this does not do. It is not established to be Windows-only — the mechanism (a subprocess
PATHmissing the install directory) is platform-neutral, and POSIX is simply unmeasured. And it does not repair an existing~/.codex/config.toml: that file already declaresconvilyn, so the installer reportsunchangedand leaves the old bare-name table in place. Fix that one by hand, or delete the[mcp_servers.convilyn]section and re-run.
- Raised the
pdfextra'spypdffloor from>=6.10.2to>=6.16.1, closing three GHSAs disclosed against pypdf on 2026-09-01: an infinite loop inTreeObject.insert_childon a crafted PDF (GHSA-jp53-mhqp-8xcg), and two long-runtime/high-memory amplification issues in outline and XForm-object handling (GHSA-23w6-3w8w-8484, GHSA-763m-79hh-57f2). No code in this package changed — resolvingpypdf>=6.16.1(or newer) is the whole fix.
convert.download_to(job, to_dir=...)lands a result in a directory under the name the platform gave it, instead of a name you have to invent. Pass eitherto(a filename) orto_dir(a directory) — one of the two.
-
A conversion that produced a package is no longer written under a name that denies being one. Converting a document containing images to
mdreturns a ZIP — the Markdown plus itsassets/— andclient.convert.download_to(job, to="report.md")
used to write those ZIP bytes into that name without a word. The result was a file whose name guarantees Markdown and whose first two bytes are
PK; any tool that opened it as Markdown got binary. The response saidmimetype='application/zip'the whole time and this method never read it.That call now raises, naming the package and pointing at
to_dir. If you meant to keep the archive, name it.zip.Only an archive is refused. Writing PDF bytes to
report.outputstill works: the bytes are what the name promises and only the extension is unconventional, which is your business. An archive is different in kind — the file is a container holding the thing its name claims to be.
-
goals.understand()described itself as taking several files, and the platform takes one. Its summary line read "understanding of file(s)", its parameter is a list, and nothing anywhere mentioned a limit — so the shape a caller could reasonably infer was one the platform rejects. It now says one file, and says what to do instead: send each file as its own request.The limit is the platform's, and it is being lifted. The count is deliberately not checked in the client, and this is worth saying because the opposite looks like the helpful choice: a copy of a server-side admission rule goes stale the day the server moves, and it goes stale in the direction where this SDK refuses work the platform would have accepted. The refusal you get today is immediate, names the limit, and costs no credits — which is a better answer than a duplicate of the rule that will outlive it.
-
An uploaded
Filewas accepted byconvertand rejected by the goal lane.files.upload()hands you aFile, andconvert.create(file=...)takes that object — so passing it togoals.understand(files=[...])is the obvious next step. It producedTypeError: Object of type File is not JSON serializableraised from inside the HTTP library while encoding the request, naming neither the method, nor the parameter, nor what to pass instead.
Every
files=parameter onclient.goalsnow takes uploadedFileobjects, theirfile_idstrings, or a mix. That isstart(),extract(),understand(),run_interactive()andto_markdown()— the report namedunderstand(), but all of them built the request the same way.Anything else is refused before the request is sent, with a message that names the parameter and the fix rather than one from the JSON encoder two layers down.
The parameters are typed
Sequence[str | File], notlist[str | File]. Alistis invariant, so widening it that way would have rejected every existing caller passing a plainlist[str]— silently for anyone using a type checker, and invisibly for everyone else. -
The
convilyn_quotatool did not tell your assistant that a quote is not a balance. 3.5.0's release notes drew the distinction — "it is not a balance check; the balance isclient.account.get_balance()" — but the tool description, which is the only text the assistant actually reads, contained neither the word "balance" nor the call that answers it.So an assistant was told to use the number to answer an approval, and told nothing about the question it cannot answer. It now says three things: that this is a price and not what the account has left; that the balance is
client.account.get_balance(); and that the figure is pre-margin cost in micro-USD, so converting it to credits understates what you are charged — which fails in the direction that tells someone they can afford a run they cannot. -
CostEstimate's docstring said the SDK "does not wrap it yet". It pointed atPOST /credits/workflow-quotefor affordability and described the absence of a wrapper as a scheduling detail. It is a decision: that route is not on this SDK's published surface, and the public contract argues against the shape it was being used in. Now stated as a decision, with the direction that is actually going somewhere — the charge arriving on the job you ran. -
An interrupted
convilyn setupcould leave you with no key at all — and destroy the one you already had. The credentials file was truncated in place and then written, so a Ctrl-C, a full disk, or a sign-in that failed after that point leftcredentials.jsonempty. The key that had been in it was already gone.That is a worse position than it sounds, because the two sides then disagree: your machine reads the empty file and reports no credential, while the server still holds an active key created under this machine's name — and it will not create a second one under the same name. Nothing in the terminal could settle it.
The file is now written beside itself and renamed into place, which is atomic on every platform this package supports. An interrupted write leaves the previous key exactly as it was, and leaves nothing behind.
-
convilyn agent install --dry-runstill said it had changed one of your files. 3.5.0 fixed this for two of the three destinations. The third —~/.codex/config.toml— reportedappended: C:\Users\you\.codex\config.tomlin the past tense while writing nothing. It is now
would append:.The reason it was missed is worth stating, because it says which machines saw it:
appendedis the word this command uses when the Codex config already exists, so the case that read wrong was every Codex user, and the case that read right was a machine that had never run Codex. Every test written for the 3.5.0 fix started from an empty home directory, so none of them could reach it.The verb list is no longer maintained by hand next to the code that prints it. It is derived from the set of actions the installer can return, and a test fails if the two ever disagree — so a new destination cannot ship a word the dry run does not know how to say.
-
convilyn setup --no-browserannounced that it was opening your browser. The flag worked — no browser was launched — but the two lines printed above the URL both promised a launch:Opening your browser to sign in with google... If it doesn't open automatically, visit this URL:On the headless and SSH sessions the flag exists for, that means waiting for a window that was never coming. It now prints one line that matches what it will do:
Open this URL to sign in with google (this machine will not launch a browser):.The sentence used to be written by the caller while the decision to launch was made somewhere else, which is how the two came apart. Both are now decided in the same place, so the same mistake cannot be made again by the next command that opens a link.
Those lines are also ASCII now. They were the one place a Windows console using cp950 or cp932 — which is most of them in Taiwan, Hong Kong and Japan — rendered an ellipsis as mojibake, in the single line telling a headless user where to sign in.
-
convilyn setupcould only succeed once per machine. The API key it creates was named after your hostname, with no way to change it, and the console refuses a second active key with the same name. So a first run that half-completed — you closed the browser, or the sign-in worked but the save was interrupted — left every later attempt failing withLogin failed: HTTP 409 key_mint_failed: An active key with this name already exists.and nothing you could do from the terminal to get past it.
--forcedid not help: it only skips reusing a key already saved on your machine, then asks for the same name again.Three things change.
convilyn setupnow retries once under a distinct name when the first is taken, so an interrupted run repairs itself. A new--key-nameoption lets you choose the name outright — useful on a shared machine, or when you would rather pick than accept a generated one. And if both names are taken, the error now says so and names the flag, instead of repeating advice you had no way to follow.A name you supply is checked before the browser opens, so a typo costs a message rather than a full sign-in ending in a rejection.
-
convilyn doctorreported a missing API key as a failure. It exited non-zero and printed1 failed, which reads as a broken install — but offline conversion (convilyn local …) needs no account at all, so an install without a key is limited, not broken. It is now a warning, and the command exits 0. -
convilyn doctorcould say your credentials file was fine and your key was missing, in the same run. That check only ever looked at file permissions, never at the contents, so a file holding no usable key still reportedOK. It is now namedCredentials file perms, and a file that exists but yields no key is reported as exactly that — with the path — rather than as "not set".
-
Your AI coding assistant can now use convilyn directly. One command sets it up:
uv tool install "convilyn[all,mcp]" # or: pip install --user "convilyn[all,mcp]" convilyn agent install
After that, an assistant working in your project can convert a
.docx,.pptxor.pdfto Markdown by itself, without you copying text around. The conversion still happens on your machine and still costs nothing.Claude Code and Codex look in different places, so both are written: Claude Code gets a plugin at
~/.claude/skills/convilyn/that loads on the next session with no marketplace and no install step; Codex gets~/.agents/skills/convilyn/SKILL.mdand an[mcp_servers.convilyn]table merged into~/.codex/config.toml.Install it where your editor can find it. The MCP server is started by the editor rather than by your shell, so it has to reach
convilynonPATH, and a project virtualenv is not on the editor'sPATH.convilyn agent installis safe to re-run, supports--dry-run, and merges into your existing config rather than replacing it. If your config is in a shape it cannot safely edit, it says so and changes nothing. -
convilyn mcp serve— speak the Model Context Protocol on stdin/stdout, which is how coding assistants talk to outside tools. It offers five:convilyn_convert,convilyn_capabilitiesandconvilyn_pdf(local and free),convilyn_quota(prices a hosted run and reports your tier — it is not a balance check; the balance isclient.account.get_balance()), andconvilyn_understand(structured extraction — this one spends credits, and its description says so where the assistant will read it).Only those last two need an account. The three local tools work with no
convilyn setupat all.Needs the new
mcpextra:pip install "convilyn[mcp]". It is not part of[all], because[all]is about file formats and this is not. -
A plugin for editors that install plugins from a marketplace. Point yours at
CoreNovus/convilyn-pythonand it gets both the guidance and the five tools. This is the route for handing convilyn to a team; on your own machineconvilyn agent installalready sets Claude Code up directly. -
Guidance that says when not to use convilyn. The skill file tells your assistant to read
.md,.txt,.csvand source files directly, because that is genuinely faster and costs the same nothing. It is there so the tool gets reached for when it actually helps.
-
No API key is written into any assistant config file.
convilyn setupalready stores your key where the CLI finds it, so the MCP setup carries no credentials at all — nothing to leak when a config file gets copied to another machine or pasted into a bug report. -
convilyn apino longer accepts an absolute URL, and that closes a way your API key could leave your machine. Every request this client makes carries your key in anAuthorizationheader, and the underlying HTTP library ignores the configured API host the moment it is handed a fullhttps://…address — soconvilyn api GET https://somewhere-else/…sent your key tosomewhere-else. It now refuses before anything is sent, and tells you to pass a path like/api/v1/jobsinstead.This matters most where the path is not typed by you: the command is documented for AI assistants to use, and the skill this package ships grants an assistant permission to run any
convilyncommand — so the argument could come from a document the assistant was asked to read. Reaching an external address is still supported where it always was, on the download/upload paths, which do not attach your key and already validate the address. -
convilyn_understandonly reads files from the folder your editor opened, and never a credential file. It is the one tool here that sends your files to us, and it used to accept any path that existed — so an assistant that had just read a document telling it to "check~/.ssh/id_rsa" could have uploaded it. It now resolves each path first (so a shortcut cannot point outside the folder) and refuses anything credential-shaped —.env,*.pem, private keys,.npmrc, and this tool's owncredentials.json— even inside your project. -
Nothing is uploaded or charged until you say yes. The tool used to ask your assistant, in writing, to check with you first. That is a request to the same assistant it is trying to restrain. Your editor now shows you a real prompt naming the files and the price before anything leaves your machine, and an assistant cannot answer it for you. If your editor cannot show such a prompt, the tool declines rather than proceeding — nothing is sent, and nothing is billed.
-
convilyn_pdfanswers instead of crashing when an argument is missing. It raised aKeyErrorthat reached your assistant as a stack trace; it now returns which argument it needs, which is something the assistant can act on. -
convilyn agent install --dry-runno longer says it changed your machine. It reportedcreated: …for each destination while writing nothing, so the one command whose entire job is to show you what would happen described it in the past tense. It now sayswould create:. -
A billing link from the server is checked before your browser is opened. When a job stops for want of credits, the response carries a top-up link, and the CLI offered to open it. On Windows that path will open a local file or run a program just as readily as a web page, so the link is now required to be a real
https://address first. It is still always printed, so a legitimate link is never lost.
- The browser page you land on after signing in now explains what was set up: that a key is being created for this machine, that your sign-in session is used once and then discarded, and that the key never passes through the browser. It used to say only "you can close this window".
-
The browser no longer shows "Signed in" when the sign-in was rejected. If the callback failed a security check, the page said it had succeeded while the terminal said it had failed. It now says what went wrong and that nothing was saved.
-
convilyn doctornow checks the credentials file's permissions on Windows too. It used to skip the check entirely there, so it reported nothing either way.On Windows the file is protected by the permissions it inherits from
%APPDATA%, which by default let only you, Administrators and the system read it — the same protection as on macOS and Linux. That default is fine, anddoctornow confirms it rather than staying silent:✓ [OK] Credentials file: ACL grants no broad principal (C:\...\credentials.json)If the file ends up somewhere more permissive — a redirected
%APPDATA%, a network share, a restored backup —doctorsays so and names who can read it, instead of skipping.
-
Sign in with your Convilyn email and password, not only Google or GitHub.
convilyn setup --provider emailIt asks for your email and password in the terminal (the password is not shown as you type) and never opens a browser. Google and GitHub work exactly as before.
-
A welcome message after a successful sign-in, with links to the pages worth reading first: choosing a lane, converting offline, credits and pricing, and managing your API keys.
-
convilyn setupno longer makes you sign in again if you already have a working key. It checks the saved key first and stops there if it works.It checks by using the key, not by looking for the file — a key you revoked from the console leaves the file exactly as it was, and in that case you do need to sign in again. If the key no longer works, it says so and continues to the normal login.
Use
convilyn setup --forceto sign in again anyway — for a shared machine, a rotated key, or a different account.
First stable release of the 3.3 line. It contains everything from 3.3.0b1
plus the two entries below.
-
convilyn setup— sign in from your browser. Run it and it opens your browser, you sign in as usual, and an API key is created and saved for you. Nothing to copy and paste.convilyn setupAdd
--no-browserto print the URL instead of opening one, which is what you want over SSH.Only the API key is written to disk. The login tokens are used once to create that key and then discarded — they are never saved and never logged.
-
A test results page:
docs/MEASURED-2026-08-28.md, linked from both the README and the PyPI page. It reports how well conversion and extraction actually score, on named corpora, together with the known limitations. Every figure comes fromdoc-eval, a separate evaluator you can run yourself.The filename carries the date it was measured, so a later report cannot be confused with this one.
-
convilyn.local: a running header or footer no longer arrives as body text. A page number, a document reference or aCONFIDENTIALmarker is printed on the page without being part of what the page says, and every one of them was being converted into the Markdown alongside the real content.A PDF states none of this, so it is inferred, and all three conditions narrow what is removed: the region has to lie in a band at the very top or bottom of the page, be the first or last in reading order, and be set smaller than the median of what the rest of that page is set in. Where the answer is unclear the text is kept — a repeated page number costs a line, a deleted paragraph cannot be recovered. Measured on a 23-page facing-page textbook: 16 page numbers removed, no body text touched — on the single-column pages only. On a facing-page (2-up) spread, only the left page's header and the right page's footer are ever reachable (the reading-order edge condition is evaluated over the whole flattened region list, so the right page's header and the left page's footer sit at interior indices and cannot be first or last); found in code review after this line was first written, and recorded as a known gap rather than silently left overclaiming.
Only the text goes, and only in a PDF. A logo drawn in the header band is a picture the document contains and PDF still extracts it; the HTML path strips the whole element it was found in, so the same logo inside an HTML
<header>is lost — a known asymmetry, not fixed here. -
convilyn.local: a PDF's own title no longer outranks its sections. Every PDF's title fell to the same outline level as its top-level section headings —#for the title,##for "1. Introduction", but the title's ACTUAL heading level was also##, so the outline read as three siblings rather than a title with sections nested under it. A document with 4 headings and 3 outline levels came back with 2.The size-ratio tier that decides a line's heading level had no tier for a document title at all, despite carrying that intent in its own comment — every line fell into whichever LOWER tier its font size cleared. Added the missing tier at 1.9x body text (not the more obvious 1.8x: that ratio is one of the most common real heading sizes and would have split ordinary section headings apart instead of catching only titles).
-
convilyn.local: a converted CSV no longer opens with a heading made up from the filename. The extractor titled the documentpath.stem, so a file namedexport.csvgained an# exportheading the CSV never contained — and through the hosted lane, where the path is a staging temp file, every conversion began with a fabricated# tmpXXXXXXXX. -
convilyn.local: a single-sheet XLSX no longer opens with its own sheet name as a heading. A workbook nobody bothered to rename its one sheet in — the common case, since Excel's own UI default is "Sheet1" — converted with## Sheet1as the document's first line, reading as if the workbook itself were titled that. A heading exists to navigate BETWEEN sections; a workbook with only one sheet has nothing to navigate between, so its name is dropped regardless of what it is (not just recognisable defaults — sheet COUNT is the signal, not the name). A workbook with 2+ sheets is unaffected: each sheet still gets its own heading, same as before.
- Default
base_urlis nowhttps://api.convilyn.com(washttps://api.convilyn.corenovus.com). The old host keeps serving indefinitely — it is an additive CloudFront alias, not a retirement — so this only changes what a client resolves to when neither a constructor argument norCONVILYN_BASE_URLis set. SetCONVILYN_BASE_URL(or passbase_url=explicitly) to keep using the old host.
-
convilyn.local: a PDF with more than one column no longer comes back with its lines welded together. The extractor treated "sametopcoordinate means same line", which is the same thing as asserting every page has one column. On a facing-page (2-up) scan that premise fails on every line, and the three symptoms it produced were one missing layer, not three bugs.A layout pass now runs in front of every PDF: recursive XY-cut over the word bounding boxes, pure geometry, no new dependency and no model download. A region with no qualifying gap comes back whole — i.e. exactly today's behaviour — so a single-column document cannot be made worse by it.
Measured on a 23-page 2-up textbook: welded two-column lines fell from 541 / 1,630 to 103 / 2,386.
-
A one-row or one-column grid is no longer emitted as a table.
pdfplumberreads decorative rounded label boxes as tables — 26 of 44 on that same file — and the Markdown renderer opens withheader, *body, so a one-row grid renders as a header with nothing under it. Worse, its cells were being cut out of the prose stream, so a single token could arrive split across two cells (| 閩-E | -B1 |) and a sentence could arrive interleaved with another.Rejected grids contribute no bounding box, so their words return to the prose where they belong. Tables on that file: 44 → 17, all of them at least 2x2. Character conservation is unchanged at 99.892% — filtering the false tables dropped no text.
-
Images land where the page drew them, instead of all trailing the page. Coordinates come from
pdfplumber'spage.imagesand the bytes still come frompypdf, joined on the PDF's own XObject resource name. On the reference file 68 of 68 image blocks now have content after them on the same page. -
JPEG 2000 images are no longer delivered as files named
.pngthat no viewer opens. Every unknown suffix used to be reported asimage/png; 34 embedded.jp2files shipped that way. Unknown suffixes now reportapplication/octet-stream, and non-renderable formats are re-encoded to real PNG. Assets whose name matches their format: 31 / 63 → 63 / 63, zero broken links.
account.usage_history()documents the row shape the server actually sends. It returns at most 50 rows, newest first, with no cursor — so receiving exactly 50 means older periods exist and you have not seen them. The rows are run COUNTS for quota metrics; the credits period is never in that set, so no row here reports spend.account.get_balance()is the credits question. Documentation only — no behaviour change.
- The author email published on the PyPI page can now receive mail. It
pointed at
convilyn.corenovus.com, a domain with no MX record, so the one channel a stranger installing this package can see swallowed their mail. Fixed metadata takes effect only on a release, which is one of the reasons this one exists. Support goes tosupport@convilyn.com.
-
account.get_balance()—GET /api/v1/credits/balance, the two-bucket credit balance (period_credits+topup_credits, totalled asbalance_credits). Compare a quote fromaccount.get_quota()againstbalance_credits.account.usage_history()does not answer this and never did: it returns run COUNTS for quota metrics, and the credits period is not in its tracked set, so it carries no balance row at all. Before this, the only balance ack_key could reach was a side effect of quoting a workflow you did not intend to run.
-
GoalJobFailedError.retryableis nowbool | None. It was a barebool, so "the server said do not retry" and "the server said nothing" were the same answer —False. That is the reading this package's ownInsufficientCreditsErrordocstring already forbids for its operands ("read them as unknown, never as zero"), and it applies to a verdict too.Not academic: until the backend began sending
suggestedActionon a failed job,suggested_actionwasNoneon every failure, soretryablewas structurallyFalsefor every job this SDK has ever seen fail. A caller branching on it would never once have retried.if exc.retryable:keeps working unchanged. Addelif exc.retryable is None:if you want to distinguish "no guidance" from "do not retry".
Pin the version. Do not reach for --pre:
pip install "convilyn[all]==3.2.0b1"pip already allows a pre-release when the specifier names one explicitly, so
the pin is sufficient on its own. --pre is a global switch — it applies
to the whole dependency resolution, not just to convilyn — and pip's own
hint (install with pip install --pre``) does not say so. Following that hint
during round-6 testing produced:
| package | pinned install | after --pre |
|---|---|---|
defusedxml |
0.7.1 | 0.8.0rc2 |
lxml |
6.1.1 | 7.0.0a3 |
pydantic |
2.13.4 | 2.14.0b1 |
Every model in this SDK is built on pydantic, and lxml is what
python-docx / python-pptx parse with — so a result from that environment
cannot be attributed to convilyn at all. That is pip's behaviour rather than
a defect here, but anyone installing a pre-release will meet it, so it belongs
next to the version number rather than in a support thread.
Pre-release. Minor rather than patch because of one behaviour change you can
see from the outside: five failure paths that used to raise ValueError now
raise a ConvilynError. Nothing is removed from the public API, but except ValueError: wrapped around extract() / understand() / to_markdown() stops
catching them. See Changed — it is written in both directions on purpose.
-
GoalArtifactUnusableError— the run succeeded, was charged, and there is still nothing usable to hand back. That is not the same event as a failed job and it is not your mistake, so it is now its own type underConvilynErrorrather than a bareValueError.It carries the operands you would otherwise have to parse out of the message:
attribute reason"missing"|"unparsable"|"too_large"kind"json"|"markdown"— which artifact was being fetchedjob_spec_id,artifact_idfeed both straight to goals.download_artifact_to(...)size_bytes,max_bytesset on "too_large"job_statusthe terminal status the run actually reached The
too_largecase is the one worth knowing about: the message has always told you to usedownload_artifact_to(), butextract()/understand()/to_markdown()never return a job handle, so there was no supported way to obtain the two ids that call needs. They are on the exception now.except convilyn.GoalArtifactUnusableError as exc: if exc.reason == "too_large": client.goals.download_artifact_to(exc.job_spec_id, exc.artifact_id, to="out.json")
-
GoalJobFailedError.detail/.suggested_action/.retryable.PROCESSING_LIMITis one canned sentence covering four unrelated ceilings — an iteration cap, an input-token budget, a repeated tool call, a scratchpad read loop — so until now a caller could not tell them apart, nor whether changing the input would help.except convilyn.GoalJobFailedError as exc: if exc.detail and exc.detail.reason == "ITERATION_LIMIT": print(f"stopped at {exc.detail.reached} of {exc.detail.limit} steps") if exc.retryable: job = client.goals.retry(exc.job_spec_id) # same job spec, not charged again
detail.reasonis one ofITERATION_LIMIT,TOKEN_BUDGET,REPEATED_TOOL_CALL,SCRATCHPAD_READ_BUDGET;limit/reachedareNone— never0— when a resumed run has no counter.suggested_actionis the server's own next step for thiscode, so you do not keep a second copy of that mapping, andretryableis simplysuggested_action == "retry". Read it rather than inferring: a plan ceiling is not retryable but is actionable, which is why the API sends an action rather than a boolean.Both fields require a backend that serves them; against an older deployment they are
None, which is why this is a pre-release.
-
except ConvilynError:now catches five situations it did not. All five are post-success artifact problems inextract(),understand()andto_markdown(): no JSON artifact, no Markdown artifact, the payload is not valid JSON, and the two in-memory size caps. If the exception table in QUICKSTART §4 is what you built your handling on, this is the direction that makes it more true, and no change is required. -
except ValueError:around those three methods stops catching them. This is the migration, and it is one line:- except ValueError as exc: # used to catch an unusable artifact + except convilyn.GoalArtifactUnusableError as exc:
What is still a builtin: argument mistakes — but they are
ValueErrororTypeError, not one type, and this saidValueErroralone:call raises understand([], schema={})— empty file listValueErrorunderstand(["file_x"], schema="not-a-dict")— schema is not a dictTypeErrorgoals.py's own docstring has always saidValueError / TypeErrorand the CLI handler has always caught both; only this entry merged two different inputs into one sentence. A reader who wroteexcept ValueError:from it would miss every schema type error.The line being drawn is "you passed something unreasonable" stays a builtin; "the platform produced something unusable" becomes a
ConvilynError. -
convilyn goals understandexits3instead of1when the run produced no usable result.1means you invoked the command wrongly; this outcome is a run that happened and was paid for, which is what3already meant for a failed job.This said "the command's
--helpdocuments3as covering both", and it did not: the help text named exactly one exit code,1, for a malformed--schema-file. The implementation was right the whole time — only the help was silent.--helpnow carries the full table, so the claim and the output agree.
-
A non-UTF-8 artifact escaped as a bare
UnicodeDecodeError.json.loadson bytes raisesUnicodeDecodeError, notJSONDecodeError, so the guard never fired:except ConvilynError:missed it and the promised message never appeared.to_markdown()'s.decode("utf-8")had the same hole. Both are now reported asGoalArtifactUnusableError(reason="unparsable"). -
The oversize message named
extract()even when you calledunderstand(). The cap is stated by the exception'ssize_bytes/max_bytesnow, so it cannot name the wrong method. -
doc_analyzeris not a workflow that exists. It appeared in QUICKSTART, in an example file, in--workflow-idhelp text and in a docstring — so the first snippet a new user copies returned a 4xx. Every occurrence now namesgoal_lane.content_to_multipost, an active workflow with a single required slot, which is also what makes the human-in-the-loop walkthrough actually reach its loop. -
to_markdown()'s documentation said no platform build served it. That stopped being true and the docstring did not. It now describes what the method really raises when a given output kind has no pipeline.
Minor, and both halves are why: the public surface grows by four exception types, and the set of packages installed into your environment shrinks by one. Nothing is removed from the API and nothing you catch today stops being caught, so no migration is required.
-
Four typed billing refusals. The paid path can refuse a run in four ways that want four different next steps from you, and until now all four arrived as a bare
APIError— so telling "top up" from "wait" from "this workflow has no price" meant string-matchingexc.code, which is matching on something we reserve the right to change.status what to do InsufficientCreditsError402 top up — carries required_credits,available_credits,shortfall_creditsFreeTierBlockedError403 leave the Free plan (or fund the run) — carries upgrade_urlSpecNotPricedError409 pick another workflow; retrying will not help ChargeUnavailableError409 transient — retry later InsufficientCreditsErroris notQuotaExceededError, and they share HTTP 402. A quota is a ceiling you were given and it resets at the next period; a balance is money you hold and it does not refill on its own. One status code, two different facts about your account — so they are two types rather than one type you branch on bycode:except InsufficientCreditsError as exc: print(f"short by {exc.shortfall_credits} credits") # None when unknown except QuotaExceededError: ... # wait, or upgrade
shortfall_creditsis derived from the two operands rather than sent as a third field, because a third field that must agree with two others is a field that can disagree with them. It isNone— unknown, never zero — when the refusal carried no operands, and clamped at zero if they ever disagree.All four subclass
APIError, so every existingexcept APIError:andexcept ConvilynError:keeps catching them. A refusal code this build does not model still arrives as a plainAPIErrorwithcodeanddetailsintact — on 402, 403 and 409 alike — so a new server signal is never an unhandled crash and never a type asserting a remediation nobody verified.
-
websocketsis no longer a dependency. It had been required since before 3.0.0 and imported nowhere in the package since — the WebSocket surface was removed in 3.0.0 (goals.events(),GoalEvent,WebSocketError,ws_url) and the dependency did not follow, so everypip install convilynpulled a package no code could reach.Nothing in the public API changes; there was nothing left importing it. What changes is your installed environment — one fewer transitive package, one fewer version-compatibility surface, one fewer CVE feed to read. That is why this is a minor rather than a patch.
A fix-only release: nothing added, nothing removed from the public API. That is
why it is a patch and not a minor. goals.extract() and estimated_micro_u are
both still here and still work; their removal is bound to 4.0.0.
-
goals.understand()refusals now carry the server's reason instead of a blanket "not supported". A request the backend rejected for a nameable, actionable cause — too many files, mixed file kinds — surfaced asUnderstandUnavailableErrorwith the class's default text: the connected platform does not support schema-grounded understanding yet. That is a claim about the platform, and it was false; the caller's request was the problem, and the backend had said so.Two independent losses, both fixed:
- the error envelope decoder recognised
{code, message, …}and{"detail": {…}}but not{"detail": "<string>"}— a plain-string detail, which several AI-workflow create paths return. That body fell through, somessagedegraded to the HTTP reason phrase and the explanation was discarded before any resource saw it. understand()then constructed the error with no argument, discarding even that.
A refusal whose body carries no message is unchanged: it still reads as the platform not supporting the feature, which is the accurate reading when the server offered nothing. The HTTP reason phrase is a status label, not an explanation, and is not forwarded as one.
UnderstandUnavailableErroris still the type raised, for the same four statuses as before — only the message improves, soexcept UnderstandUnavailableErrorwritten against 3.0.0 keeps working. Giving the wrong-request case a distinct type needs a machine-readable discriminator on the wire, which does not exist today; that is tracked for the next major. - the error envelope decoder recognised
-
A shipped CLI example crashed.
examples/07_goals_cli.shwas published in the sdist and did not run; it is removed. The examples index and the scripts it lists are now checked against each other in both directions, so the index cannot name a file that is absent and a file cannot ship unlisted. -
The docs described a WebSocket event stream that 3.0.0 removed.
docs/QUICKSTART.mdclaimed the goals surface "adds … a WebSocket event stream" while §7.3 of the same document recorded its removal, and theconvilyn goalsCLI docstring still listed aneventssubcommand. Both now match what the package does. -
The upload path's SSRF guard is now actually covered by a test. Uploads go through a presigned POST grant. The SDK also carried a presigned-PUT fallback "so the SDK works against both backend generations" — there is no such generation; the contract makes
fieldsrequired and the server has one producer. That dead path is gone.What matters more than the removal: the two SSRF assertions (reject non-HTTPS, reject internal hosts) were attached to the dead method, while the live one had none. They were moved onto the live path rather than deleted with the code, so the guard your uploads actually pass through is the one under test. No behaviour change — the path you were already using is unchanged.
-
The offline engine's format table now cannot advertise a format the package does not contain. No conversion changes here —
convilyn.localreads the same formats it read in 3.0.0, and the shipped engine is byte-for-byte what 3.0.0 shipped. What changed is that this is now enforced rather than true by luck.The engine is generated from the platform's own conversion code. That upstream gained an HTML reader; the generator's precondition asked whether the import could be rewritten for the published package, not whether the module it named was one the package actually carries — and those read as the same question. A regenerated engine would have advertised
html → mdthroughcapabilities(), then raisedModuleNotFoundErrorthe first time anyone converted an HTML file.A postcondition over the whole generated tree now refuses any build whose own imports do not resolve, so a route this package offers is a route it can run.
HTML remains unavailable offline, and is now a stated limit rather than an omission. The platform's HTML reader is built on a GPL-3.0 library, and this package is Apache-2.0 with permissive dependencies throughout; adding it is a licensing decision, not a packaging one.
convilyn.local.capabilities()lists nohtmlroute, which is the honest answer — the hosted conversion API reads HTML as it always has.
-
ConvertJob.warnings— what a successful conversion could not preserve. The field has been on the wire since the warnings channel was built, and the LibreOffice route began filling it in the same wave as this release; the SDK simply did not model it, so every warning the server sent was dropped at the last hop.It matters most where the job succeeds. An
.xlsworkbook converted to CSV returns a file, reportscompleted, and holds only its first sheet —warningsis the only thing that says so.pdf_reversehas likewise been emittingPage N has minimal or no textall along, seen by nobody.job = await client.convert.create_and_wait(file=f, target_format="csv") for note in job.warnings: print(note)
Entries are prefixed by kind (
best_effort:,truncated:,bundled:,layout_degraded:, …), so splitting on the first:groups them — but treat an unprefixed entry as a plain note rather than an error, because some producers emit one. Always a list: absent on the wire means empty, neverNone, sofor note in job.warningsneeds no guard. -
A refused conversion now tells you why, in fields you can act on.
JobFailedErrorgainsdetail(convilyn.types.JobErrorDetail), carryingreason, and — for a workbook refused because CSV holds one table —sheet_countplusfaithful_targets.except JobFailedError as exc: if exc.detail and exc.detail.reason == "MULTI_SHEET_WORKBOOK": print(f"{exc.detail.sheet_count} sheets; try " f"{' / '.join(exc.detail.faithful_targets or [])}")
Before this, a six-sheet
.xlsx→csvreturned[GENERIC]: Something went wrong during processing. Please try again.— a retry instruction for a refusal that is deterministic, so every attempt spent quota to fail identically. The code is nowUNSUPPORTED_INPUTand the retry advice is gone;detailis what lets you explain the refusal in your own words and your own locale.str(exc)also gains a trailing sentence when a detail is present. The existingJob <id> (<type>) failed [<code>]: <message>prefix is unchanged, sostartswithmatching keeps working.codedeliberately staysUNSUPPORTED_INPUTrather than becoming a new member. A new code sends clients that predate it down their unknown-code path, which on the web client resolves to a generic "try again" — reinstating the exact advice this removed, for the users least able to act on it. Branch oncodefirst and treat an unrecogniseddetail.reasonas absent: the server may know refusals your build does not.
-
The WebSocket event stream is gone. Removed:
goals.events(), theconvilyn goals eventsCLI command, theGoalEventtype, theWebSocketErrorexception, and thews_url/ws_transport_factoryconstructor arguments (plusCONVILYN_WS_URL).Nothing that worked stops working — it never worked. The platform's WS gateway authenticates developer-portal keys (
cvl_/cvi_), a JWT, or an anonymous browser cookie. This SDK rejects developer-portal keys at construction and issues no JWT, so no credential it can hold was ever accepted. Every call raised. The tests passed because the transport was mocked.Why it was removed rather than fixed. Making it work means the gateway accepting a consumer key at
$connect, and its authorizer takes identity from a query parameter — it must, because the browser client shares that gateway and a browser cannot set headers on a WebSocket handshake. So "gateway support" meant putting a long-lived, non-self-revocable API key in a URL, permanently, on every streaming call. Query strings reach proxy logs, debug tooling, and any access log later switched on.Migration. Use
client.goals.wait(job_spec_id, timeout=..., idle_timeout=...)orretrieve(); CLIconvilyn goals status. Both authenticate over HTTPS with anAuthorizationheader.wait()already backed every documented example.If streaming returns it will use a short-lived, single-use connect ticket — a design sharing no code with what was removed, which is why keeping this was not free optionality.
-
PDF → Markdown rebuilds paragraphs instead of emitting one per visual line. A PDF stores glyph positions, not paragraphs, so a Chinese paragraph wrapping over four lines arrived as four paragraphs — and the font-size pass then promoted some of them to headings.
Measured on a 4-page Chinese whitepaper and a 12-page PRD, against the same documents'
.docxoriginals as the reference:headings before headings after .docxreferencewhitepaper 17 7 7 PRD 129 32 32 List items went from 0 to 7 and 0 to 32; table extraction is unchanged.
CJK text is joined with no space, because Chinese and Japanese put none between words — a line break there carries no character, and inserting one invents data. Latin keeps its space, where the break really is standing in for one.
Three defects sat behind this:
- Lines were grouped by
int(top / tolerance)— a bucket, not a distance. Two words 1.8pt apart landed either together or apart depending on where the line sat on the page, which is how a numbered heading arrived as#### 摘要followed by#### 1.. - Bullets were invisible. Word emits them from a symbol font as Private Use Area codepoints (U+F0B7), which render as nothing — so a list looked like prose with a leading blank.
str.isupper()was used as a heading signal. It skips uncased characters and answers about whatever is left, so a Chinese sentence containing one Latin acronym read as upper case. And a leading1.promoted numbered list items to headings; a finished sentence now distinguishes them.
- Lines were grouped by
-
convilyn doctor --pingno longer reports success when the API rejects your key. A401or403from the tier probe is now a FAIL with exit code2, not aWARNwith exit code0.It mattered because
doctoris what people put in the first step of CI. The old classifier could not tell "the backend hiccuped" from "your credentials are refused" — everyAPIErrorwas advisory — so a broken key producedAll checks passed.and travelled downstream until something else failed for a reason that looked unrelated.A 5xx or a transport error stays advisory, deliberately: the required checks did pass, and an unreachable optional signal is not a broken environment.
Two smaller fixes ride along. The exit code now comes from the check that failed rather than from matching its display name, so an auth failure is an
EXIT_API_ERRORinstead of anEXIT_USAGE. And the summary line counts warnings —All checks passed.is now reserved for a run with none, rather than printed above aWARNline it contradicts. -
convilyn.local.pdfno longer leakspypdf's exception types. An encrypted source raisedpypdf.errors.FileNotDecryptedErrorout of seven of the eight operations —page_count,extract_text,select,merge,rotate,compress,burst— past anexcept LocalErrorthatdocs/STABILITY.mdsays is enough. All of them now raisePdfOperationError.The translation had been hand-rolled at five call sites with three different
exceptlists; it is now one guard that every operation goes through, so the next one added cannot be written without it. -
convert.create(file="report.pdf")raisesTypeErrorwith an explanation instead ofAttributeError: 'str' object has no attribute 'filename'from two frames down. The message names both ways forward:file_id=for an id, orfiles.upload(path)first. The signature was always right and a type checker always caught this; the fix is for callers who do not run one.
-
The typed-exception list in QUICKSTART section 4 is now the exceptions this package exports, and a test keeps it that way. It had named
UnsupportedRouteErrorunder afrom convilyn import ...heading — that name lives inconvilyn.localand importing it from the top level is anImportError— and listed 6 of the 14 catchable types.UnsupportedRouteErroris deliberately not promoted to the top-level namespace: it is the offline engine's answer about what this machine can convert, and the offline engine ships behind extras. The list now has a section per namespace.Also noted there:
JobErroris exported and ends inErrorbut is a pydantic model, not an exception.except JobErroris aTypeErrorat runtime. -
QUICKSTART documents
max_rows/--max-rows, andgoals.understand()/goals.to_markdown()— all three shipped in 2.0.0 with no section to read. -
QUICKSTART says outright that CSV, XML and plain text get bigger when rendered to Markdown (measured: +3.1% and +33.2% in tokens), and that the conversion earns its keep on formats whose structure is locked in a binary container. The tool converts them because the route is real; nothing said it costs you tokens rather than saving them.
-
docs/README.md's "no silent fallbacks, no partly-converted files" is scoped to the offline engine, which is where it is true and whereconvilyn local doctorcan enumerate it. The hosted API publishes aqualityModeper route, and the sentence now points at it.
-
convertnow reaches images and media, not only documents.client.convert.create(file=photo, target_format="webp")andconvilyn convert clip.mp4 --to mp3work. Previously the resource sent a hardcodeddocument_conversionand could not derive a source format from anything but 13 document extensions, soconvilyn convert video.mp4 --to mp3failed locally without issuing a request — and for a video the metered verb was the only one available, which is the wrong way round for a split whose whole purpose is knowing before you spend.The processor is derived, never named by the caller. It is the conversion family that speaks both the source and the target format — which is why
clip.gif --to pngis an image conversion andclip.gif --to mp4is a media one. Reading the family off the source alone cannot answer that.await client.convert.create_and_wait(file=clip, target_format="mp3") # → {"processor_type": "media_processing", "params": {...}}
Nothing
convertcan reach spends credits. OCR and transcription are not in the derivation at all; extraction remainsgoals.understand(). Whether a particular pair is producible stays the backend's answer, published atGET /api/v1/{document,image,media}/support— this SDK carries no copy of it. -
convilyn convert --dry-runnow prints theprocessor_typeit would reach (and carries it in--json), so the lane is visible before anything uploads. It builds the real request body rather than describing one, so an argument the live call would reject is rejected here too. -
goals.to_markdown(files)— extract unstructured content into Markdown. The metered path, for documents whose content has to be extracted before it can be written down: scanned pages with no text layer, embedded figures that need describing. That work calls per-unit billed third-party APIs, so it is charged.If you only need a plain rendered
.md, do not use this. Deterministic document-to-Markdown conversion ships free on every plan through the file-conversion API; this method will never be the cheaper way to get one.try: md = await client.goals.to_markdown(["file_abc"]) except convilyn.UnderstandUnavailableError: ... # fall back to file conversion
Not yet served by any platform build — every call currently raises
UnderstandUnavailableError, naming the free alternative. The method ships ahead of the pipeline so code written against it keeps working unchanged when the capability is enabled server-side, exactly asunderstand()shipped ahead of its own rollout. A differently-shaped result is never returned in its place. -
Route.unavailable_kind— why a conversion cannot run, in a form a program can act on.available=Falsedid not mean the same thing on every engine: a document route was unavailable only because something was not installed, while an image route could be unavailable because this build of Pillow has no encoder for the target — which no install changes. Telling those apart meant reading the English inunavailable_reason, or knowing which engine produced the row.routes = convilyn.local.capabilities().routes worth_fixing = [ r for r in routes if not r.available and r.unavailable_kind != "unsupported_by_build" ]
The three values are
missing_requirement(a declared requirement is absent —Route.missingnames it),missing_plugin(fixable, but by a component this package does not distribute, such aspillow-heif), andunsupported_by_build(nothing installable changes the answer).Noneexactly when the route is available. -
Media conversion in
convilyn.local— offline, no API key. Video and audio containers convert into one another, and a video converts into an audio file:mov → mp4,mp4 → mp3,wav → flac, and 67 other pairs.convilyn local convert clip.mov --to mp4 convilyn local formats --from mov # what this machine can do with a .mov
from convilyn import local local.convert("clip.mov", to="mp4")
It needs FFmpeg, which is a program rather than a package, so — like LibreOffice and Calibre — it is not an extra and
pipcannot supply it. Nothing raises to find that out: an unavailable route says so as data, withunavailable_kind="missing_requirement"and a sentence naming the download.convilyn local doctorlists it alongside the other two.Animated GIF is a source only. It has no encoder here, so
*-to-gifis not offered rather than offered and failing.Enginegains"media". The union is wider, so a caller that exhaustively matches on it — amatchstatement with no default, or a dict keyed by every engine — needs a branch for the new value. Reading the field, comparing it, or printing it is unaffected.Not included: trimming, cropping, resolution changes, and compressing to a target size. Also not included, and never will be: transcription and anything else that calls a paid service.
convilyn.localis the half that needs nothing from us, and a conversion whose cost depends on how long your video is does not belong in it. -
--out-dironconvilyn local convert.batchhas always had it, and reaching for it onconvertgot only "Did you mean '--out'?". It writes into the directory keeping the source's name —convert report.docx --to md --out-dir build/→build/report.md. Passing it together with--outis refused rather than ranked: one names a file and the other a directory, so honouring one would mean silently ignoring the other. -
UnavailableKind, the type alias for that field. -
convilyn local formats --jsonandconvilyn local convert --dry-run --jsoncarryunavailable_kindalongsideunavailable_reason. -
max_rowsonlocal.convert()/local.convert_many(), and--max-rowsonconvilyn local convert/batch. The CSV row cap was a module constant no caller could reach, so neither "this is 500,000 rows, give me the first 200" nor "give me all of it" could be said at all.convilyn local convert transactions.csv --to md --max-rows 200 convilyn local convert transactions.csv --to md --max-rows 0 # the whole file
It counts data rows — the header is not charged to it — and applies only to row-based sources. Passing it for a source with no rows is refused rather than ignored: a cap silently dropped on some inputs and honoured on others is the same invisibility this option exists to remove. In a batch that refusal is one file's
ok=Falseresult, not a stopped run.
-
An unsupported conversion raises
UnsupportedRouteError, notValueError. QUICKSTART says "catch the baseConvilynErrorto handle them all uniformly", andValueErroris not one — so anyone who wrote their error handling from the documentation missed this entire class:client.convert.create_and_wait(file=txt_file, target_format="mp3") # was: ValueError — invisible to `except ConvilynError` # now: UnsupportedRouteError — which is a ConvilynError
The message is unchanged, and the refusal still happens before the upload, so an impossible pair still costs nothing. Only the type moved.
It is the type
convilyn.localalready uses for the same question, reused rather than duplicated under a second name.⚠️ That changes whatLocalErrormeans.UnsupportedRouteErrorsubclassesLocalError, soexcept LocalError:now also catches this refusal from the hosted lane:try: client.convert.create_and_wait(file=txt_file, target_format="mp3") except LocalError: ... # reached now; would not have been before
No
exceptloses a case — every clause that caught something still catches it, so this cannot silently miss. ButLocalErrorhas stopped being a reliable answer to "was this an offline failure?", and code that branches on it to route between the two halves needs to readUnsupportedRouteErrorexplicitly instead.The alternative — moving
UnsupportedRouteErrorout from underLocalErrorinto the shared taxonomy — was considered and rejected: it would narrowexcept LocalError:so it stopped catching genuine offline route failures, which is the silent direction. A widening you can see beats a narrowing you cannot.The same applies to a source extension no family speaks, and to asking for a format to convert to itself — both reach the same refusal and both moved with it.
Argument mistakes deliberately stay builtins.
upload()with neitherpathnorcontent, apage_rangeon an image conversion, amax_attemptsbelow zero — those keep raisingValueError/TypeError. The line is the conversion you asked for cannot be produced (a domain failure) versus the arguments you passed do not make sense (not one). -
convilyn local convert --out photo.webpnow converts to WebP.--outnamed the path and nothing else, so the target format still defaulted to Markdown — the command planned a conversion tomd, converted to the suffix, and reported the failure of the plan it had not run:$ convilyn local convert logo.svg --out logo.png ✗ Unsupported conversion: No route from svg to md. This engine cannot read svg at all. ^^ nobody asked for md
It now reports what was actually wrong:
✗ Cannot convert here: Reading svg needs cairosvg, a Pillow plugin this package does not install: a vector rasteriser, which needs a native cairo. …
This is the semantics the library has always documented —
tonames the format and the output sits beside the input;outnames the path and the format comes from its suffix — so the CLI now matches the Python API rather than contradicting it. Passing--toas well still wins, and--outwith no extension is refused with a message naming the flag to add, instead of guessing. -
local.plan()acceptsout=. It took onlyto=, so it could not answer the questionconvert(out=...)answers, which is why the CLI had to guess a format before asking.plan(src)with neither still means Markdown; passing both letstowin, the same precedenceconvertuses. -
⚠️ convert.download_to()andgoals.download_artifact_to()no longer replace an existing file. They raiseFileExistsErrorunless you passoverwrite=True. This is a behaviour change to methods that already shipped, not a new option: a script that re-downloads to the same path succeeded before and now raises.# before: silently replaced whatever was there # now: FileExistsError — "out/report.pdf exists; pass overwrite=True to replace it" client.convert.download_to(job, to="out/report.pdf") # the re-run, and the whole migration client.convert.download_to(job, to="out/report.pdf", overwrite=True)
Why it is worth a break:
convilyn.local.convert()has always refused, and its docstring gives the reason as a product position rather than a default — guessing what somebody wanted is how a converter overwrites the wrong file. The cloud half overwrote silently. A package that answers the same question two ways, depending on which half you reach, does not have two defaults; it has no position, and you cannot reason from one side to the other.goals.download_artifact_to()changed with it. Both call one writer whose docstring promises they behave identically, so fixing only the reported one would have made that promise false and recreated the inconsistency one resource over.A pre-existing symlink at the destination is still refused outright, and
overwrite=Truedoes not override that — following it would write the bytes wherever it points, which is a different hazard with a different answer. -
convilyn convertgained--overwrite. The CLI downloads through the method above, so without it a second run of the same command had no way to finish. Same name and same meaning asconvilyn local convert --overwrite, which has always had it. -
XLSX conversion now carries percentage and currency formats across, and still refuses everything lossy. A cell formatted
0.0%used to convert as-0.720386735542037, which is correct but drops the fact that it is a ratio. It now converts as-72.0386735542037%.The format's rounding is deliberately not applied. A spreadsheet displays that cell as
-72.0%, and so does the hostedxlsx → csvroute; matching that string would discard eleven digits the file actually contains. The rule is lossless and adds meaning:format applied why percentage 0.0%yes, without the rounding lossless; says it is a ratio currency "NT$"#,##0yes, the symbol lossless; nothing else records the currency thousands #,##0no no meaning, and separators break parsing rounding never a rounded number is a different number The shift is an exact decimal one, so no digit appears that was not in the file:
0.07under0%converts as7, not7.000000000000001.Dates are unaffected — they were already emitted as ISO rather than as Excel serial numbers.
-
qualitydefaults to omitted rather than to"standard", and accepts an integer. Each processor's own default then applies — which it must, because they disagree: document and media conversions take a preset name, image conversions take a 1-100 integer, and a request carrying"standard"for an image is rejected by the API. Callers passingquality="standard"for a document conversion are unaffected; that is the server's default for it. -
page_rangenow raises when passed with an image or media conversion instead of being sent to params that do not define it. -
An unconvertible pair is refused before the upload rather than after it.
-
Nothing raises differently.
convert()still raisesMissingDependencyErrorwhen a declared requirement is missing andUnsupportedRouteErrorotherwise — the error taxonomy continues to split on "is it one of our extras", which is a question this SDK can answer for a caller, whileunavailable_kindanswers the separate one of whether a fix exists at all. -
ConversionResult.engineis nowEngine | None. It is set on every success and on every failure that reached an engine; it isNoneonly when the requested conversion has no route at all, because an unknown extension has no engine and naming one would state a fact about the run that is not true. -
AsyncConvertis constructed from the HTTP transport alone. It also took afilesresource, stored it, and never read it — a dead dependency that made the two SDKs' constructors disagree, since the TypeScriptConverthas always taken only the transport. Callers are unaffected: the resource is reached asclient.convertand is not constructed directly. -
Error decoding recognises two envelope shapes rather than three. The SDK also unwrapped a top-level
{"error": {...}}, described as coming from "older endpoints". No endpoint sends it: every error body declared across the API contracts is either flat ordetail-wrapped, and the only object-valuederrorfields belong to job-status responses, which error decoding never reads. A response in some other shape now reports the HTTP status as its code instead of taking a code from an arbitrary nested object.
-
CSV truncation converted one row fewer than its warning claimed. The row cap was charged the header, so a 6,000-row export produced 4,999 data rows under a warning saying 5,000. The off-by-one on its own is minor; a truncation warning that misreports what it truncated is not, because it is the only thing telling the reader the tail is gone at all. The cap now counts data rows, and the warning says
data rowsso the two cannot be read past each other. -
convilyn local batchno longer makes one document's Markdown point at another document's images. The engine names extracted assets per document —img-0001.png,img-0002.png— which is unambiguous inside one document and collides once a batch renders several into the same directory. The second write won, silently: the Markdown still linked to a file that existed, so nothing errored and nothing warned, and the reader got the wrong picture.This changes where batch output lands. Companion files now go under a per-document directory:
md/ report.md  deck.md  assets/ report/img-0001.png deck/img-0001.pngAnything that globs
assets/*.pngafter a batch, or hard-codes the flat path, needs the extra segment. The links inside the Markdown are rewritten to match, so a rendered document and its images stay a valid pair wherever they are moved.convilyn local convert— the single-file path — is unchanged. It writesassets/img-0001.pngexactly as before. It shares its output directory with nothing this call knows about, so it never had the collision, and a path change it did not need would be one users pay for and gain nothing from.The directory name is the source's stem, which is already required to be unique across a batch: two inputs whose outputs would collide are refused before anything is written, and that check now protects the assets as well as the
.md. -
convilyn convert page.htm --to pdfno longer fails inside a worker. The CLI had its own extension guess that returned the raw suffix, and since the resource only infers when no source format was supplied, the SDK's own mapping was unreachable from the command line —htmreached a worker that builds its format enum bare and raises. Both entry points now share one derivation, which also lower-cases and strips a leading dot. -
A failed conversion no longer reports the wrong engine.
ConversionResult.enginewas the literal"structured"for every failure, so a failed image conversion — andconvilyn local batch --jsonreporting it — named an engine that had not run. It now comes from the route the failure occurred on. -
convilyn local convertno longer prints a traceback for a conversion that cannot run. A route can be unavailable with nothing missing (Pillow installed, this build unable to write the target), and the command tested only for missing requirements — so that case fell through to aUnsupportedRouteErrornothing caught. It now refuses with the same one-line message as every other refusal. -
Eight links in the PyPI project description no longer 404. The packaged README (
docs/README.md) linked to./QUICKSTART.md,../CHANGELOG.md,../examples/,../AGENT.md,../LICENSEand./STABILITY.mdrelatively. Those resolve against GitHub when the file is read in the repository, and againstpypi.orgwhen it is read as the project description — where none of them exist. Every link is now absolute to the public mirror, which is correct in all three places the file is rendered. -
The project description no longer omits image conversion and PDF page operations. Both shipped (1.5.0b1, 1.6.0b1) while the packaged README still described offline conversion as documents-to-Markdown only, so the two newest offline capabilities were invisible to anyone reading the PyPI page. It also opened by exporting an API key, which is not needed for anything under
convilyn local; the zero-setup path now comes first.
-
QUICKSTART covers
out=andoverwrite=. It showed onlyto=, so the natural guess for "put it here" wasoutput=— which is aTypeError— and nothing said thattoandoutare mutually exclusive or that a second run needsoverwrite=True. All three were in the docstrings and in no document.plan(out=...)is shown alongside, since it answers the same question without converting.docs/README.mdgets the short form of the same three facts, so the PyPI page and the guide do not disagree. -
QUICKSTART warns that Git Bash rewrites
apiarguments on Windows. MSYS2 converts anything shaped like a Unix path before the program runs, soconvilyn api GET /api/v1/healthrequestsC:/Program Files/Git/api/v1/healthand returns 404 — a symptom that reads as a broken backend and has been misdiagnosed as one. The fix isexport MSYS2_ARG_CONV_EXCL='*'.Not an SDK defect — every program invoked that way sees the same rewritten argument — but
apiis the escape hatch this package points people at, and it is the one command whose arguments are always paths. -
The
[Unreleased]section above had two### Changedheadings and two### Fixedheadings, and four entries that add API (goals.to_markdown,Route.unavailable_kind,UnavailableKind,--jsoncarryingunavailable_kind) were filed under### Fixed. Regrouped without changing any wording, so the next release's notes describe additions as additions.
-
PDF page operations, offline —
convilyn.local.pdf. Merge, select pages, split into single pages, rotate, compress, and add or remove a password, all on the local machine with no API key:from convilyn.local import pdf pdf.merge(["a.pdf", "b.pdf"], "combined.pdf") pdf.select("report.pdf", "summary.pdf", pages="1-3,10") pdf.burst("scan.pdf", "pages/")
A separate namespace from
convertbecause these are not conversions: a PDF goes in and a PDF comes out, rearranged. Needs thepdfextra. -
convilyn local pdfCLI group —merge,select,split,rotate,compress,protect,unlock,info.protectandunlockprompt for the password when it is omitted, so it stays out of shell history. -
PdfOperationError, underLocalErrorlike the rest of the taxonomy.
- The CLI no longer crashes on a console that cannot encode its glyphs.
A Windows console left on
cp437cannot represent✓or an em dash, and Python raises from insideprintrather than dropping the character — so a finished conversion ended in a traceback after the file had been written. Unencodable characters now degrade to a visible escape and the message survives. - A malformed page range reported
invalid literal for int() with base 10: 'oops'. It now names what was typed and what was expected.
MissingDependencyError.routeis now optional. A page operation has no route, and inventing one to describe a missing package would have put a fictional value in the attribute callers are meant to trust. Existing callers are unaffected — the parameter keeps its position and meaning.
-
Image conversion, offline.
convilyn local convert photo.png --to webpconverts between the raster formats Pillow supports on your machine — no API key, no network, no account. Alpha is composited when the target cannot carry it, so a transparent PNG becomes a JPEG rather than an error.- Install with
uv add "convilyn[images]"(orpip install "convilyn[images]"). - Images are refused above 40 megapixels. A file's declared dimensions are checked before it is decoded, so a decompression bomb costs a header read.
- Install with
-
Every known conversion now appears in
capabilities(), available or not, and an unavailable one says what would fix it. Asking forheicon a machine without the HEIF codec previously returned nothing — indistinguishable from a typo — and now returns a route namingpillow-heif. Where nothing would fix it, the reason says so instead of naming a package that cannot help. -
convilyn goals understand— the CLI counterpart ofgoals.understand(). Runs a grounded, schema-constrained understanding over one or more uploaded files straight from the shell:convilyn goals understand --files file_abc,file_def \ --schema-file invoice.schema.json \ [--instructions "totals only"] [--timeout 300] [--json] [--dry-run]--jsonemits a single object —{command, file_ids, result}— with the grounded result underresultand no duplicated pretty-print riding along; human mode prints the indented result, because the result is this command's output.--dry-runreads and validates the schema file, prints the would-be request, and makes no network call.A missing, unreadable, non-JSON, or non-object
--schema-fileexits1with one error line — before any network call — instead of a traceback.UnderstandUnavailableError(the connected platform has not rolled the capability out) exits2: it is a property of the backend, not of your arguments. No public API surface changed — the command calls the existingclient.goals.understand(...).
convilyn local formatsgroups by source format. The image engine alone produces several hundred routes and the flat list had become unreadable; the--jsonpayload is unchanged and still carries every route individually.capabilities()is cached for the process. Building it probes every codec with a one-pixel save, which a batch was otherwise paying for once per file.
- Converting to a format Pillow can read but not write (
psd,pcd) raisedConversionFailedErrorwrapping aKeyErrorfrom deep inside the encoder. It now raisesUnsupportedRouteErrorbefore opening the file, carrying the same sentencecapabilities()would have shown. - LibreOffice conversions no longer fail when LibreOffice is already open.
It refuses to start a second instance against a profile another one holds,
exiting non-zero with nothing in stderr — so converting an
.odtwhile the desktop application was running reported "conversion failed" and no reason. Conversions now run against a profile of their own, kept under the user's cache directory. Deleting it is safe; it is rebuilt on the next conversion.
- Legacy, OpenDocument and ebook formats now convert, rather than being
declared and unavailable.
.doc,.odt,.rtf,.xls,.ods,.pptand.odpgo through LibreOffice;.epub,.mobiand.azw3through Calibre. Each is converted once into the modern sibling this engine already reads well, so all ten inherit headings, tables and embedded images without a second parser being written for any of them.- The result reports the original format, not the sibling's, and carries a warning saying which sibling was used. Nothing about the route is hidden.
- Neither program can be installed from PyPI, so neither is an extra.
convilyn local doctorreports which are present and where to get the rest.
- A conversion that reaches a vanished external program now raises
MissingDependencyErrorrather thanConversionFailedError. The two are acted on differently — install something, or look at the file. convilyn.localinternals are renamed to make the layering legible: a leading underscore now means internal, with no exceptions (_probe,_tools,_run,_routes,_engine). No public symbol moved;convilyn.local.__all__is unchanged. A contract test holds the rule.
- Offline file conversion —
convilyn.local. Converts PDF, Word, PowerPoint, Excel, CSV, XML and plain text to Markdown entirely on the local machine: no API key, no network, no account, no quota. Structure survives — headings, lists and tables — and embedded images are written to anassets/directory beside the Markdown so their links resolve.convert,convert_many,plan,capabilities,detect_format, and the thread-offloadingaconvert/aconvert_many.capabilities()andplan()never raise: a missing dependency is reported as aRoutewithavailable=Falseand a sentence saying what to install.convert()raises typed errors;convert_many()returns a result per file so one bad input does not stop a batch.- Semver-covered from this release — see
docs/STABILITY.md.
convilyn localCLI —convert,batch,formats,doctor. Works with no credential configured.- Optional extras, one per format family —
pdf,docx,pptx,xlsx,xml,images, plus the compositesdocumentsandall. Install only what you read; plain text, CSV and Markdown rendering need nothing. Extra names are semver-covered; the distributions behind them are not.
- Legacy and OpenDocument office formats and ebooks are declared but not yet
runnable: they need LibreOffice or Calibre, which no extra can install.
capabilities()reports them with instructions rather than hiding them, so the missing piece is discoverable. pip uninstall convilyndoes not remove an extra's packages — a pip limitation.uv remove/uv tool uninstalldo. See QUICKSTART §1.
- A caller-supplied
poll_intervalis now clamped to aMIN_POLL_INTERVAL(0.2s) floor in both the goals and convert wait loops.poll_interval=0previously produced an unbounded request rate for the whole timeout window: the stale-progress backoff is multiplicative, so a zero never grew, andasyncio.sleep(0)yields without waiting. The clamp is applied at the single loop both public waiters funnel through, so no entry point can bypass it. A slower cadence than the floor is left untouched.
- Install and development instructions are now uv-first across the README and examples (pip remains a documented alternative). No API or behaviour change.
- Documentation polish across the public surface: docstrings, guides, examples, and this changelog now use plain product language throughout (internal shorthand and tracker references removed). No API or behaviour change.
goals.understand(files, *, schema, instructions=None)— grounded, schema-constrained understanding. Returns a result that conforms toschema(a plain JSON Schema dict — language-neutral, no new client-side validation dependency) and is grounded by the platform before it is returned, instead of a freeformgoal_textanswer. Safe-degrade: when the connected platform does not yet support schema-grounded understanding, it raises the newUnderstandUnavailableErrorrather than silently returning an ungrounded result (402/429/5xx propagate unchanged). async + sync.extract()is now deprecated in favour ofunderstand()(see Deprecated below).client.builder— chat-driven workflow authoring. Build auw_workflow by conversation from your own client (parity with the web app):create_session()→send_message()→ on aregisterverdict readBuilderTurn.registered_workflow_idand hand it togoals.run(...). Alsoget_session(),messages(), andquota()(async + sync). Requires a Pro-tier account (402TIER_REQUIRED; thediscoversub-mode is exempt). New public typesBuilderSession/BuilderTurn/BuilderMessage/BuilderMessageList/BuilderPendingSlot/BuilderAttachment/BuilderQuota.
goals.extract()— superseded bygoals.understand().extract()now emits aDeprecationWarning. It runs a single fixed workflow with no caller control over the output shape, whereasunderstand(files, schema=...)returns a result that conforms to a caller-supplied JSON Schema and is grounded by the platform before it is returned.extract()keeps working unchanged (a thin wrapper over the samerun() → artifacts() → parsemachineryunderstand()reuses) for back-compat; migrate tounderstand()for a guaranteed, grounded shape.
client.user_workflows— typed management namespace for the workflows you author.list()(cursor-paged),get(),runs(),export()(portable JSON document +X-Export-Schema-Version),delete()(409WORKFLOW_IS_PUBLIC_USE_ARCHIVEwhile public). async + sync. Wraps the curated/user_workflows/*management subset now declared in the SDK contract (sdk_public_openapi.yaml); pairs withgoals.run(user_workflow_id=…)for running them andclient.builderfor authoring them. New top-level models:UserWorkflowSummary,UserWorkflowsPage,UserWorkflowDetail,UserWorkflowRun,UserWorkflowExport. Community-gallery workflows by other authors remain underclient.workflows— the two namespaces stay deliberately distinct (minor, additive).- The public API-surface test now also covers the
builderresource methods and the seven client resource accessors.
goals.start(user_workflow_id=...)/run(...)/run_interactive(...)(async + sync) — run a Builder-authored workflow (uw_...) on the typed SDK surface. Previously only built-in catalog workflows (workflow_id=) or natural-language goals (goal_text=) were typed parameters; a user-authored workflow had to be sent through theraw_requestescape hatch. The three workflow sources (workflow_id/user_workflow_id/goal_text) are now mutually exclusive and validated client-side (exactly one required). Auser_workflow_idrun may start with nofiles(it can collect them via checkpoints); only thegoal_text-only NLP path still requiresfiles. Theconvilyn goals startCLI gains a matching--user-workflow-idflag. Wire key:userWorkflowId(already on the create contract; this exposes it as a first-class SDK argument).
PlanTiernow includes"business". A business-tier account'saccount.get_plan()/account.get_quota()response previously raised a pydantic validation error becausePlanTierwasLiteral["free", "pro"]. The literal now mirrors the backend plan catalog (free/pro/business).get_plan()'s docstring no longer references a phantom/billing/planendpoint —cost-previewis documented as the SDK's canonical tier source (theck_-accepting endpoint that returnsquotaCheck.tier; the web app's JWT-only/payment/subscriptionis not reachable with ack_key).
- Public-surface contract test truthed up to the shipped surface. The
keystone guard (
tests/contract/test_public_surface.py) had drifted: its frozen sets never caught up with three already-released additions —goals.extract()(1.2.0b6),client.files.list()+ itsFileList/StoredFile/StorageUsageexports (1.2.0b8), andgoals.run_interactive()(1.2.0b8) — so the guard failed against the code it is meant to protect. The frozen__all__and per-resource method sets now match the shipped surface. No public API change — the contract snapshot catches up. This unblocks the pre-publish gate (a red keystone test is a publish blocker).
goals.run_interactive(on_slot=, on_preview=)(async + sync) — drives the whole human-in-the-loop lifecycle to a terminal state so callers no longer hand-roll theslots_pending → fill_slot → confirm → waitloop. Reacts to each stop:slots_pending→on_slot(slot, job)for each pending slot →fill_slots;ready→confirm;ready_with_preview→on_preview(job)(default approve) →confirm/cancel; terminal → return. Callbacks may be sync or async. Amax_roundsguard (GoalJobTimeoutError(reason="rounds")) bounds a runaway callback. Reuses the existingwait/fill_slots/confirmprimitives — no new endpoint, no changed semantics. (Compiled/silent-mode workflows never stop for input, so this just runs them to completion.)
client.files.list()(async + sync) — lists your durable stored files (e.g. emailed-in attachments) with a storage-usage summary (used_bytes/free_bytes/over_quota). Returns typedFileList/StoredFile/StorageUsage. Note: ordinary uploads are ephemeral and are removed by the platform's ~1-hour cleanup, so a just-uploaded transient file is not listed here — this surfaces durable storage only. An unauthenticated caller gets an empty list.
goals.extract(files)(async + sync) — one-call document extraction. Sugar overstart()→wait()→artifacts()for the common "image/PDF → one JSON object" case, so single-step extraction no longer pays the fullclient.goalslifecycle boilerplate (start/wait/fetch/parse). Runs the platform's document-extraction workflow and returns the parsed JSON of the job's primary JSON artifact. It is not a new inference product — the understanding comes from the same platform workflow; this only collapses the run-then-fetch-then- parse dance into one method. A caller-supplied outputschemais not yet supported (roadmap); to steer the extraction, callrun()+artifacts().
- Status-aware waiting:
goals.wait(..., idle_timeout=)/goals.run(..., idle_timeout=). Long agentic runs (7–9 min analyze/execute phases are normal) made the flat 300 stimeouta bad trade-off — give up on healthy jobs or wait forever on wedged ones.idle_timeoutbounds the time tolerated without any status or progress change; a job that keeps advancing holds the loop open. On an idle trip,GoalJobTimeoutError.reason == "idle"(total-budget trips carry"total"); the message says the job may still be healthy on a long phase. Fully backward compatible — omitted, behaviour is unchanged.
client.files.delete(file_id)(async + sync) — deletes an uploaded file's cloud copy (storage object + metadata record) the moment you are done with it, instead of waiting for the platform's ~1-hour automatic cleanup. Only the uploader can delete (404 otherwise); a file attached to a still-running job returns 409FILE_IN_USE. Aimed at privacy-sensitive callers (e.g. edge devices processing family documents) who want deterministic control over cloud retention.
goals.confirm()withoutexpected_versionno longer fails. The SDK used to send a body-less POST when no version was supplied; backends that declare the confirm body as a required parameter rejected it with a validation error before the handler ran. The SDK now always sends a JSON object ({}when empty). Server-side, the confirm body is also optional now, andexpectedVersionomission onfill_slotsis documented: the server conditions the write on the version it just read, so you only need to passjob.item_versionwhen you want strict read-your-write locking.
fill_slots()/confirm()docstrings now spell out the optimistic-locking semantics (when to passexpected_version, what a 409 means).
client.workflows.catalog()(async + sync) — lists the platform's built-in workflow catalog (GET /workflows/catalog), returning the newCatalogWorkflowtype (workflow id, name, supported inputs, locales,tier/free_tier_allowedgate hints). Previouslyworkflows.search()only reached the user-published community listing, so the built-in catalog was invisible to SDK callers.
- AI-workflow output artifacts are now reachable from the SDK. Three new
methods on
client.goals(async + sync):goals.artifacts(job_spec_id)→list[Artifact]— every output artifact of a completed/partial job, each with a presigneddownload_urlvalid for 1 hour;goals.download_artifact_url(job_spec_id, artifact_id)→ArtifactDownload— mint a fresh presigned URL for one artifact;goals.download_artifact_to(job_spec_id, artifact_id, to=...)— stream one artifact to disk (same size-capped streaming + symlink refusal asconvert.download_to). Previously the only way to retrieve an AI-workflow's output was to callGET /jobs/goal/{id}/artifactsby hand outside the SDK.
- New public types
ArtifactandArtifactDownload(exported at top level), wired into the conformance harness against the contract's newOutputArtifact/DownloadInfoschemas.
import convilynno longer crashes on Python 3.10.client.py/sync_client.pyusedtyping.Self(Python 3.11+, PEP 673) despite the package declaringRequires-Python: >=3.10; both now fall back totyping_extensions.Selfon 3.10 (already an unconditional dependency forpython_version < '3.11', just not wired up until now). A second, previously-masked 3.10 incompatibility in_internal/throttle.py(from datetime import UTC, also 3.11+ — hidden behind thetyping.Selfcrash until that one was fixed) is corrected the same way, usingdatetime.timezone.utc, which has always been available.
- Public-mirror CI is now green: the SDK source is
ruff format-clean (formatting was previously unenforced onsdk/), the secret-scan usesdetect-secrets-hook(the old step'sgit diff --exit-codealways failed on detect-secrets' volatilegenerated_attimestamp), and the broken typecheck step (pyright/mypy, neither shipped) was removed.
- Pin
ruff==0.15.6in thedevextra and enforceruff formaton the SDK tree so local formatting never drifts from the mirror CI.
- Remove references to the not-yet-published TypeScript / Go SDKs — Python is the only SDK live today; the multi-language framing returns when they ship.
- Correct the README licence line to Apache-2.0 (matches
LICENSE+ the[project] licensefield; the1.1.1b1README still said MIT).
- Storage URLs are validated before the client dials them. The client now rejects an upload/download URL whose host resolves to a loopback, link-local, or private address (in addition to the existing https-only check), so a malformed or tampered response cannot redirect an upload or download to an internal target.
- Downloads are streamed with a size cap instead of being buffered
whole in memory, so a very large or hostile response cannot exhaust
memory. Uploads are likewise capped (
MAX_UPLOAD_BYTES) and fail fast. base_urlmust be https for any non-loopback host — the API key travels in anAuthorizationheader, so anhttp://target is refused to avoid sending it in cleartext. Loopback hosts may use http for local development.- WebSocket URLs must be
wss://for any non-loopback host, and connection errors no longer include the auth token from the URL.
- Docstrings, README, and CHANGELOG were revised for clarity; no public API changed.
First published release (PyPI). Sections below accumulated since 1.0.1;
the Removed entries predate any published version, so no released
consumer is affected.
Events are polling-only in v1: retrieve goal progress with
client.goals.wait(...)/retrieve(...). The WebSocket gateway does not accept consumerck_keys yet;goals.events()streaming is roadmap (seedocs/STABILITY.md).
files.uploadnow speaks the backend's presigned-POST upload grant: when the presign response carriesfields, the SDK multipart-POSTs (fields verbatim, file part last) instead of PUTting — the backend switched input uploads to a size-capped S3 POST policy (content-length-range) and a PUT against the POST URL fails with S3 403. A grant withoutfieldsstill uses the legacy presigned-PUT path, so the SDK works against both backend generations.- The synchronous
Convilynclient now runs every call — and the finalclose()— on one private, long-lived event loop instead of a freshasyncio.runloop per call. Per-call loops orphaned pooledhttpxconnections, which touched their already-closed loop at interpreter teardown and crashed the CLI on Windows (RuntimeError: Event loop is closed). Calling a sync method afterclose()now raises a clearRuntimeError, and calling one from inside a running event loop still raises with guidance to useAsyncConvilyn. - A failed conversion job now surfaces
JobFailedErroreven when the backend attaches a 0-byte placeholder result file.ResultFile.sizewasField(gt=0), so parsing a failed job whoseresultFiles[0].size == 0raised a pydanticValidationErrorinstead — masking the real failure. The bound is nowge=0. convert.createnow sends the discriminated-union tag as snake_caseprocessor_type(was camelCaseprocessorType); the file-conversionJobRequestdiscriminator isprocessor_typeper the contract (processorTypeis only the response field name), so the previous key made the backend reject every conversion with HTTP 400union_tag_not_found. File conversions now succeed against the current backend.- The CLI
--jsonoutput now escapes non-ASCII (ensure_ascii=True); a raw glyph in a payload (e.g. a✓from a job) previously crashed withUnicodeEncodeErroron a non-UTF-8 console (Windowscp950). goals.start(slots=...)now sends the answers asslotAnswers([{slotId, value}]); the previousslotspayload had no matching field on the create endpoint and was silently dropped, so pre-seeded slot answers never reached the backend.- Goal-job parsing now coerces wire-
nullpendingInterrupts/pendingSlots/fileIds/filledSlotsto an empty list/dict via a before-validator; the backend legitimately returnsnullfor these, which previously raised a validation error on the non-optional fields and made the wholeGoalJobunparseable. - File uploads from a path now send a length-bearing bytes body (so httpx emits
Content-Length); the previous async-generator body made httpx useTransfer-Encoding: chunked, which an S3 presigned PUT rejects with HTTP 501. Path-based uploads now succeed against real storage.
goals.events()failure now points atwait()polling. The WS gateway does not acceptck_keys in v1, so a connect failure'sWebSocketErrormessage and the method docstring now spell out that WebSocket streaming is polling-only for now (usewait()).- Author-SDK / developer-portal tokens (
cvl_/cvi_) are now rejected byAPIKey/Convilyn(api_key=...)with a preciseAuthError, instead of being treated as acceptable consumer keys (they never authenticated against the data plane — the backend answered with an opaque 401). Theck_prefix and any unknown prefix are still accepted (forward-compat).
- BREAKING:
GoalJob.workflow_id— the backend'sGoalJobResponsenever echoesworkflowId(the request accepts it; the response does not), so the attribute was alwaysNoneat runtime. Removed pre-first-publish, so no released consumer is affected.goals.start(workflow_id=...)/run(workflow_id=...)and theWorkflow/WorkflowSummarymodels are unchanged.GoalJobis now conformance-mapped insdk/sdks.json, so any future field the wire doesn't speak fails CI instead of shipping silently.
client.goals.start(..., llm_config_id=...)/run(..., llm_config_id=...)— optionally pin a goal run to one of your stored BYO-LLM provider configs (created in the console) so the run executes on your own provider/key. Omit it to use your account default. Serialised asllmConfigId; honoured only when BYO-LLM is enabled for your account, otherwise the run uses the platform provider.
- Default API base URL — corrected to
https://api.convilyn.corenovus.com(washttps://api.convilyn.com, which does not serve the API), so a defaultConvilyn()reaches the real backend out of the box. The base URL is the host root — resource paths carry their own/api/v1prefix. - API-key prefix — the canonical consumer key is now
ck_(minted in the API Console / Settings → API), matching the backend (USER_API_KEY_PREFIX) and the docs.ACCEPTED_KEY_PREFIXESnow includesck_(the developer-portalcvl_/cvi_tiers stay recognised); the quickstart +convilyn doctorexamples showck_. The runnableexamples/*now lead withck_too, completing the alignment. - Stale post-rename references — after the
sdk-consumer→sdk-consumer-pythondirectory rename, thepyproject.toml[project.urls](Changelog / Source Code), theexamples/03AGENT.md reference, and theexamples/README.mdtest-path link now point atsdk-consumer-python.
-
CONVILYN_BASE_URLenvironment override — the client now honours theCONVILYN_BASE_URLenv var (precedence: explicitbase_url=arg →CONVILYN_BASE_URL→ default), so the CLI and SDK can target a dev/staging API without code changes.convilyn doctoralready surfaced this var and now reports the URL the client actually dials. -
Public-API contract test (
tests/contract/test_public_surface.py) — freezesconvilyn.__all__, the per-resource method sets, and the exception taxonomy, and fails if anyconvilyn._internalsymbol leaks into the public namespace or the surface grows unexpectedly. The keystone guard behind the SemVer promise; seedocs/STABILITY.md. -
convilyn.config— a public module home for the resilience config types (RetryPolicy,ExponentialBackoffRetry,NoRetry,AutoThrottleConfig). They are still re-exported from the top-levelconvilynnamespace, sofrom convilyn import RetryPolicyis unchanged — but the documented home is now a non-underscore module rather thanconvilyn._internal. -
docs/STABILITY.md— the published stability & versioning policy: what the public surface is, the SemVer promise, the deprecation policy, and the documentedraw_requestescape-hatch caveat. -
Convilyn(auto_throttle=...)— opt-in retry loop forQuotaExceededError. PassTruefor the default policy (1 retry, 60 s sleep cap, 5 s fallback delay) or anAutoThrottleConfig/ dict for tuned knobs. The SDK reads the server'sdetails.retry_after_seconds/details.reset_athint when present and gives up immediately if the implied sleep exceedsmax_sleep, so a misconfigured caller never blocks indefinitely. -
Soft-limit signalling — any response carrying the
X-Quota-State: soft_limitheader now emits aconvilyn.throttlelog warning + PythonUserWarningand returns normally (forward-compat wiring; the backend will emit the header once the gateway support lands). -
client.account.usage_history(*, since=None)— list past usage periods (one row per metric+period). WrapsGET /api/v1/payment/usage/history; the newUsageHistoryEntrymodel carriesmetric,period_start,period_end,used, and optionallimit. Pair withclient.account.get_quota()for MTD spend reviews. -
CostEstimatenow exposesestimated_total_micro_u,estimated_min_micro_u, andestimated_max_micro_u(wire aliasesestimatedTotalMicroU/estimatedMinMicroU/estimatedMaxMicroU). Use these to render the projected cost range; the legacyestimated_micro_uupper-bound field stays for back-compat.
- Renamed event types on
GoalEventType:specialist_started→agent_step_started,specialist_finished→agent_step_finished,handoff→orchestration_transition. CLI glyphs follow.
CostEstimate.max_iterationsandCostEstimate.llm_cost_per_iter_micro_uare no longer surfaced on the model. Callers should rely on the new cost-range triple (min / total / max).
- The package (
convilyn/__init__.py) and async-client (client.py) docstrings now reflect the shipped resource surface — they previously said resources "land in subsequent releases" / "follow-up commits" whileclient.files/convert/goals/workflows/accountalready ship.
https-only scheme guard on backend-supplied URLs.external_get/external_put(the presigned-URL download/upload paths) now reject any URL whose scheme is nothttps(e.g.http://,file://, an internal address over plain HTTP). This is defence-in-depth against a compromised or MITM'd backend returning a downgrade/SSRF URL. Scheme — not host — is checked, so every legitimate https presign host (S3, CloudFront, custom domains) still works.convert.download_torefuses to write through an existing symlink at the destination path, so a pre-placed link cannot redirect the downloaded bytes. Writing to a regular or new path is unaffected.convilyn doctorsecret masking tightened — diagnostics now reveal only the first 3 characters (the key tier prefix) and no longer print the trailing 4 characters of an API key.
First production release. The package surface has stabilised across the
pre-1.0 development cycle; the bump from 0.1.0 reflects API readiness, not a
breaking change.
client.accountresource —get_plan()returns the caller's billing tier;get_quota(tools=..., max_iterations=...)previews workflow cost + returns the tier's quota verdict (ok/soft_limit/quota_exceeded). Read-only, no side effects.convilyn accountCLI —convilyn account planandconvilyn account quotamirror the resource. Both support--jsonfor pipe consumption.- Typed billing exceptions:
PlanRequiredError(HTTP 402 +TIER_REQUIRED) andQuotaExceededError(HTTP 402 +QUOTA_EXCEEDED). Both subclassAPIError, so existingexcept APIError:handlers continue to catch them. Each carries anupgrade_urlfor the caller's pricing CTA. client.workflowsresource — community marketplace surface:search,get,fork,publish,patch,like.client.goalsresource — agentic AI workflows:start,wait,run,retrieve,fill_slot,confirm,cancel,retry, plus async-onlyevents()WebSocket streaming.convilyn goalsCLI — drive AI workflows from the shell. NDJSON streaming forevents; pinned exit codes (0 / 1 / 2 / 3 / 130).convilyn convertCLI +client.convert+client.filesresources.convilyn doctorCLI — environment + connectivity diagnostics for the SDK's dependencies and auth setup.convilyn apiCLI —gh-style escape hatch for any backend endpoint the SDK has not wrapped yet.- Production-grade resilience: retry on 5xx / 429 / 408 with
exponential backoff + jitter,
Idempotency-Keyauto-stamped on mutating verbs,Retry-Afterhonoured.
- Error envelope handling normalises three shapes: flat
{code, message, ...}, FastAPI{"detail": {...}}, and the older{"error": {...}}. Callers now see the same typed exception regardless of which endpoint raised. - License moved from speculative
Apache-2.0placeholder toMIT(matches the repo's existing precedent underllm-gateway/LICENSE).
docs/QUICKSTART.md— 5-min Python + CLI walkthrough.docs/README.md— PyPI landing page.AGENT.md— SOLID seams + extension points for AI coding agents contributing to the SDK.- 9 runnable examples under
examples/01_*.py…09_account_quota.py.