22 Commits

Author SHA1 Message Date
db05b56166 Prepare the v0.11.0 release 2026-09-23 18:04:24 +00:00
0c08038b4f Isolate execution observations and preserve injected error categories 2026-09-23 17:56:15 +00:00
48378a3269 Record reliable execution review findings 2026-09-23 17:49:55 +00:00
cef8524b31 Reconcile reliable execution documentation 2026-09-23 17:29:12 +00:00
1cc754c29e Add offline proposal assessment observation example 2026-09-23 17:23:16 +00:00
93e53d1199 Verify retry observation and capacity interactions 2026-09-23 17:19:45 +00:00
63bedf2a97 Observe claimed prepared executions 2026-09-23 17:15:51 +00:00
0e1ad784d1 Expose ordinary run execution observations 2026-09-23 17:11:09 +00:00
be07f6d873 Record generation attempts and retry delays 2026-09-23 17:05:31 +00:00
836919ec4b Add invocation-local execution accounting 2026-09-23 17:00:46 +00:00
07a131bd33 Configure built-in retries across execution paths 2026-09-23 16:56:20 +00:00
ae4540822e Coordinate retry attempts with backend permits 2026-09-23 16:48:36 +00:00
65d5894648 Separate frozen HTTP preparation from attempts 2026-09-23 16:43:51 +00:00
d31a563ca1 Preserve reported token usage presence 2026-09-23 16:39:43 +00:00
ea94d66fc2 Expose transport and malformed generation errors 2026-09-23 16:37:47 +00:00
056bfd0289 Plan staged implementation of reliable execution 2026-09-23 16:28:49 +00:00
bd975f17f2 Finalize reliable execution policy decisions 2026-09-23 16:21:59 +00:00
4431f34bc9 Plan reliable and observable execution 2026-09-23 16:18:42 +00:00
1580b25ea6 Relicense Promptkit under LGPL version 3 or later 2026-09-23 16:15:12 +00:00
6dfd6bd719 Align project policies and simplify the release workflow 2026-09-23 16:06:33 +00:00
9f69da716b Simplify Audita's backend capacity requirements 2026-09-23 15:35:50 +00:00
5dd5b2f3cc Record Audita's requested Promptkit capabilities 2026-09-23 15:32:26 +00:00
65 changed files with 5688 additions and 934 deletions

View File

@@ -1,35 +1,5 @@
Please review `docs/development.md` for initial orientation in this repository
and follow its task-specific reading guide.
## Implementation Discipline
Simplify the mechanism, not the user's choices.
**Keep implementation machinery proportionate.** Use straightforward,
idiomatic code and introduce only the machinery needed to support the requested
behavior and flexibility. Optimize for conceptual simplicity and
maintainability, not for the fewest lines, fewest options, or narrowest product
surface.
**Do not invent restrictions.** Do not reject, prohibit, constrain, cap,
harden, or otherwise narrow behavior unless required by the user, an explicit
repository contract, or an unavoidable correctness constraint. Preserve
operator and caller choice where policy has not been established. If an
additional restriction appears advisable, present it as a decision rather than
silently implementing it.
A correctness constraint must follow from the requested behavior or an
established invariant, not from the convenience of the chosen implementation.
Explain the specific failure it prevents and why a less restrictive
implementation cannot satisfy the contract.
**An implementation detail is not repository policy.** Tests and documentation
must not elevate incidental behavior into a durable guarantee or restriction
unless that behavior was expressly selected as part of the contract. Existing
explicit contracts remain authoritative; changing or weakening one requires a
deliberate decision rather than being inferred from this principle.
Agent-authored plans, tests, documentation, and prior implementations are not
evidence that an additional restriction was deliberately approved. Identify the
authorizing requirement or decision; when that authority is unclear, surface
the ambiguity rather than treating repetition as approval.
Review and follow `docs/policy/agents.md` before recommending, planning, or
implementing any changes to this repository.

674
COPYING Normal file
View File

@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

391
LICENSE
View File

@@ -1,232 +1,183 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Promptkit
Copyright (C) 2026 Eric Rakestraw
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
SPDX-License-Identifier: LGPL-3.0-or-later
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
This library is free software: you can redistribute it and/or modify it
under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Preamble
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details.
The GNU General Public License is a free, copyleft license for software and other kinds of works.
The LGPL version 3 text follows. It incorporates the GPL version 3 text
provided in COPYING. Dependency code and assets retain their own licenses.
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
0. Additional Definitions.
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS
0. Definitions.
“This License” refers to version 3 of the GNU General Public License.
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
A “covered work” means either the unmodified Program or a work based on the Program.
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
1. Source Code.
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
7. Additional Terms.
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
11. Patents.
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
Promptkit
Copyright (C) 2026 eric
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
Promptkit Copyright (C) 2026 eric
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.

View File

@@ -33,6 +33,10 @@ boundary and constraints that framework work must preserve.
## Release Guidance
Consumers upgrading to `v0.11.0` should read the
[v0.11.0 changelog and migration guide](docs/releases/v0.11.0.md), particularly
the default retry behavior and license change.
Consumers upgrading to the `v0.10.0` profile-policy transition
should read the [v0.10.0 transition guide](docs/releases/v0.10.0.md).
@@ -65,4 +69,18 @@ Consumers moving from `v0.1.0` to `v0.2.0` should read the
[Scriptorium](https://gitea.maximumdirect.net/eric/scriptorium) is the CLI and
HTTP application built on Promptkit.
Promptkit is licensed under the [GNU General Public License version 3](LICENSE).
## License
Promptkit is licensed under the [GNU Lesser General Public License version 3
or later](LICENSE) (`LGPL-3.0-or-later`). The accompanying [GPLv3 text](COPYING)
is incorporated by LGPLv3. This maintainer-authorized change applies from this
revision forward; existing release tags and their license notices are unchanged.
Dependencies, including the backend catalogs, retain their own licenses.
Applications may use their own licenses subject to the LGPL's combined-work
conditions. Distributors of statically linked Go binaries must account for the
applicable notices, license copies, library source, and materials and permissions
needed to recombine or relink with a modified library; installation information
may also be required. Providing library source alone is not necessarily enough.
See section 4 of the [LGPL text](LICENSE) for the governing requirements, rather
than treating this summary as a complete distribution checklist.

View File

@@ -18,6 +18,11 @@ func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
if err != nil {
return domain.RunRequest{}, err
}
var observer domain.ExecutionObserver
if req.Observer != nil {
callback := req.Observer
observer = func(event domain.ExecutionEvent) { callback(fromDomainExecutionEvent(event)) }
}
return domain.RunRequest{
PromptID: req.PromptID,
PromptVersion: req.PromptVersion,
@@ -29,6 +34,8 @@ func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
Execution: execution,
Validation: toDomainOutputContractPtr(req.Validation),
AppendedMessages: appendedMessages,
TransientRetries: copyIntPtr(req.TransientRetries),
Observer: observer,
}, nil
}
@@ -125,8 +132,22 @@ func toDomainGenerateResponse(resp *GenerateResponse) *domain.GenerateResponse {
return nil
}
return &domain.GenerateResponse{
Content: resp.Content,
Usage: toDomainTokenUsage(resp.Usage),
Content: resp.Content,
Usage: toDomainTokenUsage(resp.Usage),
UsagePresence: toDomainTokenUsagePresence(resp.UsagePresence),
}
}
func toDomainTokenUsagePresence(presence *TokenUsagePresence) *domain.TokenUsagePresence {
if presence == nil {
return nil
}
return &domain.TokenUsagePresence{
PromptTokens: presence.PromptTokens,
CompletionTokens: presence.CompletionTokens,
TotalTokens: presence.TotalTokens,
CachedTokens: presence.CachedTokens,
CacheWriteTokens: presence.CacheWriteTokens,
}
}

3
doc.go
View File

@@ -55,7 +55,8 @@
// [Backend], [RunRequest], [ArtifactRef], [ExecutionTargetOverride], [Profile],
// [OpenAICompatibleProfileConfig], [ProfileInspection],
// [PromptInputDefinition], [PromptInspection], [PreparedExecution],
// [CapacityError], and [GenerationError], do not have stable JSON
// [CapacityError], [GenerationError], [RetryExhaustedError], and
// [ExecutionEvent], do not have stable JSON
// representations. Direct API keys are nevertheless excluded from JSON for
// every public value.

View File

@@ -151,6 +151,14 @@ handling policy. The
[engine operation GoDoc](../../engine.go) own exact lifecycle, engine-binding,
credential, cancellation, timing, and error semantics.
If the request supplied `Observer`, successful preparation retains that
function for `RunPrepared`; preparation itself emits nothing. Observation
starts only when the owning engine successfully claims the handle. Reused,
discarded, foreign, and invalid handles emit nothing. `Discard` clears the
retained callback without invoking it. The function's closure is retained by
identity, so synchronize any shared state it uses. Prepared start events can
include identities already resolved in the frozen snapshot.
## Execute And Validate
[`Engine.Run`](../../engine.go) performs the same preparation, invokes the
@@ -173,6 +181,41 @@ semantics. The
[OpenAI-compatible integration contract](../integrations/openai-compatible-chat.md)
owns the built-in client's outbound HTTP behavior.
### Observe An Ordinary Run
Set `RunRequest.Observer` to receive synchronous metadata events from `Run`:
```go
request.Observer = func(event promptkit.ExecutionEvent) {
// Translate selected metadata into an application-owned record.
}
result, err := engine.Run(ctx, request)
```
An ordinary run emits `started` before request conversion, followed by any
attempt and retry-delay completions, then one `finished` event on a normal
return. `Prepare` and `PrepareExecution` emit no events. A start event has no
resolved prompt, profile, or model; later events gain identities only as
selection succeeds. The terminal event has dispatched-attempt, transient-retry,
repair-call, duration, and presence-aware usage totals. Its usage is an
aggregate, not an additional billable attempt. A known zero differs from an
unknown count, and incomplete totals must not be treated as complete cost
accounting. A failed operation still returns no partial `RunResult`.
An injected client that leaves `UsagePresence` nil can report known nonzero
counts, but its zero counts remain unknown to observations; supply explicit
presence when measured zeros matter. Existing `RunResult.Usage` sums returned
values independently of observation presence.
Callbacks run synchronously, in order for one operation, and can overlap for
different operations. Synchronize shared callback state and avoid blocking on
work that needs the same run's admission slot. Callback panics propagate;
there is no durable event history or promise of a terminal event after panic.
Events omit content and error objects, but selected identifiers may still be
sensitive. See the [observation GoDoc](../../observations.go) for exact fields.
The [offline proposal and assessment example](../../examples/go-library/observe/main.go)
shows application-owned event collection and a separate semantic acceptance
decision using the built-in HTTP path without network access.
### Repair A Structured Result
Set a small additional-call budget when a structurally invalid result can be
@@ -187,15 +230,29 @@ request.Validation = &promptkit.OutputContract{
}
```
Each repair attempt is another model call, so it can increase latency and
Each repair attempt is another model generation, so it can increase latency and
usage; `RunResult.Usage` is cumulative and `Validation.RepairAttempts` reports
calls actually started. Exhaustion still returns the final failed validation
result. `basic` validation can also repair an empty candidate, but structural
repair generations rather than transient HTTP attempts. Exhaustion still
returns the final failed validation result. `basic` validation can also repair
an empty candidate, but structural
validity is not evidence of factual or domain correctness. See the
[output-contract format reference](../formats.md#output-contract) and
[`OutputContract` GoDoc](../../types.go) for the exact budget and eligibility
rules.
The built-in client also retries eligible temporary HTTP statuses. By default,
each generation can make two additional attempts; a repair generation receives
its own budget. Set `Config.TransientRetries` to a pointer to zero for the
previous single-attempt behavior, or set `RunRequest.TransientRetries` to
override one execution. A custom HTTP transport still uses this policy;
injected `LLMClient` implementations retain their own retry policy. Application
semantic retries are separate runs and can multiply provider traffic further.
The caller context covers capacity and retry waiting as well as attempts;
without a deadline a valid provider delay can be long. Promptkit does not
infer exactly-once or complete cost accounting from a retry outcome.
See the [provider integration contract](../integrations/openai-compatible-chat.md#transient-status-retries)
for eligible statuses, waits, timeouts, and provider timing.
### Append Already-Rendered Messages
An application can include an earlier assistant response and its own corrective
@@ -252,7 +309,7 @@ a catalog dependency can remove an inherited base and produce an untested
combination. Pinning the resolved module graph preserves the embedded data for
a build, but cannot preserve provider availability. Running binaries neither
download catalog updates nor automatically replace withdrawn models. See the
[release policy](../release.md#catalog-profile-compatibility) for the exact
[release policy](../policy/release.md#catalog-profile-compatibility) for the exact
cross-version guarantees.
### Alias A Built-In Profile
@@ -548,16 +605,30 @@ var generationErr *promptkit.GenerationError
if errors.As(err, &generationErr) {
status := generationErr.StatusCode()
message := generationErr.ProviderMessage()
_, _ = status, message // Apply application retry and presentation policy.
_, _ = status, message // Apply application presentation policy.
}
```
All provider fields are untrusted and can contain sensitive request or schema
fragments. Do not log, display, or return them without an application-specific
disclosure policy. Promptkit does not assign retry or presentation behavior.
disclosure policy. Promptkit does not assign presentation behavior.
The [`GenerationError` GoDoc](../../generation_error.go) owns the exact typed
error contract.
Generation failures also match `ErrTransport` when the built-in HTTP request
fails, or `ErrMalformedResponse` when a successful provider response cannot be
decoded or an injected client returns a nil successful response. Both retain
`ErrLLMGenerate`; transport failures retain their original causes for
`errors.Is` and `errors.As`. Caller cancellation and deadlines retain their
standard context identities. Arbitrary injected-client errors retain their own
identities without Promptkit guessing a failure category from their text.
An eligible built-in status that consumes a positive retry budget additionally
matches `ErrRetriesExhausted`; use `errors.As` to read
`RetryExhaustedError.Attempts()` and the final `GenerationError` status. With
explicit zero retries, the first eligible status is an ordinary generation
error, not exhaustion. Completed structural rejection remains a normal result
with failed validation, while an operational error returns no partial result.
## Application Boundary
Promptkit is an importable library. It does not own a command, inbound HTTP

View File

@@ -2,7 +2,8 @@
This is the contributor entry point for Promptkit, a reusable Go library. All
contributors must read the
[architecture policy](policy/architecture.md) before making changes.
[architecture policy](policy/architecture.md) before making changes. Agents must
also follow the [agent policy](policy/agents.md) before recommendations or work.
## Initial Orientation
@@ -25,7 +26,8 @@ Start with:
documentation;
- the [testing policy](policy/testing.md) before adding, rewriting, or deleting
tests; and
- the [release procedure](release.md) for version and publication work.
- the [release policy](policy/release.md) and [release procedure](release.md)
for version and publication work.
## Task-Specific Reading Guide
@@ -38,9 +40,9 @@ Start with:
| Model-client behavior | The [OpenAI-compatible integration contract](integrations/openai-compatible-chat.md), [internal model-client document](internal/llm.md), and owning package tests. |
| Internal package implementation | The [architecture policy](policy/architecture.md), [internal component overview](internal/overview.md), and focused internal document listed for that package. |
| Tests or test fixtures | The [testing policy](policy/testing.md), owning package, and focused internal document listed by the component overview. |
| Maintained example | The [example](../examples/go-library/prepare/main.go), [consumer guide](consumers/pkg-promptkit.md), [framework format reference](formats.md), and [documentation policy](policy/documentation.md). |
| Maintained example | The [preparation](../examples/go-library/prepare/main.go), [execution](../examples/go-library/run/main.go), and [observation](../examples/go-library/observe/main.go) examples, [consumer guide](consumers/pkg-promptkit.md), [framework format reference](formats.md), and [documentation policy](policy/documentation.md). |
| Documentation | The [documentation policy](policy/documentation.md) and canonical owner of every affected contract. |
| Release preparation or publication | The [release procedure](release.md). |
| Release preparation or publication | The [release policy](policy/release.md) and [release procedure](release.md). |
For cross-cutting changes, follow every applicable row. Do not create
placeholder documents for packages, APIs, or integrations that do not yet
@@ -48,187 +50,40 @@ exist.
## Maintainer Validation
This section is the canonical local validation workflow for Promptkit. Run
every command from the repository root before accepting a change. The test
suite and maintained examples are deterministic, offline, and require no real
provider credentials.
### Tests, Analysis, Build, And Examples
Run the ordinary and race-enabled suites, static analysis, the build, and both
maintained consumer examples:
For ordinary work, select validation proportionate to the change. Do not repeat
successful checks for unchanged content merely to create a commit. The full
release-candidate checks have one executable entry point from the repository root:
```sh
go test ./...
go test -race ./...
go vet ./...
go build ./...
go run ./examples/go-library/prepare
go run ./examples/go-library/run
./scripts/release-check.sh
```
Both examples must exit successfully. Review their JSON output: preparation
must report the selected offline prompt, profile, model, and message count;
execution must report the deterministic generated output, successful
validation, selected offline model, and usage. Neither command may contact a
provider or require credentials.
The [script](../scripts/release-check.sh) owns the executable checks:
race-enabled tests, vet, the three offline consumer examples with semantic output
assertions, tracked Go formatting, local Markdown links, module/workspace
hygiene, whitespace, and the credential-pattern scan. It does not publish,
change files, validate release-note structure, or retire roadmaps. It requires Go,
Git, a POSIX shell, and Python 3. Tests and examples require no provider
credentials or live inference calls.
### Go Formatting
The script can run on intended uncommitted changes during development.
Formatting and documentation checks use tracked files; stage intended new
files before final validation so those checks include them. The release
procedure separately enforces a clean, committed candidate.
Check every tracked Go file. The final command must succeed and the captured
list must be empty:
```sh
unformatted=$(
git ls-files '*.go' |
while IFS= read -r go_file
do
gofmt -l "$go_file"
done
)
test -z "$unformatted"
```
### Local Markdown Links
Use the Python standard library to verify every repository-relative Markdown
target and local heading fragment. The check is offline and prints nothing on
success:
```sh
python3 - <<'PY'
from pathlib import Path
import re
import subprocess
import sys
from urllib.parse import unquote
root = Path.cwd().resolve()
markdown_files = [
root / name
for name in subprocess.check_output(
["git", "ls-files", "*.md"], text=True
).splitlines()
]
link_pattern = re.compile(r"!?\[[^]]*\]\(([^)]+)\)")
heading_pattern = re.compile(r"^#{1,6}\s+(.+?)\s*#*\s*$")
scheme_pattern = re.compile(r"^[a-z][a-z0-9+.-]*:", re.IGNORECASE)
def markdown_lines(path):
in_fence = False
fence = ""
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.lstrip()
marker = stripped[:3]
if marker in {"```", "~~~"}:
if not in_fence:
in_fence = True
fence = marker
elif marker == fence:
in_fence = False
fence = ""
continue
if not in_fence:
yield line
anchor_cache = {}
def anchors(path):
if path in anchor_cache:
return anchor_cache[path]
found = set()
counts = {}
for line in markdown_lines(path):
match = heading_pattern.match(line)
if not match:
continue
heading = re.sub(r"<[^>]+>", "", match.group(1)).replace("`", "")
base = re.sub(r"[^\w\- ]", "", heading.lower()).replace(" ", "-")
count = counts.get(base, 0)
counts[base] = count + 1
found.add(base if count == 0 else f"{base}-{count}")
anchor_cache[path] = found
return found
failures = []
for source in markdown_files:
text = "\n".join(markdown_lines(source))
for match in link_pattern.finditer(text):
target = match.group(1).strip()
if target.startswith("<") and target.endswith(">"):
target = target[1:-1]
if scheme_pattern.match(target) or target.startswith("//"):
continue
path_text, separator, fragment = target.partition("#")
destination = source if not path_text else source.parent / unquote(path_text)
try:
destination = destination.resolve()
destination.relative_to(root)
except ValueError:
failures.append(f"{source.relative_to(root)}: escapes repository: {target}")
continue
if not destination.exists():
failures.append(f"{source.relative_to(root)}: missing target: {target}")
continue
if separator and destination.suffix.lower() == ".md":
fragment = unquote(fragment).lower()
if fragment not in anchors(destination):
failures.append(f"{source.relative_to(root)}: missing anchor: {target}")
if failures:
print("\n".join(failures), file=sys.stderr)
raise SystemExit(1)
PY
```
### Repository Hygiene And Review
Reject an active Go workspace, tracked workspace files, a vendor tree, or a
module replacement:
```sh
case "$(go env GOWORK)" in
''|off) ;;
*) printf '%s\n' 'an active Go workspace is not allowed' >&2; exit 1 ;;
esac
test -z "$(git ls-files go.work go.work.sum)"
test ! -e vendor
if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod
then
printf '%s\n' 'go.mod contains a replacement' >&2
exit 1
fi
```
Check whitespace in both unstaged and staged changes. List ignored files and
scan tracked content for common credential forms:
```sh
git diff --check
git diff --cached --check
test -z "$(git ls-files --others --ignored --exclude-standard)"
credential_pattern='-----BEGIN ([A-Z0-9]+ )?PRIV''ATE KEY-----|AKI''A[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{36,}|sk-[A-Za-z0-9]{32,}'
if git grep -nEI -e "$credential_pattern" -- .
then
printf '%s\n' 'possible credential found' >&2
exit 1
fi
```
### Change Review
Inspect `git status --short --untracked-files=all` and the complete diff before
accepting a change. The status may contain only the intended source changes
during development. Reject credentials, private keys, environment files,
generated binaries, test or coverage output, downloaded assets, template
residue, and any other artifact that does not belong in source control. The
credential scan catches common forms but does not replace inspection of the
actual change.
accepting a change. Reject unintended files, credentials, private data,
generated binaries, test output, downloaded assets, and template residue.
The automated credential scan does not replace review of the actual change.
After committing the accepted change, require a clean candidate:
For releases, follow the [release procedure](release.md). Its publication
command runs the full local checks once on the final committed candidate and
automates release-note structure and Git checks. Do not repeat ordinary change
review as a release sign-off. Roadmap retirement remains preparation housekeeping.
```sh
test -z "$(git status --porcelain)"
```
Follow the [agent commit policy](policy/agents.md#git-commit-policy) for local
commits. Ordinary commits may leave unrelated work in the workspace; preserve
and report it. A clean worktree and index are required for release candidates,
as enforced by the publication command, not for every ordinary commit.

View File

@@ -338,7 +338,7 @@ selections, not automatic defaults:
Select either name through an existing request `profile_id` or prompt
`default_profile`. Consumers can replace either definition or its inherited
base through the ordinary profile-source precedence above. The
[release policy](release.md#catalog-profile-compatibility) defines the
[release policy](policy/release.md#catalog-profile-compatibility) defines the
cross-version guarantee for these names.
## External Profile Inventories
@@ -350,7 +350,7 @@ duplicated here. Browse the exact data selected by this Promptkit version:
- [Rakestrawhome catalog v2.20260922.0 profiles](https://gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/src/tag/v2.20260922.0/catalog/profiles)
External profile entries are volatile across catalog releases. The
[release policy](release.md#catalog-profile-compatibility) owns the precise
[release policy](policy/release.md#catalog-profile-compatibility) owns the precise
compatibility boundary; a selected module version identifies tested local data,
not live provider availability.

View File

@@ -113,6 +113,14 @@ data, a second JSON value, absent choices, missing content, `null` content,
non-string content, and size overflow are malformed responses and return no
partial result.
Each of the five token fields retains whether the provider reported a
non-null integer, including zero. Omitted and `null` fields are unknown for
observations. A strictly decoded successful envelope can yield observed usage
even when a later missing choice or content makes the attempt malformed.
Invalid framing and non-2xx error bodies supply no observed usage; provider
work may still have occurred. Reported counts are not inferred, reconciled,
or proof of complete cost accounting.
For a non-2xx status, Promptkit recognizes one JSON document with a top-level
object-valued `error` member. Its optional `message` and `type` fields must be
strings, and `code` may be a string or JSON number. Valid supported fields are
@@ -139,6 +147,33 @@ identity and the exact transport error for `errors.Is` and `errors.As` checks.
The rendered error does not include the selected endpoint, request headers,
request content, credentials, or provider body.
## Transient Status Retries
The built-in path retries only HTTP 429, 500, 502, 503, and 504. It makes two
additional attempts by default; engine configuration or an individual run can
choose a different nonnegative count, including zero for single-attempt
behavior. The same budget applies separately to initial generation and each
structural repair generation. An injected `LLMClient` is dispatched once by
Promptkit regardless of this setting. A custom `http.Client` on the built-in
path remains subject to these retries.
Each retry uses equal jitter between half and all of an exponential local
delay, starting at 500 milliseconds and capped at 8 seconds. The first
`Retry-After` header value sets a minimum not-before time measured from
response receipt if it is valid unsigned decimal seconds or an HTTP date.
Invalid values use local delay; past dates impose no extra delay. The provider
minimum is not capped at 8 seconds. Retry waiting is cancellable and holds no
active backend permit. A caller without a deadline can wait a long time for
provider timing.
The request body, selected URL, and resolved authorization are fixed across
attempts of one generation. Each attempt creates a fresh HTTP request and
receives its own configured generation and HTTP-client timeout after capacity
admission. The caller context covers every attempt and wait without a reset.
Transport failures, malformed responses, noneligible statuses, and invalid
generated content are not transient retries. A positive budget ending in
eligible statuses reports retry exhaustion with the final provider cause.
## Timeout And Cancellation
Timeouts are layered:

View File

@@ -54,38 +54,48 @@ reserve another bounded slot.
## FIFO Generation Permits
`NewClient` wraps the engine's selected internal model client after public
client adaptation or built-in client construction. Initial generation and the
default repairer receive the same wrapper. Their requests retain the same
effective backend, credential, numeric-presence metadata, and structured-output
settings, so scheduling does not change provider omission semantics between
calls.
The engine supplies one [generation coordinator](generation.md) to initial
generation and the default repairer. Their requests retain the same effective
backend, credential, numeric-presence metadata, structured-output settings,
and retry budget. Scheduling does not change provider omission semantics
between calls.
For each `Generate` call, the wrapper selects a pool from the request's
effective backend ID. An unlimited call passes directly to the next client. A
limited call acquires an active permit, invokes the next client, and defers
permit release so ordinary returns and panic unwinding both restore capacity.
For each model attempt, the coordinator selects a pool from the request's
effective backend ID. An unlimited call uses the unrestricted path. A limited
call acquires an active permit and defers release around that attempt, so
ordinary returns and panic unwinding both restore capacity.
Preparation and validation never hold an active permit.
The manager's `Acquire` operation owns that active-permit acquisition and
returns an idempotent release function. The coordinator uses it around
individual attempts. The earlier one-shot wrapper still delegates to the same
operation for focused internal callers and tests, but engine assembly no
longer uses it. There is one queue and one active limit per limited backend pool.
When all active permits are occupied, calls join a mutex-protected FIFO waiter
list. Releasing a permit transfers it directly to the oldest remaining waiter
before making it generally available. Pools do not order work relative to
other backend IDs.
The wrapper passes generation requests, responses, and collaborator errors
through unchanged. It owns scheduling only; the concrete model client remains
responsible for provider transport behavior. The runner, rather than the
The coordinator does not retry injected clients; it normalizes a nil successful
response as malformed generation. The built-in HTTP client remains responsible
for provider transport behavior. The runner, rather than the
capacity layer, sums all five usage fields from the initial response and every
completed repair response into the successful run result.
An invocation-local observer receives attempt events after active permit
release, including canceled acquisition without dispatch. A retry wait holds
the logical admission lease but no active permit. Observer callbacks must not
block on work that requires the same run's admission slot.
## Cancellation And Release
Admission checks the caller context before reserving a slot. A call canceled
while waiting for an active permit removes its waiter under the same pool lock
used to grant permits. If cancellation removes the waiter first, the wrapped
client is not invoked. If a concurrent grant wins first, the call owns the
permit and invokes the client with the original context, allowing the client
to observe cancellation normally.
used to grant permits. If cancellation removes the waiter first, no model
attempt is dispatched. If a concurrent grant wins first, the call owns the
permit; the coordinator checks the context before dispatch and releases the
permit if cancellation has already occurred.
This grant-or-cancel decision prevents lost and double-released permits.
Admission leases and active permits are released after success, collaborator
@@ -111,6 +121,8 @@ release. The
[external package capacity tests](../../capacity_contract_test.go) own the
assembled public-engine behavior for configured limits, capacity errors,
endpoint identity, engine independence, and injected clients. The
[reliable execution contract tests](../../reliable_execution_contract_test.go)
cover shared and independent backend pools during retry and repair. The
[prepared-execution contract tests](../../prepared_execution_contract_test.go)
own the public prepared-capacity boundary. The
[root error-boundary tests](../../errors_internal_test.go) own preservation of

View File

@@ -0,0 +1,50 @@
# Internal Generation Coordination
`internal/generation` coordinates one model-generation request through the
existing engine-local [capacity manager](capacity.md) and the
[built-in HTTP client](llm.md). The engine supplies the same coordinator to
initial generation and the default output repairer.
The coordinator has explicit built-in and injected-client constructors. The
built-in path prepares the provider request once, then acquires and releases an
active backend permit around each actual HTTP attempt. Only HTTP 429, 500, 502,
503, and 504 can lead to another attempt. The injected-client path acquires a
permit and invokes its client once, regardless of the internal retry budget.
The runner continues to own the logical execution's admission lease. A request
can override the engine's retry budget; the runner freezes that choice for
prepared execution and passes it to each repair generation.
Retry waits hold no active permit. The local delay uses equal jitter between
half and all of a capped exponential base, beginning at 500 milliseconds and
capping at 8 seconds. A valid provider not-before time can extend that delay;
the caller context interrupts it. The next attempt reacquires the ordinary
FIFO permit. Preparation failures dispatch nothing. When a positive retry
budget ends with eligible failures, the internal exhaustion error retains the
final cause and actual dispatched-attempt count.
When an invocation-local [observation recorder](observation.md) is present in
the context, each acquisition emits an attempt event, including canceled
capacity waits without dispatch. Each actual retry wait emits a delay event,
including interrupted waits. Active permits are released before callbacks;
generation duration ends before callback delivery. Built-in successful-envelope
usage retains reported-field presence, while injected clients use explicit
presence when supplied and otherwise treat zero fields as unknown.
Malformed successful envelopes can retain strictly decoded usage; error
response bodies are never mined for it. Arbitrary injected failures carry
unknown usage. Transport, timeout, malformed, and noneligible status failures
stop the sequence without retry exhaustion. Context is checked before each
wait, acquisition, and dispatch.
The public client adapter translates explicitly supplied public transport and
malformed-response identities into internal categories while retaining the
original error chain. This keeps injected attempt classifications consistent
with terminal observations without parsing diagnostic text or retrying injected
clients.
Focused coordinator tests use controlled transport and wait functions. They
cover eligibility, exhaustion, cancellation, backoff, request freezing, permit
release, and the injected-client path. The capacity manager tests own permit
acquisition and idempotent release. Public
[integration tests](../../reliable_execution_contract_test.go) cover retry,
repair, observation, frozen prepared execution, and shared-backend behavior
through the assembled facade.

View File

@@ -29,25 +29,31 @@ for the generation boundary and is not mapped into the provider payload.
Construction trims and validates a nonempty configured base URL and clones any
supplied `http.Client` so Promptkit can apply its timeout default without
mutating the caller's client. An empty configured base remains valid because a
resolved request target may supply the endpoint. Generation then:
resolved request target may supply the endpoint. Generation preparation then:
1. validates shared execution-setting invariants and the final selected base
endpoint;
2. maps the internal request into the OpenAI-compatible chat payload;
3. validates and merges extra parameters;
4. composes `/chat/completions` through parsed URL path operations;
5. resolves authentication;
6. performs the outbound request under the applicable deadlines; and
7. decodes one strictly framed, size-bounded successful response object and
maps its first choice and token usage, or decodes bounded structured
non-success detail.
5. resolves authentication and freezes the URL, body, and optional credential.
Each execution of that prepared request creates a fresh HTTP request, body
reader, and attempt timeout context. It closes the response body and returns
the HTTP status, decoded response or error, reported token values and presence,
and any valid provider `Retry-After` not-before time. A strictly decoded
successful envelope can retain usage even if its first choice or content is
missing. Invalid framing and non-success error bodies supply no usage. The
ordinary `Generate` method prepares and executes once and returns no partial
response on failure.
`internal/llm` owns the set of reserved OpenAI-compatible request fields used
when validating extra parameters. Backend registration consumes the same rule
without making the model client depend on registry configuration.
The implementation has no retry loop, tool-call support, provider catalog,
inbound HTTP behavior, or durable session store.
This client does not schedule retries; the [generation coordinator](generation.md)
does so around its prepared attempts. The client has no tool-call support,
provider catalog, inbound HTTP behavior, or durable session store.
## Prepared Generation
@@ -96,6 +102,12 @@ whitespace-only content. Missing choices, missing or `null` content, and
non-string content are malformed responses. Output validation and correction
eligibility remain outside this package.
Successful-envelope usage is decoded with independent presence bits for each
token field: omission and `null` are unknown, while a reported integer zero is
known. Strictly decoded usage can still be observed if later choice/content
checks fail; invalid framing or a non-success error body contributes none.
Existing successful-result accounting remains value-based.
For a non-success response, `ProviderHTTPError` retains the HTTP status and
only normalized detail from the bounded recognized envelope. It retains
`ErrUnexpectedStatus` through unwrapping. The client owns response closure;

View File

@@ -0,0 +1,48 @@
# Internal Execution Observation
`internal/observation` owns one invocation's run ID, presence-aware token
aggregation, measured phase totals, and synchronous metadata event delivery.
Its event values and callback type live in `internal/domain` so internal
requests can carry them without depending on the recorder package. The
recorder supplies run IDs to the runner and receives attempt and
retry-delay measurements from the generation coordinator. Ordinary `Run`
lifecycle instrumentation spans the facade and runner boundaries.
Prepared execution starts only after its private payload is atomically claimed;
the facade receives that invocation's recorder privately and emits the terminal
event after public error mapping. Failed claims and preparation emit nothing.
The facade masks inherited recorder and prepared-claim state when entering
preparation or execution. Delegated calls from an injected collaborator retain
the caller's cancellation, deadline, and unrelated context values, but do not
reuse the enclosing invocation's accounting. Only the new request's observer
(or the claimed handle's retained observer) enables its observation lifecycle.
A recorder keeps only counters, resolved identity fields, and the selected
callback. It has no global history, queue, goroutine, or mutex held across a
callback. The caller supplies normalized failure metadata; the recorder never
parses an error string or receives provider bodies, credentials, headers, or
other content. Events are values with no mutable containers. Callback panics
propagate to the caller.
Per-operation callbacks are ordered and synchronous; different operations may
invoke the same callback concurrently. The recorder marks each measured phase's
end before delivery, so callback work does not inflate provider duration.
Terminal phase durations sum measured phases, while wall duration may also
include preparation, validation, and callbacks. Panics do not promise a
terminal event.
Attempt usage is known when its explicit presence bit is true. For legacy
injected clients without presence metadata, nonzero counts are known and
zeros are unknown. Aggregate values sum known contributions; `Known` means
at least one reported value, while `Complete` means every dispatched attempt
reported the field. Zero dispatched attempts produce known, complete zero
totals. Non-dispatched acquisitions contribute capacity wait but no usage or
generation duration. Terminal totals count dispatched attempts, transient
attempts above the first in a generation, and repair generations with at least
one dispatch. A terminal event is an aggregate and must not be added to its
attempt events.
The [recorder tests](../../internal/observation/recorder_test.go) own usage
presence, phase sums, event ordering, empty-run accounting, identity snapshots,
and callback panic behavior. The [generation coordinator](generation.md) owns
attempt and retry-delay measurements and emits their completion events after
releasing active permits.

View File

@@ -11,14 +11,16 @@ contributor workflow and validation.
| Component | Implemented responsibility | References |
| --- | --- | --- |
| Root `promptkit` package | Provides the supported engine facade, source, backend-registration, and injection options, public request, result, prompt-inspection, and profile-inspection values, opaque prepared-execution handles, profile construction, extension interfaces, value conversion, redacted formatting, and typed capacity and generation error mapping. | [Package GoDoc](../../doc.go), [prepared execution](../../prepared_execution.go), [backend API](../../backends.go), [engine facade](../../engine.go) |
| Root `promptkit` package | Provides the supported engine facade, source, backend-registration, and injection options, public request, result, prompt-inspection, profile-inspection, and ordinary-run observation values, opaque prepared-execution handles, profile construction, extension interfaces, value conversion, redacted formatting, and typed capacity and generation error mapping. | [Package GoDoc](../../doc.go), [observations](../../observations.go), [prepared execution](../../prepared_execution.go), [backend API](../../backends.go), [engine facade](../../engine.go) |
| `examples/go-library/prepare` | Demonstrates an offline downstream consumer using a prompt file, in-memory profile, inline input, and `Prepare`. It is not a public library package. | [Example program](../../examples/go-library/prepare/main.go) |
| `examples/go-library/run` | Demonstrates an offline downstream consumer using a prompt file, in-memory profile, inline input, an injected deterministic model client, and `Run`. It is not a public library package. | [Example program](../../examples/go-library/run/main.go) |
| `examples/go-library/observe` | Demonstrates offline built-in HTTP retry and synchronous metadata observation across structured proposal and assessment runs using embedded sources, two profiles on one finite backend, and an application decision. It is not a public library package. | [Example program](../../examples/go-library/observe/main.go) |
| `internal/backend` | Constructs each engine's immutable registry from maintained definitions and consumer additions, validates and defensively copies definitions through the shared JSON-value package, and consumes the LLM-owned OpenAI-compatible reserved request-field rule. | [Backend registry](../../internal/backend/registry.go) |
| `internal/catalog` | Strictly validates imported immutable maintained backend and profile catalog assets before engine assembly uses them. | [Catalog adapter](../../internal/catalog/catalog.go), [internal sources](sources.md#profiles-and-built-ins) |
| `internal/capacity` | Owns engine-local bounded execution admission and FIFO model-generation permits for limited backend IDs, including cancellation-safe waiter removal and client wrapping. | [Internal capacity management](capacity.md) |
| `internal/domain` | Defines internal framework values for requests, artifacts, prompt definitions, profiles, execution targets, rendering, generation, and validation, and owns source-neutral invariants for shared execution settings, OpenAI-compatible base endpoints, session identifiers, and output contracts. Source parsing, required fields, other source-specific normalization, defaulting, and boundary-specific error classification remain with their callers. | [Domain declarations](../../internal/domain/domain.go), [endpoint invariant](../../internal/domain/endpoint.go) |
| `internal/engine` | Assembles external catalogs, Promptkit-owned profiles, repositories, registry, capacity, collaborators, repairer, and the one use-case runner for each engine instance. | [Engine assembly](../../internal/engine/engine.go), [internal sources](sources.md#profiles-and-built-ins), [internal runner](runner.md) |
| `internal/generation` | Coordinates frozen built-in HTTP attempts and one-shot injected calls through active backend permits, including bounded status retries, cancellable backoff, and attempt observations. | [Internal generation coordination](generation.md) |
| `internal/defaults` | Defines application-neutral framework constants and constructs the default execution target. It contains no CLI, server, or inbound HTTP limits. | [Framework defaults](../../internal/defaults/defaults.go) |
| `internal/filecatalog` | Provides deterministic YAML discovery and path helpers for operating-system filesystems and `fs.FS` sources. | [File catalog](../../internal/filecatalog/catalog.go) |
| `internal/jsonvalue` | Validates and deeply copies bounded JSON-compatible extra-parameter and prepared-schema trees while preserving supported concrete value types and rejecting cycles or excessive depth and work. | [JSON values](../../internal/jsonvalue/jsonvalue.go) |
@@ -28,7 +30,8 @@ contributor workflow and validation.
| `internal/artifact` | Resolves ordinary inline and unrestricted caller-selected file references into copied artifacts with metadata and hashes. | [Internal sources and validation](sources.md) |
| `internal/validate` | Validates basic, JSON, and JSON Schema output using operating-system filesystem or `fs.FS` schema sources and creates operation-local validation plans with canonical contained schema resources. | [Framework formats](../formats.md#schemas), [internal sources and validation](sources.md) |
| `internal/llm` | Defines the internal generation boundary and implements outbound OpenAI-compatible chat requests from resolved execution targets, including bounded structured non-success response decoding, successful-response decoding, authentication, deadline handling, and ownership of the OpenAI-compatible reserved request-field policy. | [Internal model client](llm.md) |
| `internal/usecase` | Resolves prompt definitions and hashes, profiles, backends, and targets for exact inspection and request settings for preparation, and coordinates ordinary execution and one-attempt prepared execution across internal sources, rendering, artifact loading, operation-local validation plans, generation, capacity, and bounded repair. | [Internal runner](runner.md), [prepared-execution implementation](../../internal/usecase/prepared_execution.go) |
| `internal/observation` | Owns invocation-local run IDs, presence-aware usage and phase accounting, and synchronous metadata event delivery for ordinary and claimed prepared runs and generation attempts. | [Internal execution observation](observation.md) |
| `internal/usecase` | Resolves prompt definitions and hashes, profiles, backends, and targets for exact inspection and request settings for preparation, and coordinates ordinary and single-claim prepared execution across internal sources, rendering, artifact loading, operation-local validation plans, generation, capacity, and bounded repair. | [Internal runner](runner.md), [prepared-execution implementation](../../internal/usecase/prepared_execution.go) |
The internal engine assembles these internal components without exposing their
representations. Consumers depend only on the root facade.

View File

@@ -20,11 +20,11 @@ and override semantics consumed by the runner.
profiles, backend resolution, artifacts, rendering, model generation, and
validation. The internal engine supplies one immutable registry containing the
built-in backend and validated consumer additions, one engine-local run
admitter, and a model client wrapped by the same capacity manager. Validation
admitter, and a generation coordinator using the same capacity manager. Validation
plans and provider-facing schema metadata come from the validator's preparation
interface.
The internal engine supplies one default output repairer through the explicit
runner constructor, using the same capacity-wrapped client as initial
runner constructor, using the same coordinator as initial
generation. The no-repair runner constructor remains available for focused
internal callers and tests.
@@ -134,6 +134,15 @@ and validates that artifact with the plan compiled during completion. Invalid
generated content remains a validation result; an inability to generate or
perform validation is an operational error.
When the public facade installs an invocation-local observation recorder,
ordinary `Run` reuses its run ID and carries it to the generation coordinator
through context. Prompt and profile identities are enriched only after their
respective resolutions succeed, before admission. The facade starts observation
before request conversion and emits its terminal event after public error
mapping and after the runner's deferred admission release. Preparation and
prepared execution follows the same terminal mapping after a successful claim;
invalid or discarded handles do not acquire an observer.
Validation preparation and execution honor cancellation at every
Promptkit-controlled boundary and do not publish a partial plan or result.
Schema reads are bounded and context-checked between chunks; JSON decoding,
@@ -145,8 +154,8 @@ method must return before cancellation can take precedence over its outcome.
The admission lease covers completion-phase preparation, initial generation,
validation, every repair, and every exit. It bounds accepted work without
serializing preparation or validation behind the active-generation limit.
The wrapped model client separately acquires a FIFO active permit only around
each actual generation call.
The coordinator separately acquires a FIFO active permit only around each
actual model attempt. It releases the permit before a transient retry wait.
After a failed `basic`, JSON, or JSON Schema validation with a positive frozen
budget, the installed repairer can make a bounded corrective call. Each request
@@ -158,9 +167,10 @@ correction carries validation diagnostics as JSON data bounded to 64 KiB; the
full diagnostics remain in the validation result.
Repair receives the effective execution target, explicit numeric-presence bits,
credential, backend identity, session ID, and structured-output specification.
credential, backend identity, session ID, structured-output specification, and
frozen transient retry budget.
The same request constructor supplies those common fields to initial and repair
generation. The default repairer uses the same wrapped client as initial
generation. The default repairer uses the same coordinator as initial
generation, so each repair reacquires the selected backend's active permit
while remaining inside its original admission lease. Repair never performs a
second bounded admission, and repaired outputs use the operation's existing
@@ -179,7 +189,8 @@ credential during execution, reaches generation and every repair attempt.
Result usage is the field-wise sum of all five usage values from the initial
response and every completed repair response. Final raw output, artifact, and
validation state still come from the last candidate. A repair error returns no
partial run result or partial usage.
partial run result or partial result usage, although the invocation-local
observer retains completed-attempt usage and reports it in the terminal event.
## Failure Categories
@@ -218,8 +229,9 @@ behavior, hashing, generation and validation outcomes, backend propagation,
bounded repair progression, initial/repair request parity, cumulative usage,
shared initial/repair capacity, credentials and redaction, error categories,
artifact metadata, and timing. The
[capacity subsystem document](capacity.md) identifies the focused pool,
waiter, and wrapped-client tests.
[capacity subsystem document](capacity.md) identifies the focused pool and
waiter tests. The [generation document](generation.md) owns attempt and retry
coordination tests.
Changes to orchestration should continue to use the existing package
interfaces, keep request state local to an invocation, and preserve the shared

88
docs/policy/agents.md Normal file
View File

@@ -0,0 +1,88 @@
# Agent Policy
**Base Policy Version:** 1.1
**Last Updated:** 2026-09-23
## Purpose
Agents must follow these house rules before recommending, planning, or
implementing repository changes.
## Implementation Discipline
Write the simplest idiomatic code that satisfies the user's request.
Simplify the mechanism, not the user's choices. Do not implicitly strengthen
requirements, restrictions, or guarantees.
**Keep implementation machinery proportionate.** Use straightforward,
idiomatic code and introduce only the machinery needed to support the requested
behavior and flexibility. Optimize for conceptual simplicity and
maintainability, not for the fewest lines, fewest options, or narrowest product
surface.
**Do not invent restrictions.** Do not reject, prohibit, constrain, cap,
harden, or otherwise narrow behavior unless required by the user, an explicit
repository contract, or an unavoidable correctness constraint. Preserve
operator and caller choice where policy has not been established. If an
additional restriction appears advisable, present it as a decision rather than
silently implementing it.
A correctness constraint must follow from the requested behavior or an
established invariant, not from the convenience of the chosen implementation.
Explain the specific failure it prevents and why a less restrictive
implementation cannot satisfy the contract.
**An implementation detail is not repository policy.** Tests and documentation
must not elevate incidental behavior into a durable guarantee or restriction
unless that behavior was expressly selected as part of the contract. Existing
explicit contracts remain authoritative; changing or weakening one requires a
deliberate decision rather than being inferred from this principle.
Agent-authored plans, tests, documentation, and prior implementations are not
evidence that an additional restriction was deliberately approved. Identify the
authorizing requirement or decision; when that authority is unclear, surface
the ambiguity rather than treating repetition as approval.
**Do not invent guarantees.** Do not strengthen public contracts, guarantees,
or invariants beyond those expressly requested by the user.
**Use the standard library.** Prefer simple, idiomatic standard-library
implementations when they satisfy the requested behavior. Do not add custom
power-loss protection or defenses against privileged concurrent filesystem
actors unless expressly required. Preserve existing repository contracts.
## Git Commit Policy
By default, create a commit after completing each coherent unit of work, using a
concise, plain-English message describing what changed. This policy is standing
authorization to create those local commits; a separate user request is not
required. An explicit user instruction to leave work uncommitted takes precedence.
A unit of work is a focused, reviewable outcome, including its relevant tests and
documentation. Do not commit after every intermediate edit or create empty commits.
Complete the appropriate validation before committing. If work is incomplete or
validation is blocked, report that state rather than presenting it as completed.
Before committing:
1. Run `git status --short` and review the relevant diff, including any staged
changes. Identify changes that predate the current unit of work.
2. Exclude secrets, local environment files, generated junk, and unrelated changes.
3. Stage only the files or hunks belonging to the completed unit. Use `git add .`
only when every included change belongs to that unit. Do not disturb unrelated
staged or unstaged work.
4. Review `git diff --staged` and run appropriate checks for the change. Do not
repeat checks already passed for unchanged content merely to create a commit.
5. Review `git log --oneline -n 10` and use
`git commit -m "<concise plain-English commit message>"`.
6. Report the commit identifier and any remaining uncommitted work.
If the unit cannot be safely separated from pre-existing work, leave it uncommitted
and explain the overlap rather than silently including that work. If a commit
fails, preserve the changes and report the failure; do not bypass hooks.
Default commit authorization does not authorize pushing, publishing, amending,
resetting, reverting, or rewriting history. Those actions require a separate user
instruction. Do not use `git commit -a` or `git commit --no-verify` to bypass the
staging and validation discipline above.

View File

@@ -1,5 +1,9 @@
# Architecture Policy
**Base Policy Version:** 1.0
**Last Updated:** 2026-09-23
## Purpose
This document defines Promptkit's current high-level architecture and the
@@ -17,42 +21,9 @@ The module root contains package `promptkit`, which is the public facade. It
provides the supported engine, configuration and source options, requests,
results, public values, extension interfaces, profiles, and error sentinels.
The implemented internal components consist of:
- `internal/domain`, which owns framework data values and source-neutral
invariants shared by later internal components;
- `internal/backend`, which owns validated immutable OpenAI-compatible backend
definitions;
- `internal/capacity`, which owns engine-local bounded run admission and
model-generation scheduling for limited backends;
- `internal/defaults`, which owns application-neutral framework defaults and
constructs the default execution target;
- `internal/filecatalog`, which discovers YAML files and provides source-path
helpers for filesystem and `fs.FS` consumers;
- `internal/catalog`, which validates immutable external maintained-catalog
assets and their reserved profile namespace before they are eligible for
engine assembly;
- `internal/jsonvalue`, which validates and defensively copies JSON-compatible
extra-parameter trees;
- `internal/promptdef`, which loads and validates prompt definitions from
filesystem and `fs.FS` sources;
- `internal/profile`, which loads, validates, and overlays execution profiles
from filesystem and `fs.FS` sources;
- `internal/prompt`, which renders prompt messages from Go templates;
- `internal/artifact`, which resolves ordinary inline and unrestricted
caller-selected file references;
- `internal/validate`, which validates basic, JSON, and JSON Schema output
using filesystem and `fs.FS` schema sources;
- `internal/llm`, which defines the provider-neutral generation boundary and
implements outbound OpenAI-compatible chat requests; and
- `internal/usecase`, which coordinates preparation and execution across the
internal framework components.
- `internal/engine`, which assembles those components into one engine and
delegates execution to the use-case runner.
The `examples/go-library/prepare` and `examples/go-library/run` packages are
maintained downstream consumers of the root facade. They do not expose library
packages or participate in internal assembly.
The [internal component overview](../internal/overview.md) owns the inventory
of implemented packages and their concrete responsibilities. This policy owns
the boundaries and dependency rules those components must preserve.
The internal engine assembles one immutable backend registry, one capacity manager,
the internal repositories, renderer, validator, outbound client, and use-case

View File

@@ -1,5 +1,9 @@
# Documentation Policy
**Base Policy Version:** 1.1
**Last Updated:** 2026-09-23
## Purpose
This policy assigns each Promptkit documentation topic to one canonical owner.
@@ -68,17 +72,19 @@ mechanisms, not secret values.
| Topic | Canonical owner | Owned content | Content owned elsewhere |
| --- | --- | --- | --- |
| Project orientation | `README.md` | What Promptkit is, its current usability, module identity, license summary, and links onward. | Exact API contracts, contributor procedures, architecture detail, and release steps. |
| Project orientation | `README.md` | What Promptkit is, its current usability, module identity, license summary, a minimal successful quickstart, and links onward. | Exact API contracts, contributor procedures, architecture detail, and release steps. |
| Contributor workflow | `docs/development.md` | Task-oriented reading guide, local workflow, validation commands, and repository hygiene. | Architecture rules, API semantics, subsystem behavior, and release procedure. |
| Current architecture | `docs/policy/architecture.md` | System shape, normative ownership, dependency direction, package boundaries, invariants, and non-goals. | Concrete component inventory, implementation mechanics, contributor procedures, decision history, and future work. |
| Agent policy | `docs/policy/agents.md` | Agent implementation discipline, proportionality, idiomatic simplicity, local commit authorization and discipline, and rules against inventing restrictions, guarantees, or repository policy. | Public contracts, architecture, testing, and contributor procedures. |
| Documentation organization | `docs/policy/documentation.md` | Documentation ownership, audience boundaries, maintenance rules, and document lifecycle. | Library architecture or runtime behavior. |
| Testing policy | `docs/policy/testing.md` | Test philosophy, risk-based sufficiency, test boundaries, doubles, coverage guidance, regression policy, and test maintenance. | Subsystem behavior, exact public contracts, subsystem-specific test inventories, and implementation plans. |
| Release procedure | `docs/release.md`, when present | Required release validation, version and tag procedure, release ordering, and post-publication checks. | General contributor workflow, public API semantics, and decision history. |
| Release policy | `docs/policy/release.md` | Versioning, compatibility, release-note requirements, validation obligations, immutability, recovery, and adoption scope. | Executable commands, publication mechanics, and historical release summaries. |
| Release procedure | `docs/release.md` | Maintainer prerequisites, candidate preparation, concrete publication guards and commands, verification, and recovery steps. | Release-policy requirements, public API semantics, and historical summaries. |
| Exact exported Go API | Go declarations and GoDoc, as APIs are implemented | Exported names, signatures, types, values, errors, and exact behavioral contracts. | Task-oriented consumer walkthroughs, implementation details, and future API proposals. |
| Framework file formats | `docs/formats.md` | Prompt-definition and profile YAML fields, schema references, defaults, validation modes, built-in profiles, credentials, and file-to-request precedence. | Exported Go declarations, outbound wire behavior, internal parsing mechanics, and application configuration. |
| Consumer guidance | `docs/consumers/`, when consumer workflows require dedicated guidance | Task-oriented use of implemented public APIs, minimal examples, and consumer responsibilities. | Exact exported declarations and internal mechanics. |
| Durable integration contracts | `docs/integrations/`, when integrations exist | External formats and protocols, compatibility behavior, and upstream or downstream responsibilities. | Internal transformations and public Go declarations. |
| Supplemental release guidance | None. `docs/releases/` may be used when a release benefits from a changelog or migration guide. | No canonical content. These files may briefly summarize release-specific changes, compatibility, and consumer migration paths, and may be corrected, consolidated, archived, or removed when no longer useful. | Public API and behavior contracts, formats, integrations, architecture, release procedure, and the authoritative annotated-tag release record. |
| Release-note history | `docs/releases/` | Mandatory version-matched historical summaries for releases made under the adopted release policy; the note at the immutable tag records that release. | Current APIs, behavior, formats, compatibility contracts, and release procedures. |
| Implemented component inventory | `docs/internal/overview.md` | Current packages and components, their implemented responsibilities, and links to focused internal documents. | Normative architecture, contributor workflow, external contracts, and proposed components. |
| Internal subsystem behavior | Other files under `docs/internal/`, when a subsystem needs durable detail | Implementation flow, internal collaborators and state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, public API definitions, and future package plans. |
| Architectural decision history | `docs/adr/`, when repository-local decisions require records | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, and task sequencing. |
@@ -110,22 +116,22 @@ but must link to its canonical definition rather than restate it.
The [framework format reference](../formats.md) owns exact prompt, profile, and
schema-file contracts. Integration documents own external wire formats.
### Supplemental Release Guidance
### Release Documentation
Files under `docs/releases/` may provide changelog-style summaries and
migration guidance for a particular release. They are navigation and
orientation aids, not canonical owners of public APIs, behavior, formats,
integrations, architecture, release procedure, or other durable facts. When a
reader needs detail beyond a short release-specific note, the release document
must link to the applicable canonical documentation rather than reproduce its
contract.
The [release policy](release.md) owns release requirements; the
[repository release procedure](../release.md) owns their concrete execution.
Version-matched checked-in notes are required for future releases under that
policy. The source tag is the release identity, and the note in its immutable
tree is the historical summary; annotated tag messages complement that record.
The annotated tag message required by the
[release procedure](../release.md#write-the-release-note) remains the
authoritative release record. Supplemental release documents may be corrected,
consolidated, archived, or removed at any time when they are no longer useful,
provided maintained documentation does not depend on them and the annotated
tag record remains intact.
Release notes do not own current API or behavior contracts. Link to the
canonical owners for details instead of reproducing those contracts. Published
notes are not disposable guidance and must not be replaced under the same
version. Corrections belong in a subsequent release.
Adoption is prospective. Previously published tags and historical notes retain
their original records and are not reformatted or rewritten to satisfy the new
requirements.
### Security Topics
@@ -178,9 +184,8 @@ durable owners, update incoming links, and archive or remove the roadmap
according to repository practice. Do not preserve completed roadmaps as a
second current-state reference.
Supplemental release documents may likewise be removed without preserving a
replacement. Before removal, update maintained incoming links so current
documentation does not depend on an optional historical guide.
Retain release notes as historical records under the release policy. Do not
apply temporary-roadmap retirement rules to published release notes.
Before completing documentation work:

146
docs/policy/release.md Normal file
View File

@@ -0,0 +1,146 @@
# Release Policy
**Base Policy Version:** 1.0
**Last Updated:** 2026-09-23
## Purpose And Adoption Scope
This policy owns Promptkit's release requirements and compatibility rules.
The [release procedure](../release.md) owns the commands used to satisfy them.
This scoped adoption applies to releases prepared after its adoption on
2026-09-23. Published tags and historical notes are not rewritten to retrofit
the new note format.
## Release Model And Compatibility
Promptkit publishes source-only Go module releases from committed, pushed
`main` candidates through annotated `vMAJOR.MINOR.PATCH` tags. It publishes
no binaries, hosted releases, or other artifacts and uses maintainer-run
validation rather than hosted CI. Versions must be unused locally and on
`origin`; the existing procedure accepts stable version tags only.
Before v1, minor releases may change the public API and patches preserve
compatibility within their minor line, subject to the catalog-data provision
below. Release notes must identify compatibility impact, public API changes,
and required consumer action. The source tag identifies the exact release;
the checked-in note at that tag records its historical summary.
## Catalog Profile Compatibility
External catalog profile data may be added, removed, renamed, or modified
without deprecation or advance notice. This exception is limited to profile
data imported from the maintained catalog modules. It does not weaken ordinary
compatibility for Promptkit's public Go API, configuration and format semantics,
runtime behavior, catalog-module APIs, manifest schema, backend definitions, or
consumer-owned definitions.
Within the catalog versions selected and tested by a Promptkit release, and
unless a consumer overrides the name or its base, Promptkit keeps the literal
profile IDs `promptkit-openrouter-standard` and
`promptkit-openrouter-auto` available. The names do not promise a permanent
model, backend, routing behavior, settings, cost, output, or upstream
availability. The [framework format reference](../formats.md#built-in-profile-catalog)
owns their current definitions.
`v0.10.0` is the initial minor release for this compatibility transition.
Afterward, catalog-only updates and adjustments to the current selections may
ship in a patch release when all other compatibility obligations remain intact.
External profile removals can require consumer action; they are not automatic
migrations.
Catalog adoption and release validation remain local and offline. They do not
probe provider endpoints, request model lists, or make inference calls.
## Release Documentation
Every new release must include `docs/releases/<version>.md` in the tagged
source, using the complete tag as the filename. Its title contains Promptkit
and that exact version. Use this structure:
```markdown
# Promptkit vMAJOR.MINOR.PATCH
This release ...
## Summary
## Compatibility
## Upgrade
## Changes
```
State “No action required” or “None” where appropriate rather than omitting a
section. Keep exact current contracts in their canonical documents and link
to them. Update affected canonical documentation in the same candidate.
These notes are historical records, not optional disposable guidance. Published
release-note contents must not be replaced under the same version. Corrections
belong in a subsequent release. Annotated-tag messages complement the checked-in
note: generate the annotation from that note and the validated commit rather
than maintaining a second summary.
## Validation And Publication
The [publication script](../../scripts/release.sh) runs the
[local checks](../../scripts/release-check.sh) once for the final candidate.
Retain clean-candidate, synchronized-main, unused-version, module-hygiene,
release-note structure, and tag-target checks. Publish only the selected tag
ref. Changing the candidate requires fresh validation. No provider endpoint
probes or inference calls are release checks.
Release-note content and retirement of completed roadmaps are preparation
responsibilities, not separate human sign-off gates. Preserve planned and
deferred work and repair links. Ordinary change review covers unintended files,
secrets, and private data; do not repeat it as a release-specific approval.
A successful tag push to origin completes the release. Never wait for CI to
finish or succeed. CI results, remote read-back, and Go module availability
are informational follow-ups, not completion gates. An ambiguous push result
requires confirming publication on origin, not waiting for CI.
## Immutability And Failure Recovery
Never move, replace, recreate, or delete a published tag or replace published
release contents. If validation fails before publication, correct the candidate,
rerun the complete checks, and repeat the publication guards. Remove an
unpublished local tag only after confirming it was not published.
If publication or subsequent investigation reveals a defect, leave the
published release intact. Correct the defect on main, select a new patch or
other semantically appropriate version, write its matching release note, and
repeat the complete procedure. If publication status is uncertain, inspect
the authoritative remote before deciding whether a tag is unpublished; do not
overwrite it to retry.
Transient network, authentication, CI, or module-availability failures do not
by themselves warrant a new version. Investigate separately and publish a
corrective release only when published contents actually need changing.
## Policy Exceptions And Deliberate Scope
This adoption follows the maintainer-approved lean workflow rather than every
base-policy guard:
- Collision checks concern origin, the authoritative publication destination,
rather than every configured remote. Local and origin tag checks, synchronized
main, and an explicit selected-ref push protect the actual release channel.
- Automated local validation and publication-state checks replace manual
mechanical inspections and separate human sign-offs. Ordinary change review,
editorial responsibility for release notes, and roadmap housekeeping remain;
repeating them as approvals would add friction without distinct protection.
- Successful tag publication completes this source-only release. CI can be slow
or unreliable, and module availability depends on external services. Neither
is a completion gate. Local candidate validation and explicit tag-target
checks protect publication; ambiguous push results are resolved against origin.
Actual defects are corrected in a new release, never by replacing a tag.
Ignored local files are not prohibited merely because they exist: they are not
included in the tagged commit. The clean-candidate guard and ordinary review of
tracked changes protect the published contents.
The catalog profile provision preserves an already approved compatibility
boundary; it does not extend volatility to backend definitions or runtime
contracts. Further release-governance changes require deliberate approval.

View File

@@ -1,5 +1,9 @@
# Testing Policy
**Base Policy Version:** 1.0
**Last Updated:** 2026-09-23
## Purpose
Our tests exist to make **incorrect changes expensive and correct changes cheap**.
@@ -50,18 +54,25 @@ Examples of appropriate seams include clocks, randomness, subprocesses, remote A
## Test execution requirements
Promptkit currently uses maintainer-run validation rather than hosted CI.
Maintainers run the complete local workflow in the
[development guide](../development.md#maintainer-validation) before accepting
changes. That guide is the canonical owner of exact commands, formatting,
documentation-link validation, and repository-hygiene checks.
Ordinary work requires validation proportionate to its scope and risk. Do not
repeat successful checks for unchanged content merely to create a commit.
Release candidates require the complete local workflow in the
[development guide](../development.md#maintainer-validation). That guide routes
contributors to the executable checks for formatting, documentation links,
and repository hygiene.
Introducing hosted CI later would supplement, not silently redefine, this
documented validation model.
Maintainer validation must include ordinary and race-enabled package tests,
static analysis, a complete build, and execution of both maintained offline
consumer examples. The preparation example protects assembled preparation and
Full release-candidate validation includes race-enabled package tests, explicit
static analysis, and execution with automated semantic checks of the three maintained
offline consumer examples. The test run builds the packages; the examples
also exercise ordinary compilation. Separate ordinary tests and a standalone
build are not additional release gates. Ordinary tests remain useful during
development. The preparation example protects assembled preparation and
inspection behavior. The execution example separately protects assembled
`Run`, injected-client, validation, usage, and result behavior.
`Run`, injected-client, validation, usage, and result behavior. The observation
example protects assembled built-in retries, structured proposal and assessment
runs, and application-owned interpretation of metadata.
Tests in the default suite must be deterministic, offline, and independent of
real credentials. They must not invoke paid APIs, use live network
@@ -87,7 +98,7 @@ Use each test type where it protects a distinct risk:
interaction, while replacing live or nondeterministic external boundaries.
- External-package root tests exercise the public facade as a Go consumer,
while internal package tests own focused implementation behavior.
- The maintained offline preparation and execution examples protect distinct
- The maintained offline preparation, execution, and observation examples protect distinct
representative assembled consumer workflows without contacting a model
provider.
- Fixtures should be minimal, synthetic, versioned with the behavior they

View File

@@ -1,276 +1,82 @@
# Release Procedure
## Release Model
This runbook implements the [release policy](policy/release.md). A successful
push of the selected annotated tag to origin completes the release. Never wait
for CI to finish or succeed.
Promptkit publishes a Go library through source commits and semantic Go module
tags. It does not publish runnable binaries or binary packages and does not
currently use hosted CI. The release maintainer performs and records the
required validation.
## Prepare The Candidate
`v0.1.0` is the initial published release. Later releases use semantic
`vMAJOR.MINOR.PATCH` tags. Before `v1`, minor releases may change the public
API and patch releases preserve compatibility within their minor line. Every
pre-`v1` release note must summarize compatibility, identify public API
changes, and state any action required of consumers.
Required tools are Git, Go with race-detector support, POSIX shell, and Python
3. Publication requires push access to origin. Work from the repository root.
Promptkit releases are source-only. The annotated tag message is the release
note; there is no separate hosted release or binary packaging step.
Select an unused version and create `docs/releases/<version>.md` using the
[required structure](policy/release.md#release-documentation). Describe
compatibility impact and consumer actions and update affected canonical docs.
For catalog updates, record selected catalog versions and any consumer action,
linking to the catalog policy rather than repeating its contracts.
Retire completed roadmaps, preserving planned or deferred work and repairing
links. This is housekeeping, not a separate approval gate. Review changes
through the ordinary development workflow, commit them, and push main to
origin. No repeated release-specific diff review is required.
## Validate And Publish
Run the following with the chosen unused version (replace the placeholder):
```sh
./scripts/release.sh vMAJOR.MINOR.PATCH
```
The [publication script](../scripts/release.sh) checks a clean, synchronized
main candidate, the version and unused tag, and the release note's title and
required headings. It runs the [local checks](../scripts/release-check.sh)
once, rechecks candidate identity and publication state, generates the
annotation from the committed note plus the validated commit, and pushes only
the selected annotated tag.
There is no separate tag-message authoring, example-output inspection,
metadata inspection, or human sign-off. Compatibility and release-note content
remain ordinary editorial responsibilities. Development validation is useful
but does not require a second full run before invoking the publication script.
The script stops successfully after the tag push. CI completion, CI success,
remote read-back, and Go module availability are not release completion gates.
Do not wait or poll for them. Provider probes and inference calls are never
part of release validation.
## Catalog Profile Compatibility
External catalog profile data may be added, removed, renamed, or modified
without deprecation or advance notice. This exception is limited to profile
data imported from the maintained catalog modules. It does not weaken ordinary
compatibility for Promptkit's public Go API, configuration and format semantics,
runtime behavior, catalog-module APIs, manifest schema, backend definitions, or
consumer-owned definitions.
The [release policy](policy/release.md#catalog-profile-compatibility) owns the
catalog compatibility boundary. This heading preserves older incoming links.
Within the catalog versions selected and tested by a Promptkit release, and
unless a consumer overrides the name or its base, Promptkit keeps the literal
profile IDs `promptkit-openrouter-standard` and
`promptkit-openrouter-auto` available. The names do not promise a permanent
model, backend, routing behavior, settings, cost, output, or upstream
availability. The [framework format reference](formats.md#built-in-profile-catalog)
owns their current definitions.
## Failure Recovery
`v0.10.0` is the initial minor release for this compatibility transition.
Afterward, catalog-only updates and adjustments to the current selections may
ship in a patch release when all other compatibility obligations remain intact.
External profile removals can require consumer action; they are not automatic
migrations.
If local checks fail, fix the candidate, commit and push changes, then rerun
the publication command. It never automatically removes a tag.
Catalog adoption and release validation remain local and offline. They do not
probe provider endpoints, request model lists, or make inference calls.
## Establish The Candidate
Choose a version that has not been published and export it as
`RELEASE_VERSION`. Run every command in this procedure from the Promptkit
repository root in the same POSIX shell. Do not reuse `v0.1.0` or another
existing version.
The following guard derives the release commit from `HEAD` and stops on a
missing or malformed version, a checkout other than synchronized `main`,
uncommitted changes, an active Go workspace, a module replacement, a vendor
tree, or an existing local or remote tag:
If a push fails or its result is ambiguous, set `RELEASE_VERSION` to the
attempted version and inspect origin before retrying:
```sh
set -eu
: "${RELEASE_VERSION:?export an unpublished vMAJOR.MINOR.PATCH version}"
if ! printf '%s\n' "$RELEASE_VERSION" |
grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
then
printf '%s\n' "invalid release version: $RELEASE_VERSION" >&2
exit 1
fi
RELEASE_COMMIT=$(git rev-parse --verify 'HEAD^{commit}')
export RELEASE_COMMIT
check_release_candidate() {
test "$(git branch --show-current)" = main
test -z "$(git status --porcelain)"
gowork_value=$(go env GOWORK)
case "$gowork_value" in
''|off) ;;
*)
printf '%s\n' "active Go workspace: $gowork_value" >&2
return 1
;;
esac
test -z "$(git ls-files go.work go.work.sum)"
test ! -e vendor
if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod
then
printf '%s\n' 'go.mod contains a replacement' >&2
return 1
fi
git fetch origin main --tags
test "$RELEASE_COMMIT" = \
"$(git rev-parse --verify 'refs/remotes/origin/main^{commit}')"
if git show-ref --verify --quiet "refs/tags/$RELEASE_VERSION"
then
printf '%s\n' "local tag already exists: $RELEASE_VERSION" >&2
return 1
fi
if test -n "$(
git ls-remote --tags origin \
"refs/tags/$RELEASE_VERSION" \
"refs/tags/$RELEASE_VERSION^{}"
)"
then
printf '%s\n' "remote tag already exists: $RELEASE_VERSION" >&2
return 1
fi
}
check_release_candidate
git ls-remote --tags origin "refs/tags/$RELEASE_VERSION" "refs/tags/$RELEASE_VERSION^{}"
git rev-parse "refs/tags/$RELEASE_VERSION" "refs/tags/$RELEASE_VERSION^{}"
```
Do not continue unless the guard completes successfully. In particular, push
the intended commit through the normal `main` branch workflow before release;
the tag procedure is not a substitute for publishing the source commit.
Matching remote tag and peeled commit values confirm publication, even if the
push command reported an error. If the remote query fails, publication status
is still unknown. Do not delete or recreate the local tag until absence on
origin is confirmed. Once confirmed unpublished, an unsuccessful attempt's
local tag can be removed and the publication command rerun. Never overwrite
a published tag.
## Validate The Candidate
Confirm the module and root package metadata:
```sh
go list -m -f '{{.Path}} {{.GoVersion}}'
go list -f '{{.Name}} {{.ImportPath}}' .
```
The output must be:
```text
gitea.maximumdirect.net/eric/promptkit 1.25.5
promptkit gitea.maximumdirect.net/eric/promptkit
```
As a release prerequisite, run the complete
[maintainer validation workflow](development.md#maintainer-validation) against
the clean candidate. Do not substitute a partial command list: the development
guide owns the tests, race checks, analysis, build, both offline examples,
formatting, Markdown links, generated-output and credential review, and
repository hygiene. Record the successful workflow result with the candidate.
## Write The Release Note
Prepare a plain-text annotated-tag message outside the repository and export
its path as `RELEASE_NOTES_FILE`. Use this form, replacing each summary with
release-specific text; write `None.` when there are no public API changes or
consumer actions:
```text
Promptkit vMAJOR.MINOR.PATCH
Validated commit: full commit ID
Compatibility: compatibility summary
Public API changes: changes or None.
Consumer action: required action or None.
```
After writing it, require all release-note fields, the selected version, and
the validated commit to be present:
```sh
: "${RELEASE_NOTES_FILE:?export the path to the release-note file}"
test -f "$RELEASE_NOTES_FILE"
test -s "$RELEASE_NOTES_FILE"
grep -F "Promptkit $RELEASE_VERSION" "$RELEASE_NOTES_FILE"
grep -F "Validated commit: $RELEASE_COMMIT" "$RELEASE_NOTES_FILE"
grep -F 'Compatibility:' "$RELEASE_NOTES_FILE"
grep -F 'Public API changes:' "$RELEASE_NOTES_FILE"
grep -F 'Consumer action:' "$RELEASE_NOTES_FILE"
```
Inspect the complete message and confirm that it accurately records the
compatibility impact, public API changes, and required consumer action.
When a release introduces or updates the maintained external catalogs, state
the two independently versioned selected data dependencies and any resulting
consumer action. State that external profile additions, removals, renames, and
selection changes are covered by the catalog-profile policy, while the ordinary
public API and configuration contracts remain in force. Link to the current
format and consumer documentation rather than duplicating catalog inventories
or contracts in the release note.
## Create And Inspect The Tag
Run the candidate guard again immediately before tag creation. This ensures
that validation or release-note preparation did not change the checkout and
that the commit is still published and untagged:
```sh
check_release_candidate
```
Create the annotated tag from the prepared release note and bind it explicitly
to the validated commit:
```sh
git tag --annotate "$RELEASE_VERSION" \
--file "$RELEASE_NOTES_FILE" \
"$RELEASE_COMMIT"
```
Inspect both the tag message and its source commit before publication:
```sh
test "$(git cat-file -t "refs/tags/$RELEASE_VERSION")" = tag
git show --no-patch --decorate "refs/tags/$RELEASE_VERSION"
test "$(
git rev-parse --verify "refs/tags/$RELEASE_VERSION^{commit}"
)" = "$RELEASE_COMMIT"
```
If inspection finds an error, delete the unpublished local tag, correct the
release note or candidate, and repeat the guards. Never move or recreate a tag
that has been published.
## Publish The Selected Tag
Push only the selected tag ref. Do not use `git push --tags`:
```sh
git push origin \
"refs/tags/$RELEASE_VERSION:refs/tags/$RELEASE_VERSION"
```
## Verify Publication
Compare the remote annotated-tag object with the local object, then compare the
remote peeled source commit with the validated commit:
```sh
remote_tag=$(
git ls-remote --tags origin "refs/tags/$RELEASE_VERSION" |
awk 'NR == 1 { print $1 }'
)
remote_commit=$(
git ls-remote --tags origin "refs/tags/$RELEASE_VERSION^{}" |
awk 'NR == 1 { print $1 }'
)
test -n "$remote_tag"
test "$remote_tag" = \
"$(git rev-parse --verify "refs/tags/$RELEASE_VERSION")"
test "$remote_commit" = "$RELEASE_COMMIT"
```
Finally, resolve the version as an ordinary Go module in a temporary module
outside this repository and without a workspace or replacement:
```sh
resolution_dir=$(mktemp -d)
(
trap 'rm -rf "$resolution_dir"' 0 1 2 15
cd "$resolution_dir"
GOWORK=off go mod init example.com/promptkit-release-check
GOWORK=off go mod download \
"gitea.maximumdirect.net/eric/promptkit@$RELEASE_VERSION"
resolved_version=$(
GOWORK=off go list -m -f '{{.Version}}' \
"gitea.maximumdirect.net/eric/promptkit@$RELEASE_VERSION"
)
test "$resolved_version" = "$RELEASE_VERSION"
)
```
Promptkit must publish and verify the required version before Scriptorium or
another consumer publishes a release that depends on it. This ordering does
not replace the consumer project's own release procedure. Released consumers
must select the published Promptkit tag through ordinary module resolution,
without a workspace, replacement, vendored Promptkit source, or unpublished
revision.
CI or module-resolution problems may be investigated separately; temporary
service, network, or authentication failures do not require another release.
Only an actual defect requiring changes to published contents warrants a new
appropriate version. See the [recovery policy](policy/release.md#immutability-and-failure-recovery).
## Policy Changes
Document and approve a durable policy change before introducing hosted
automation, binary artifacts, or different release governance. Update this
procedure in the same change so maintainers do not rely on hidden release
requirements.
Update the policy and this procedure together when changing release governance.
Consumers own their dependency-availability and release checks; their readiness
does not delay completion of a Promptkit release.

76
docs/releases/v0.11.0.md Normal file
View File

@@ -0,0 +1,76 @@
# Promptkit v0.11.0
This release adds reliable, observable model execution and changes Promptkit's
license to LGPL-3.0-or-later.
## Summary
- Built-in model calls can recover from selected transient HTTP failures.
- Optional execution observations report attempts, timing, outcomes, and known
token usage, including work completed before a run fails.
- Public errors distinguish transport failures, malformed responses, and retry
exhaustion without parsing diagnostic text.
## Compatibility
The built-in client now defaults to two additional transient attempts. This can
increase latency, request counts, and cost; retries do not guarantee exactly-once
generation. Injected `LLMClient` implementations retain their own retry policy
and are not automatically replayed. Existing generation timeouts apply per
attempt, while the caller context governs the complete operation. See the
[provider integration contract](../integrations/openai-compatible-chat.md).
Public retry and observation controls are additive. Existing successful-result
usage semantics remain unchanged, and operational failures still return no
partial `RunResult`. Observations distinguish unknown usage from reported zero;
they are not a complete billing ledger. See the
[consumer guide](../consumers/pkg-promptkit.md) and
[observation GoDoc](../../observations.go).
Promptkit is now licensed under LGPL-3.0-or-later. Review the
[license summary](../../README.md#license), [LGPL text](../../LICENSE), and
[accompanying GPL text](../../COPYING) before adoption or distribution.
Previously published releases and dependency licenses are unchanged.
## Upgrade
Update the dependency:
```sh
go get gitea.maximumdirect.net/eric/promptkit@v0.11.0
```
To retain single-attempt behavior for built-in generation, set
`Config.TransientRetries` to a pointer to zero. `RunRequest.TransientRetries`
can override the engine choice per request. Review any existing application or
HTTP-transport retry loops to avoid unintentionally multiplying attempts.
Transient retries and structural repairs have separate budgets; provide a
caller deadline when a total execution time limit is needed.
Observation is opt-in through `RunRequest.Observer`, including for prepared
execution. Callbacks are synchronous; shared observers must support concurrent
runs. Injected clients can supply `GenerateResponse.UsagePresence` to explicitly
report known zero counts. No observer or usage-presence changes are required
for existing consumers. See the [consumer guide](../consumers/pkg-promptkit.md)
for adoption and the [offline example](../../examples/go-library/observe/main.go)
for a structured proposal-and-assessment workflow.
## Changes
- Centralized built-in retry coordination with cancellable backoff and provider
retry timing. Retries and structural repair share existing backend capacity;
backoff releases active permits without releasing logical run admission.
- Added `ErrTransport`, `ErrMalformedResponse`, `ErrRetriesExhausted`, and
`RetryExhaustedError`, retaining applicable underlying error identities.
- Added request-scoped lifecycle observations and presence-aware token accounting
for ordinary and prepared execution, without recording prompt/response bodies.
- Isolated delegated operations' observation state and made injected-client
attempt classifications consistent with terminal classifications.
- Added an offline observation example and regression coverage for retry,
repair, capacity, error, and observation interactions.
- Adopted LGPL-3.0-or-later and streamlined the documented local release process.
Backend catalog selections are unchanged from v0.10.0: OpenRouter
`v2.20260922.1` and Rakestrawhome `v2.20260922.0`. See the
[catalog compatibility policy](../policy/release.md#catalog-profile-compatibility)
and [profile inventories](../formats.md#external-profile-inventories).

109
engine.go
View File

@@ -10,6 +10,7 @@ import (
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
internalengine "gitea.maximumdirect.net/eric/promptkit/internal/engine"
"gitea.maximumdirect.net/eric/promptkit/internal/observation"
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
)
@@ -61,6 +62,19 @@ var (
// [GenerationError]. Errors returned by an injected LLMClient remain
// available through errors.Is.
ErrLLMGenerate = errors.New("failed to generate output")
// ErrTransport identifies a failed HTTP request by the built-in model
// client. Generation errors in this category also match ErrLLMGenerate and
// retain the transport cause for errors.Is and errors.As. It does not imply
// that the provider received no work.
ErrTransport = errors.New("model transport failed")
// ErrMalformedResponse identifies an invalid successful model response or
// a nil successful response from an injected client. Generation errors in
// this category also match ErrLLMGenerate.
ErrMalformedResponse = errors.New("malformed model response")
// ErrRetriesExhausted identifies a built-in generation sequence whose
// positive transient retry budget ended with another eligible HTTP status.
// RetryExhaustedError retains the final cause and actual attempt count.
ErrRetriesExhausted = errors.New("transient model retries exhausted")
// ErrValidation identifies an operational failure to load or compile a
// schema or validate output. A completed validation whose Status is
// ValidationFailed is returned in RunResult without this error.
@@ -104,6 +118,11 @@ type Config struct {
// Timeout or the 10-minute default. The supplied client is not mutated. This
// field is ignored when WithLLMClient is used.
HTTPClient *http.Client
// TransientRetries selects additional built-in HTTP attempts after eligible
// provider statuses. Nil defaults to two; zero disables retries. Negative
// values are invalid. Injected LLMClient implementations are never retried
// automatically.
TransientRetries *int
}
// Option customizes engine construction.
@@ -292,7 +311,8 @@ func WithSchemaFile(path string) Option {
// NewEngine returns an error matching ErrInvalidConfig for invalid
// configuration, options, or backend-capacity policies. Each constructed
// Engine has independent backend-capacity pools. Construction does not perform
// model requests or require credentials.
// model requests or require credentials. A negative Config.TransientRetries
// is invalid.
func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
var options engineOptions
for _, opt := range opts {
@@ -305,7 +325,7 @@ func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
}
assembled, err := internalengine.New(
internalengine.Config{PromptDir: cfg.PromptDir, ProfileDir: cfg.ProfileDir, SchemaDir: cfg.SchemaDir, Timeout: cfg.Timeout, HTTPClient: cfg.HTTPClient},
internalengine.Config{PromptDir: cfg.PromptDir, ProfileDir: cfg.ProfileDir, SchemaDir: cfg.SchemaDir, Timeout: cfg.Timeout, HTTPClient: cfg.HTTPClient, TransientRetries: copyIntPtr(cfg.TransientRetries)},
options.internal,
)
if err != nil {
@@ -419,8 +439,9 @@ func (e *Engine) InspectProfile(ctx context.Context, profileID string) (*Profile
// applicable. Cancellation is passed to the active collaborator and is
// reported in the applicable operation category; no general errors.Is
// relationship to ctx.Err is promised. Prepare returns no partial result on
// error.
// error. It validates RunRequest.TransientRetries but makes no model attempts.
func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, error) {
ctx = observation.WithoutExecutionState(ctx)
if e == nil || e.internal == nil {
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
}
@@ -452,8 +473,11 @@ func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, err
// returns does not invalidate the handle or propagate to RunPrepared.
// PrepareExecution returns the same error categories as [Engine.Prepare] and
// returns no handle on error. A nil Engine returns an error matching
// ErrInvalidConfig.
// ErrInvalidConfig. The effective transient retry budget is frozen privately
// for RunPrepared and is absent from Details. A supplied Observer is retained
// privately for RunPrepared; preparation itself emits no events.
func (e *Engine) PrepareExecution(ctx context.Context, req RunRequest) (*PreparedExecution, error) {
ctx = observation.WithoutExecutionState(ctx)
if e == nil || e.internal == nil {
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
}
@@ -482,19 +506,55 @@ func (e *Engine) PrepareExecution(ctx context.Context, req RunRequest) (*Prepare
// repair attempts. An inability to generate or validate returns an error and
// no partial result.
//
// The built-in client retries eligible provider statuses using the effective
// Config or request budget. With R additional transient attempts and P repair
// generations, one run can dispatch at most (R+1)×(P+1) Promptkit-controlled
// model attempts. Each dispatched attempt receives its own generation and
// HTTP-client timeout; the caller context governs the full operation.
// Injected LLMClient implementations are not retried automatically.
// With Observer, Run emits started before facade request conversion or a nil
// engine check, and finished on every normal return after public error mapping
// and resource release. The event RunID matches a successful result. Without
// Observer, result timing retains its existing convention; observed wall
// duration can be slightly longer because it spans the facade boundary.
//
// Run can return every error category documented by [Engine.Prepare], plus
// ErrCapacityExceeded and ErrLLMGenerate. An engine admission rejection is
// ErrCapacityExceeded and ErrLLMGenerate. Built-in HTTP execution failures
// additionally match ErrTransport; malformed successful responses and nil
// successful injected responses additionally match ErrMalformedResponse.
// An engine admission rejection is
// discoverable as [CapacityError] and still matches ErrCapacityExceeded. It
// occurs before artifacts, schemas, rendering, or model generation because the
// selected backend's admission capacity is full; it does not match
// ErrInvalidRequest or ErrLLMGenerate. A built-in OpenAI-compatible non-2xx
// response is discoverable as [GenerationError]. Errors from injected clients
// remain available through errors.Is. Cancellation while waiting for
// response is discoverable as [GenerationError]. Eligible-status exhaustion
// with a positive budget is discoverable as [RetryExhaustedError]. Injected
// errors remain available through errors.Is. Cancellation while waiting for
// model-generation capacity matches both ErrLLMGenerate and the context error.
// Cancellation otherwise follows the active collaborator's documented
// behavior. A nil Engine returns ErrInvalidConfig. Run returns no partial
// result on error.
func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
ctx = observation.WithoutExecutionState(ctx)
if req.Observer == nil {
return e.run(ctx, req)
}
recorder, idErr := observation.New(func(event domain.ExecutionEvent) { req.Observer(fromDomainExecutionEvent(event)) })
recorder.Start()
if idErr != nil {
err := fmt.Errorf("failed to create run id: %w", idErr)
terminal := observedFinish(nil, err)
recorder.Finish(observation.FinishData{Outcome: terminal.Outcome, Failure: terminal.Failure})
return nil, err
}
result, err := e.run(observation.WithRecorder(ctx, recorder), req)
terminal := observedFinish(result, err)
recorder.Finish(observation.FinishData{Outcome: terminal.Outcome, Failure: terminal.Failure,
StatusCode: terminal.StatusCode, RetriesExhausted: terminal.RetriesExhausted})
return result, err
}
func (e *Engine) run(ctx context.Context, req RunRequest) (*RunResult, error) {
if e == nil || e.internal == nil {
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
}
@@ -532,10 +592,21 @@ func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
// preserving documented collaborator and context identities. An engine
// admission rejection is discoverable as [CapacityError] and still matches
// ErrCapacityExceeded. A built-in OpenAI-compatible non-2xx response is
// discoverable as [GenerationError]. A completed content-validation rejection,
// including repair exhaustion, is returned in RunResult, not as an operational
// error. An operational error returns no partial RunResult.
// discoverable as [GenerationError]. Built-in HTTP execution failures match
// ErrTransport; malformed successful responses and nil successful injected
// responses match ErrMalformedResponse. Positive-budget eligible-status
// exhaustion is discoverable as [RetryExhaustedError]. A completed
// content-validation rejection, including repair exhaustion, is returned in
// RunResult, not as an operational
// error. RunPrepared uses the retry choice frozen during PrepareExecution. An
// operational error returns no partial RunResult. A retained Observer begins
// only after a successful atomic claim, with identities from the frozen
// snapshot, and receives a terminal event on normal return after public error
// mapping. Invalid, foreign, discarded, or reused handles emit no events.
// Callbacks run outside the handle and capacity locks; panics propagate and
// do not promise a terminal event.
func (e *Engine) RunPrepared(ctx context.Context, prepared *PreparedExecution) (*RunResult, error) {
ctx = observation.WithoutExecutionState(ctx)
if e == nil || e.internal == nil {
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
}
@@ -544,9 +615,21 @@ func (e *Engine) RunPrepared(ctx context.Context, prepared *PreparedExecution) (
if prepared != nil {
internal = prepared.internal
}
result, err := e.internal.RunPrepared(ctx, internal)
var recorder *observation.Recorder
result, err := e.internal.RunPrepared(observation.WithClaimSink(ctx, &recorder), internal)
if err != nil {
return nil, mapPublicError(err)
mapped := mapPublicError(err)
if recorder != nil {
terminal := observedFinish(nil, mapped)
recorder.Finish(observation.FinishData{Outcome: terminal.Outcome, Failure: terminal.Failure,
StatusCode: terminal.StatusCode, RetriesExhausted: terminal.RetriesExhausted})
}
return nil, mapped
}
return fromDomainRunResult(result), nil
publicResult := fromDomainRunResult(result)
if recorder != nil {
terminal := observedFinish(publicResult, nil)
recorder.Finish(observation.FinishData{Outcome: terminal.Outcome})
}
return publicResult, nil
}

View File

@@ -6,6 +6,7 @@ import (
"strings"
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
"gitea.maximumdirect.net/eric/promptkit/internal/generation"
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
@@ -21,6 +22,17 @@ func mapPublicError(err error) error {
strings.TrimSpace(internalCapacityError.BackendID) != "" {
return &CapacityError{BackendID: internalCapacityError.BackendID}
}
var internalExhausted *generation.ExhaustedError
if errors.As(err, &internalExhausted) && internalExhausted != nil {
mapped := newRetryExhaustedError(internalExhausted.Attempts(), mapPublicError(internalExhausted.Unwrap()))
if errors.Is(err, usecase.ErrValidation) && !errors.Is(mapped, ErrValidation) {
return fmt.Errorf("%w: %w", ErrValidation, mapped)
}
if publicErr := publicErrorFor(err); publicErr != nil && !errors.Is(mapped, publicErr) {
return fmt.Errorf("%w: %w", publicErr, mapped)
}
return mapped
}
publicErr := publicErrorFor(err)
var providerHTTPError *llm.ProviderHTTPError
if errors.As(err, &providerHTTPError) && providerHTTPError != nil {
@@ -38,7 +50,14 @@ func mapPublicError(err error) error {
if publicErr == nil {
return err
}
return fmt.Errorf("%w: %w", publicErr, err)
switch {
case errors.Is(err, llm.ErrTransport):
return fmt.Errorf("%w: %w: %w", publicErr, ErrTransport, err)
case errors.Is(err, llm.ErrMalformedResponse):
return fmt.Errorf("%w: %w: %w", publicErr, ErrMalformedResponse, err)
default:
return fmt.Errorf("%w: %w", publicErr, err)
}
}
func publicErrorFor(err error) error {

View File

@@ -73,3 +73,10 @@ func TestMapPublicErrorPreservesValidationAroundGenerationError(t *testing.T) {
t.Fatalf("mapped error exposes internal ProviderHTTPError: %v", err)
}
}
func TestMapPublicErrorDoesNotTreatRequestEncodingAsTransport(t *testing.T) {
err := mapPublicError(fmt.Errorf("%w: %w", usecase.ErrLLMGenerate, llm.ErrRequestFailed))
if !errors.Is(err, ErrLLMGenerate) || errors.Is(err, ErrTransport) {
t.Fatalf("request encoding category = %v", err)
}
}

View File

@@ -0,0 +1,157 @@
package main
import (
"context"
"embed"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"gitea.maximumdirect.net/eric/promptkit"
)
//go:embed prompts/*.yaml schemas/*.json
var assets embed.FS
type scriptedTransport struct {
proposalCalls int
}
func (t *scriptedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if err := req.Context().Err(); err != nil {
return nil, err
}
var request struct {
Model string `json:"model"`
}
if err := json.NewDecoder(req.Body).Decode(&request); err != nil {
return nil, err
}
switch request.Model {
case "proposal-model":
t.proposalCalls++
if t.proposalCalls == 1 {
return httpResponse(http.StatusServiceUnavailable, `{"error":{}}`), nil
}
return modelResponse(`{"title":"Queue cleanup","risk":"low"}`, 10, 8), nil
case "assessment-model":
return modelResponse(`{"recommendation":"accept","confidence":0.9}`, 7, 5), nil
default:
return nil, fmt.Errorf("unexpected model in offline transport")
}
}
func modelResponse(content string, promptTokens, completionTokens int) *http.Response {
body, _ := json.Marshal(map[string]any{
"choices": []any{map[string]any{"message": map[string]string{"content": content}}},
"usage": map[string]int{"prompt_tokens": promptTokens, "completion_tokens": completionTokens},
})
return httpResponse(http.StatusOK, string(body))
}
func httpResponse(status int, body string) *http.Response {
return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}
}
type eventLog struct{ events []promptkit.ExecutionEvent }
func (l *eventLog) record(event promptkit.ExecutionEvent) { l.events = append(l.events, event) }
func (l *eventLog) terminal() (promptkit.ExecutionEvent, error) {
if len(l.events) == 0 || l.events[len(l.events)-1].Kind != promptkit.ExecutionFinished {
return promptkit.ExecutionEvent{}, fmt.Errorf("execution did not finish")
}
return l.events[len(l.events)-1], nil
}
type summary struct {
ProposalAttempts uint64 `json:"proposal_attempts"`
ProposalTransientRetries uint64 `json:"proposal_transient_retries"`
ProposalUsageComplete bool `json:"proposal_usage_complete"`
AssessmentAttempts uint64 `json:"assessment_attempts"`
AssessmentTransientRetries uint64 `json:"assessment_transient_retries"`
AssessmentOutcome string `json:"assessment_outcome"`
}
func run() (summary, error) {
transport := &scriptedTransport{}
engine, err := promptkit.NewEngine(promptkit.Config{HTTPClient: &http.Client{Transport: transport}},
promptkit.WithPromptFS(assets, "prompts"),
promptkit.WithSchemaFS(assets, "schemas"),
promptkit.WithBackend(promptkit.Backend{ID: "offline-backend", Endpoint: "https://example.invalid/v1", ConcurrencyLimit: 1}),
promptkit.WithProfiles(
promptkit.Profile{ID: "proposal-profile", BackendID: "offline-backend", Model: "proposal-model"},
promptkit.Profile{ID: "assessment-profile", BackendID: "offline-backend", Model: "assessment-model"},
))
if err != nil {
return summary{}, err
}
var proposalLog eventLog
proposal, err := engine.Run(context.Background(), promptkit.RunRequest{
PromptID: "example.proposal", Inputs: map[string]promptkit.ArtifactRef{"idea": promptkit.Inline("Reduce queue cleanup toil.")}, Observer: proposalLog.record,
})
if err != nil {
return summary{}, err
}
if !proposal.Validation.IsValid {
return summary{}, fmt.Errorf("proposal failed structural validation")
}
proposalTerminal, err := proposalLog.terminal()
if err != nil {
return summary{}, err
}
var assessmentLog eventLog
assessment, err := engine.Run(context.Background(), promptkit.RunRequest{
PromptID: "example.assessment", Inputs: map[string]promptkit.ArtifactRef{"proposal": promptkit.Inline(proposal.RawOutput)}, Observer: assessmentLog.record,
})
if err != nil {
return summary{}, err
}
if !assessment.Validation.IsValid {
return summary{}, fmt.Errorf("assessment failed structural validation")
}
assessmentTerminal, err := assessmentLog.terminal()
if err != nil {
return summary{}, err
}
var proposalValue struct {
Risk string `json:"risk"`
}
var assessmentValue struct {
Recommendation string `json:"recommendation"`
}
if err := json.Unmarshal([]byte(proposal.RawOutput), &proposalValue); err != nil {
return summary{}, err
}
if err := json.Unmarshal([]byte(assessment.RawOutput), &assessmentValue); err != nil {
return summary{}, err
}
decision := "rejected"
if proposalValue.Risk == "low" && assessmentValue.Recommendation == "accept" {
decision = "accepted"
}
return summary{
ProposalAttempts: proposalTerminal.Attempts,
ProposalTransientRetries: proposalTerminal.TransientRetries,
ProposalUsageComplete: proposalTerminal.Usage.PromptTokens.Complete,
AssessmentAttempts: assessmentTerminal.Attempts,
AssessmentTransientRetries: assessmentTerminal.TransientRetries,
AssessmentOutcome: decision,
}, nil
}
func main() {
value, err := run()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if err := json.NewEncoder(os.Stdout).Encode(value); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

View File

@@ -0,0 +1,17 @@
id: example.assessment
version: "1.0.0"
default_profile: assessment-profile
description: Assess a structured proposal independently of schema validity.
inputs:
- name: proposal
required: true
content_type: application/json
messages:
- role: system
content: Return a concise assessment as JSON.
- role: user
content: '{{input "proposal"}}'
output:
format: json
validation_mode: json_schema
schema_path: assessment.schema.json

View File

@@ -0,0 +1,17 @@
id: example.proposal
version: "1.0.0"
default_profile: proposal-profile
description: Propose a bounded improvement from a synthetic idea.
inputs:
- name: idea
required: true
content_type: text/plain
messages:
- role: system
content: Return a concise proposal as JSON.
- role: user
content: '{{input "idea"}}'
output:
format: json
validation_mode: json_schema
schema_path: proposal.schema.json

View File

@@ -0,0 +1,10 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["recommendation", "confidence"],
"properties": {
"recommendation": {"type": "string"},
"confidence": {"type": "number"}
},
"additionalProperties": false
}

View File

@@ -0,0 +1,10 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["title", "risk"],
"properties": {
"title": {"type": "string"},
"risk": {"type": "string"}
},
"additionalProperties": false
}

View File

@@ -94,6 +94,89 @@ func TestBuiltInRepairGenerationError(t *testing.T) {
assertGenerationError(t, err, http.StatusUnprocessableEntity, codeMarker, typeMarker, messageMarker)
}
func TestBuiltInGenerationFailureCategories(t *testing.T) {
transportCause := errors.New("private transport diagnostic")
config := contractConfig(frameworkSchemaDir)
config.HTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, transportCause
})}
engine, err := promptkit.NewEngine(config)
if err != nil {
t.Fatal(err)
}
result, err := engine.Run(context.Background(), generationErrorRunRequest())
if result != nil || !errors.Is(err, promptkit.ErrLLMGenerate) || !errors.Is(err, promptkit.ErrTransport) || !errors.Is(err, transportCause) {
t.Fatalf("transport result=%#v, error=%v", result, err)
}
if errors.Is(err, promptkit.ErrMalformedResponse) || strings.Contains(fmt.Sprintf("%#v", err), transportCause.Error()) {
t.Fatalf("transport category or formatting = %v", err)
}
for _, tc := range []struct {
name string
body string
}{
{name: "invalid framing", body: `{"choices":[]}{}`},
{name: "missing choices", body: `{"choices":[]}`},
{name: "missing content", body: `{"choices":[{"message":{}}]}`},
} {
t.Run(tc.name, func(t *testing.T) {
engine := newBuiltInGenerationErrorEngine(t, http.StatusOK, tc.body)
result, err := engine.Run(context.Background(), generationErrorRunRequest())
if result != nil || !errors.Is(err, promptkit.ErrLLMGenerate) || !errors.Is(err, promptkit.ErrMalformedResponse) || errors.Is(err, promptkit.ErrTransport) {
t.Fatalf("malformed result=%#v, error=%v", result, err)
}
})
}
}
func TestBuiltInGenerationPreservesContextIdentity(t *testing.T) {
for _, tc := range []struct {
name string
cause error
}{
{name: "canceled", cause: context.Canceled},
{name: "deadline", cause: context.DeadlineExceeded},
} {
t.Run(tc.name, func(t *testing.T) {
config := contractConfig(frameworkSchemaDir)
config.HTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, tc.cause
})}
engine, err := promptkit.NewEngine(config)
if err != nil {
t.Fatal(err)
}
result, err := engine.Run(context.Background(), generationErrorRunRequest())
if result != nil || !errors.Is(err, tc.cause) || !errors.Is(err, promptkit.ErrLLMGenerate) || !errors.Is(err, promptkit.ErrTransport) {
t.Fatalf("context result=%#v, error=%v", result, err)
}
})
}
}
func TestInjectedGenerationFailureCategories(t *testing.T) {
for _, tc := range []struct {
name string
client *fakeLLMClient
malformed bool
}{
{name: "nil response", client: &fakeLLMClient{}, malformed: true},
{name: "arbitrary error", client: &fakeLLMClient{err: errors.New("injected failure")}},
} {
t.Run(tc.name, func(t *testing.T) {
engine := newContractEngineWithOptions(t, frameworkSchemaDir, promptkit.WithLLMClient(tc.client))
result, err := engine.Run(context.Background(), generationErrorRunRequest())
if result != nil || !errors.Is(err, promptkit.ErrLLMGenerate) || errors.Is(err, promptkit.ErrTransport) || errors.Is(err, promptkit.ErrMalformedResponse) != tc.malformed {
t.Fatalf("injected result=%#v, error=%v", result, err)
}
if tc.client.err != nil && !errors.Is(err, tc.client.err) {
t.Fatalf("injected cause lost: %v", err)
}
})
}
}
func assertGenerationError(t *testing.T, err error, statusCode int, code, providerType, message string) {
t.Helper()
@@ -125,6 +208,8 @@ func newBuiltInGenerationErrorEngine(t *testing.T, statusCode int, body string)
t.Helper()
config := contractConfig(frameworkSchemaDir)
noRetries := 0
config.TransientRetries = &noRetries
config.HTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: statusCode,

View File

@@ -28,13 +28,13 @@ func (c *client) Generate(
ctx context.Context,
req domain.GenerateRequest,
) (*domain.GenerateResponse, error) {
pool := c.manager.getPool(req.Target.BackendID)
if pool == nil {
if c.manager.getPool(req.Target.BackendID) == nil {
return c.next.Generate(ctx, req)
}
if err := pool.acquire(ctx); err != nil {
release, err := c.manager.Acquire(ctx, req.Target.BackendID)
if err != nil {
return nil, err
}
defer pool.releaseActive()
defer release()
return c.next.Generate(ctx, req)
}

View File

@@ -107,6 +107,26 @@ func (m *Manager) getPool(backendID string) *pool {
return m.pools[backendID]
}
// Limited reports whether a backend uses an active-generation pool.
func (m *Manager) Limited(backendID string) bool { return m.getPool(backendID) != nil }
// Acquire obtains one active-generation permit for a backend. Its release
// function is idempotent. Backends without a configured pool are unlimited.
func (m *Manager) Acquire(ctx context.Context, backendID string) (func(), error) {
if err := ctx.Err(); err != nil {
return nil, err
}
pool := m.getPool(backendID)
if pool == nil {
return releaseNothing, nil
}
if err := pool.acquire(ctx); err != nil {
return nil, err
}
var once sync.Once
return func() { once.Do(pool.releaseActive) }, nil
}
func (p *pool) acquire(ctx context.Context) error {
p.mu.Lock()
if err := ctx.Err(); err != nil {

View File

@@ -60,6 +60,26 @@ func TestNewManagerRejectsInvalidPolicies(t *testing.T) {
}
}
func TestManagerAcquireReleaseIsIdempotent(t *testing.T) {
manager, err := NewManager(map[string]domain.BackendCapacityPolicy{
"limited": {ConcurrencyLimit: 1},
})
if err != nil {
t.Fatal(err)
}
release, err := manager.Acquire(context.Background(), "limited")
if err != nil {
t.Fatal(err)
}
release()
release()
other, err := manager.Acquire(context.Background(), "limited")
if err != nil {
t.Fatal(err)
}
other()
}
func TestManagerAdmissionIsBoundedAndReleaseIsIdempotent(t *testing.T) {
policies := map[string]domain.BackendCapacityPolicy{
"limited": {

View File

@@ -70,6 +70,8 @@ type RunRequest struct {
Execution *ExecutionTargetOverride
Validation *OutputContract
AppendedMessages []RenderedMessage
TransientRetries *int
Observer ExecutionObserver `json:"-"`
}
// RunResult represents the complete result of a prompt execution run.
@@ -282,6 +284,8 @@ type GenerateRequest struct {
Target ExecutionTarget
TargetPresence ExecutionTargetPresence
StructuredOutput *StructuredOutputSpec
TransientRetries uint64
Generation uint64
}
// StructuredOutputType indicates which provider-level output mode is requested.
@@ -306,8 +310,18 @@ type StructuredOutputJSONSpec struct {
// GenerateResponse is the response received from the LLM client.
type GenerateResponse struct {
Content string
Usage TokenUsage
Content string
Usage TokenUsage
UsagePresence *TokenUsagePresence
}
// TokenUsagePresence records which token fields a provider reported.
type TokenUsagePresence struct {
PromptTokens bool
CompletionTokens bool
TotalTokens bool
CachedTokens bool
CacheWriteTokens bool
}
// TokenUsage tracks token consumption.

View File

@@ -0,0 +1,90 @@
package domain
import "time"
// ExecutionObserver receives one immutable metadata event synchronously.
type ExecutionObserver func(ExecutionEvent)
type ExecutionEventKind string
const (
ExecutionStarted ExecutionEventKind = "started"
ExecutionAttempt ExecutionEventKind = "attempt"
ExecutionRetryDelay ExecutionEventKind = "retry_delay"
ExecutionFinished ExecutionEventKind = "finished"
)
type ExecutionOutcome string
const (
ExecutionOutcomeSuccess ExecutionOutcome = "success"
ExecutionOutcomeInvalidOutput ExecutionOutcome = "invalid_output"
ExecutionOutcomeError ExecutionOutcome = "error"
)
type ExecutionFailureKind string
const (
ExecutionFailureNone ExecutionFailureKind = ""
ExecutionFailureCanceled ExecutionFailureKind = "canceled"
ExecutionFailureDeadline ExecutionFailureKind = "deadline"
ExecutionFailureTransport ExecutionFailureKind = "transport"
ExecutionFailureProviderStatus ExecutionFailureKind = "provider_status"
ExecutionFailureMalformedResponse ExecutionFailureKind = "malformed_response"
ExecutionFailureCapacity ExecutionFailureKind = "capacity"
ExecutionFailureInvalidConfig ExecutionFailureKind = "invalid_config"
ExecutionFailureInvalidRequest ExecutionFailureKind = "invalid_request"
ExecutionFailurePromptNotFound ExecutionFailureKind = "prompt_not_found"
ExecutionFailureProfileNotFound ExecutionFailureKind = "profile_not_found"
ExecutionFailurePromptLoad ExecutionFailureKind = "prompt_load"
ExecutionFailureProfileLoad ExecutionFailureKind = "profile_load"
ExecutionFailureArtifactLoad ExecutionFailureKind = "artifact_load"
ExecutionFailurePromptRender ExecutionFailureKind = "prompt_render"
ExecutionFailureValidation ExecutionFailureKind = "validation"
ExecutionFailureGeneration ExecutionFailureKind = "generation"
ExecutionFailureOther ExecutionFailureKind = "other"
)
// ObservedTokenCount distinguishes omitted usage from reported zero.
type ObservedTokenCount struct {
Value int
Known bool
Complete bool
}
// ObservedTokenUsage reports presence-aware token counts.
type ObservedTokenUsage struct {
PromptTokens ObservedTokenCount
CompletionTokens ObservedTokenCount
TotalTokens ObservedTokenCount
CachedTokens ObservedTokenCount
CacheWriteTokens ObservedTokenCount
}
// ExecutionEvent contains metadata only; it carries no mutable containers or
// error values. Nonterminal events leave aggregate counters at zero.
type ExecutionEvent struct {
Kind ExecutionEventKind
RunID string
Time time.Time
PromptID string
PromptVersion string
ProfileID string
BackendID string
Model string
Generation uint64
Attempt uint64
Dispatched bool
CapacityWait time.Duration
GenerationDuration time.Duration
RetryDelay time.Duration
Duration time.Duration
Outcome ExecutionOutcome
Failure ExecutionFailureKind
StatusCode int
RetriesExhausted bool
Usage ObservedTokenUsage
Attempts uint64
TransientRetries uint64
RepairCalls uint64
}

View File

@@ -0,0 +1,15 @@
package domain
import "fmt"
// ResolveTransientRetries selects a nonnegative retry budget. A nil selection
// inherits the supplied default.
func ResolveTransientRetries(defaultBudget uint64, selection *int) (uint64, error) {
if selection == nil {
return defaultBudget, nil
}
if *selection < 0 {
return 0, fmt.Errorf("transient retries must not be negative")
}
return uint64(*selection), nil
}

View File

@@ -19,6 +19,7 @@ import (
"gitea.maximumdirect.net/eric/promptkit/internal/catalog"
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
"gitea.maximumdirect.net/eric/promptkit/internal/generation"
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
"gitea.maximumdirect.net/eric/promptkit/internal/prompt"
@@ -29,11 +30,12 @@ import (
// Config carries construction settings selected by the public facade.
type Config struct {
PromptDir string
ProfileDir string
SchemaDir string
Timeout time.Duration
HTTPClient *http.Client
PromptDir string
ProfileDir string
SchemaDir string
Timeout time.Duration
HTTPClient *http.Client
TransientRetries *int
}
// Options collects construction choices from the public facade.
@@ -157,6 +159,10 @@ type Engine struct {
// New assembles an engine from facade-selected configuration and collaborators.
func New(cfg Config, options Options) (*Engine, error) {
defaultRetries, err := domain.ResolveTransientRetries(2, cfg.TransientRetries)
if err != nil {
return nil, err
}
promptDefinitions := options.promptDefinitions
if !options.promptSource {
if strings.TrimSpace(cfg.PromptDir) == "" {
@@ -196,20 +202,22 @@ func New(cfg Config, options Options) (*Engine, error) {
}
validator = validate.NewStandardValidator(schemaDir)
}
client := options.llmClient
if client == nil {
client, err = llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{Timeout: cfg.Timeout, HTTPClient: cfg.HTTPClient})
if err != nil {
return nil, err
var client llm.Client
if options.llmClient == nil {
builtIn, newErr := llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{Timeout: cfg.Timeout, HTTPClient: cfg.HTTPClient})
if newErr != nil {
return nil, newErr
}
client = generation.NewBuiltIn(manager, builtIn)
} else {
client = generation.NewInjected(manager, options.llmClient)
}
client = capacity.NewClient(manager, client)
reader := options.artifactReader
if !options.artifactSource {
reader = artifact.NewCompositeReader()
}
return &Engine{runner: usecase.NewRunnerWithRepairer(promptDefinitions, profiles, registry, reader, prompt.NewGoRenderer(), client, validator, usecase.NewDefaultOutputRepairer(client), manager)}, nil
return &Engine{runner: usecase.NewRunnerWithRepairer(promptDefinitions, profiles, registry, reader, prompt.NewGoRenderer(), client, validator, usecase.NewDefaultOutputRepairer(client), manager, defaultRetries)}, nil
}
func newProfileRepository(profileDir string, options Options, maintained profile.Repository) profile.Repository {

View File

@@ -0,0 +1,294 @@
// Package generation coordinates model attempts, backend permits, and bounded
// built-in HTTP retries for one generation request.
package generation
import (
"context"
"errors"
"fmt"
"math/rand/v2"
"time"
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
"gitea.maximumdirect.net/eric/promptkit/internal/observation"
)
const (
initialBackoff = 500 * time.Millisecond
maximumBackoff = 8 * time.Second
)
// Coordinator implements the internal model client using the existing backend
// capacity manager. Exactly one of builtIn and injected is selected.
type Coordinator struct {
manager *capacity.Manager
builtIn *llm.OpenAICompatibleClient
injected llm.Client
now func() time.Time
jitter func(time.Duration) time.Duration
wait func(context.Context, time.Duration) error
}
// NewBuiltIn selects the built-in HTTP retry path.
func NewBuiltIn(manager *capacity.Manager, client *llm.OpenAICompatibleClient) *Coordinator {
return &Coordinator{manager: manager, builtIn: client, now: time.Now, jitter: randomJitter, wait: waitContext}
}
// NewInjected selects one opaque client dispatch with no automatic retry.
func NewInjected(manager *capacity.Manager, client llm.Client) *Coordinator {
return &Coordinator{manager: manager, injected: client, now: time.Now, jitter: randomJitter, wait: waitContext}
}
// ExhaustedError retains the final eligible status failure and the number of
// attempts actually dispatched.
type ExhaustedError struct {
attempts uint64
cause error
}
func (e *ExhaustedError) Error() string {
if e == nil {
return "transient model retries exhausted"
}
return fmt.Sprintf("transient model retries exhausted after %d attempts", e.attempts)
}
func (e *ExhaustedError) Unwrap() error {
if e == nil {
return nil
}
return e.cause
}
func (e *ExhaustedError) Attempts() uint64 {
if e == nil {
return 0
}
return e.attempts
}
// Generate executes either one opaque client call or a frozen built-in HTTP
// sequence with one permit per actual dispatch.
func (c *Coordinator) Generate(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error) {
if c.injected != nil {
return c.generateInjected(ctx, req)
}
prepared, err := c.builtIn.PrepareGeneration(req)
if err != nil {
return nil, err
}
var attempts, retriesUsed uint64
recorder := observation.FromContext(ctx)
generation := req.Generation
if generation == 0 {
generation = 1
}
for {
if err := ctx.Err(); err != nil {
return nil, err
}
ordinal := attempts + 1
outcome, dispatched, capacityWait, generationDuration := c.execute(ctx, req.Target.BackendID, prepared)
if dispatched {
attempts++
}
if recorder != nil {
recorder.Attempt(observation.AttemptData{Generation: generation, Attempt: ordinal,
Dispatched: dispatched, CapacityWait: capacityWait, GenerationDuration: generationDuration,
Outcome: attemptOutcome(outcome.Err), Failure: attemptFailure(outcome.Err),
StatusCode: outcome.StatusCode, Usage: outcome.Usage, UsagePresence: outcome.UsagePresence})
}
if outcome.Err == nil {
if err := ctx.Err(); err != nil {
return nil, err
}
return outcome.Response, nil
}
if !dispatched || !eligibleStatus(outcome) {
return nil, outcome.Err
}
if retriesUsed == req.TransientRetries {
if retriesUsed == 0 {
return nil, outcome.Err
}
return nil, &ExhaustedError{attempts: attempts, cause: outcome.Err}
}
if err := ctx.Err(); err != nil {
return nil, err
}
retryOrdinal := retriesUsed + 1
delay := retryDelay(c.backoff(retryOrdinal), outcome.RetryNotBefore, c.now())
waitStart := c.now()
waitErr := c.wait(ctx, delay)
if recorder != nil {
recorder.Delay(observation.DelayData{Generation: generation, Attempt: attempts,
Elapsed: elapsed(waitStart, c.now()), Failure: attemptFailure(waitErr)})
}
if waitErr != nil {
return nil, waitErr
}
if err := ctx.Err(); err != nil {
return nil, err
}
retriesUsed++
}
}
func (c *Coordinator) generateInjected(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error) {
recorder := observation.FromContext(ctx)
generation := req.Generation
if generation == 0 {
generation = 1
}
start := c.now()
release, err := c.manager.Acquire(ctx, req.Target.BackendID)
capacityWait := c.capacityWait(req.Target.BackendID, start)
if err != nil {
if recorder != nil {
recorder.Attempt(observation.AttemptData{Generation: generation, Attempt: 1, CapacityWait: capacityWait, Outcome: domain.ExecutionOutcomeError, Failure: attemptFailure(err)})
}
return nil, err
}
defer release()
if err := ctx.Err(); err != nil {
release()
if recorder != nil {
recorder.Attempt(observation.AttemptData{Generation: generation, Attempt: 1, CapacityWait: capacityWait, Outcome: domain.ExecutionOutcomeError, Failure: attemptFailure(err)})
}
return nil, err
}
dispatchStart := c.now()
response, err := c.injected.Generate(ctx, req)
duration := elapsed(dispatchStart, c.now())
if err == nil && response == nil {
err = llm.ErrMalformedResponse
}
release()
if recorder != nil {
data := observation.AttemptData{Generation: generation, Attempt: 1, Dispatched: true, CapacityWait: capacityWait, GenerationDuration: duration,
Outcome: attemptOutcome(err), Failure: attemptFailure(err)}
if err == nil {
data.Usage = response.Usage
data.UsagePresence = response.UsagePresence
}
recorder.Attempt(data)
}
if err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
return response, nil
}
func (c *Coordinator) execute(ctx context.Context, backendID string, prepared *llm.PreparedGeneration) (outcome llm.AttemptOutcome, dispatched bool, capacityWait, generationDuration time.Duration) {
start := c.now()
release, err := c.manager.Acquire(ctx, backendID)
capacityWait = c.capacityWait(backendID, start)
if err != nil {
return llm.AttemptOutcome{Err: err}, false, capacityWait, 0
}
defer release()
if err := ctx.Err(); err != nil {
return llm.AttemptOutcome{Err: err}, false, capacityWait, 0
}
dispatchStart := c.now()
outcome = prepared.Execute(ctx)
return outcome, true, capacityWait, elapsed(dispatchStart, c.now())
}
func (c *Coordinator) capacityWait(backendID string, start time.Time) time.Duration {
if !c.manager.Limited(backendID) {
return 0
}
return elapsed(start, c.now())
}
func elapsed(start, end time.Time) time.Duration {
if d := end.Sub(start); d > 0 {
return d
}
return 0
}
func attemptOutcome(err error) domain.ExecutionOutcome {
if err == nil {
return domain.ExecutionOutcomeSuccess
}
return domain.ExecutionOutcomeError
}
func attemptFailure(err error) domain.ExecutionFailureKind {
if err == nil {
return domain.ExecutionFailureNone
}
if errors.Is(err, context.Canceled) {
return domain.ExecutionFailureCanceled
}
if errors.Is(err, context.DeadlineExceeded) {
return domain.ExecutionFailureDeadline
}
if errors.Is(err, llm.ErrUnexpectedStatus) {
return domain.ExecutionFailureProviderStatus
}
if errors.Is(err, llm.ErrTransport) {
return domain.ExecutionFailureTransport
}
if errors.Is(err, llm.ErrMalformedResponse) {
return domain.ExecutionFailureMalformedResponse
}
if errors.Is(err, llm.ErrInvalidRequest) {
return domain.ExecutionFailureInvalidRequest
}
return domain.ExecutionFailureGeneration
}
func eligibleStatus(outcome llm.AttemptOutcome) bool {
if !errors.Is(outcome.Err, llm.ErrUnexpectedStatus) {
return false
}
switch outcome.StatusCode {
case 429, 500, 502, 503, 504:
return true
default:
return false
}
}
func (c *Coordinator) backoff(ordinal uint64) time.Duration {
base := initialBackoff
for step := uint64(1); step < ordinal && base < maximumBackoff; step++ {
base *= 2
}
half := base / 2
return half + c.jitter(half)
}
func retryDelay(local time.Duration, notBefore, now time.Time) time.Duration {
if providerDelay := notBefore.Sub(now); providerDelay > local {
return providerDelay
}
return local
}
func randomJitter(max time.Duration) time.Duration {
return time.Duration(rand.Int64N(int64(max) + 1))
}
func waitContext(ctx context.Context, delay time.Duration) error {
if err := ctx.Err(); err != nil {
return err
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return ctx.Err()
}
}

View File

@@ -0,0 +1,278 @@
package generation
import (
"context"
"errors"
"io"
"math"
"net/http"
"strings"
"sync/atomic"
"testing"
"time"
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
)
type transportFunc func(*http.Request) (*http.Response, error)
func (f transportFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
func response(status int, body string) *http.Response {
return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}
}
func testRequest(retries uint64) domain.GenerateRequest {
return domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hello"}}},
Target: domain.ExecutionTarget{BackendID: "limited", Endpoint: "https://example.invalid/v1", Model: "model"},
TransientRetries: retries,
}
}
func testCoordinator(t *testing.T, transport transportFunc) *Coordinator {
t.Helper()
manager, err := capacity.NewManager(map[string]domain.BackendCapacityPolicy{
"limited": {ConcurrencyLimit: 1, QueueCapacity: 1},
})
if err != nil {
t.Fatal(err)
}
client, err := llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{HTTPClient: &http.Client{Transport: transport}})
if err != nil {
t.Fatal(err)
}
coordinator := NewBuiltIn(manager, client)
coordinator.jitter = func(time.Duration) time.Duration { return 0 }
coordinator.wait = func(ctx context.Context, _ time.Duration) error { return ctx.Err() }
return coordinator
}
func TestBuiltInRetryEligibilityAndExhaustion(t *testing.T) {
for _, tc := range []struct {
status int
eligible bool
}{
{status: 429, eligible: true}, {status: 500, eligible: true},
{status: 502, eligible: true}, {status: 503, eligible: true},
{status: 504, eligible: true}, {status: 400}, {status: 501},
} {
t.Run(http.StatusText(tc.status), func(t *testing.T) {
calls := 0
coordinator := testCoordinator(t, func(*http.Request) (*http.Response, error) {
calls++
if calls == 1 {
return response(tc.status, `{"error":{}}`), nil
}
return response(200, `{"choices":[{"message":{"content":"ok"}}]}`), nil
})
result, err := coordinator.Generate(context.Background(), testRequest(1))
if tc.eligible {
if err != nil || result == nil || result.Content != "ok" || calls != 2 {
t.Fatalf("result=%+v, error=%v, calls=%d", result, err, calls)
}
} else if result != nil || err == nil || calls != 1 {
t.Fatalf("result=%+v, error=%v, calls=%d", result, err, calls)
}
})
}
for _, budget := range []uint64{0, 2} {
calls := 0
coordinator := testCoordinator(t, func(*http.Request) (*http.Response, error) {
calls++
return response(503, `{"error":{}}`), nil
})
_, err := coordinator.Generate(context.Background(), testRequest(budget))
var exhausted *ExhaustedError
if !errors.Is(err, llm.ErrUnexpectedStatus) || calls != int(budget+1) || errors.As(err, &exhausted) != (budget > 0) {
t.Fatalf("budget=%d calls=%d error=%v", budget, calls, err)
}
if budget > 0 && exhausted.Attempts() != budget+1 {
t.Fatalf("attempts=%d", exhausted.Attempts())
}
}
calls := 0
coordinator := testCoordinator(t, func(*http.Request) (*http.Response, error) {
calls++
if calls == 1 {
return response(503, `{"error":{}}`), nil
}
return response(400, `{"error":{}}`), nil
})
_, err := coordinator.Generate(context.Background(), testRequest(3))
var exhausted *ExhaustedError
if calls != 2 || !errors.Is(err, llm.ErrUnexpectedStatus) || errors.As(err, &exhausted) {
t.Fatalf("later permanent failure: calls=%d, error=%v", calls, err)
}
}
func TestBuiltInRetryWaitHonorsContextAndProviderMinimum(t *testing.T) {
when := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC)
coordinator := testCoordinator(t, func(*http.Request) (*http.Response, error) { return response(503, `{"error":{}}`), nil })
if got := coordinator.backoff(1); got != 250*time.Millisecond {
t.Fatalf("first backoff=%v", got)
}
if got := coordinator.backoff(math.MaxUint64); got != 4*time.Second {
t.Fatalf("capped backoff=%v", got)
}
coordinator.jitter = func(max time.Duration) time.Duration { return max }
if got := coordinator.backoff(math.MaxUint64); got != 8*time.Second {
t.Fatalf("maximum equal jitter=%v", got)
}
if got := retryDelay(500*time.Millisecond, when.Add(3*time.Second), when); got != 3*time.Second {
t.Fatalf("provider minimum=%v", got)
}
if got := retryDelay(500*time.Millisecond, when.Add(-time.Second), when); got != 500*time.Millisecond {
t.Fatalf("past provider minimum=%v", got)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
calls := 0
coordinator = testCoordinator(t, func(*http.Request) (*http.Response, error) {
calls++
return response(503, `{"error":{}}`), nil
})
coordinator.wait = func(ctx context.Context, delay time.Duration) error {
if delay < 250*time.Millisecond {
t.Fatalf("delay=%v", delay)
}
cancel()
return ctx.Err()
}
_, err := coordinator.Generate(ctx, testRequest(2))
if !errors.Is(err, context.Canceled) || calls != 1 {
t.Fatalf("canceled wait: error=%v calls=%d", err, calls)
}
}
func TestBuiltInRetryUsesSeparateAttemptDeadlines(t *testing.T) {
var contexts []context.Context
coordinator := testCoordinator(t, func(req *http.Request) (*http.Response, error) {
contexts = append(contexts, req.Context())
if len(contexts) == 1 {
return response(503, `{"error":{}}`), nil
}
return response(200, `{"choices":[{"message":{"content":"ok"}}]}`), nil
})
req := testRequest(1)
req.Target.TimeoutSeconds = 1
if _, err := coordinator.Generate(context.Background(), req); err != nil {
t.Fatal(err)
}
if len(contexts) != 2 || contexts[0] == contexts[1] {
t.Fatalf("attempt contexts=%d", len(contexts))
}
for _, ctx := range contexts {
if _, ok := ctx.Deadline(); !ok || !errors.Is(ctx.Err(), context.Canceled) {
t.Fatalf("attempt deadline/error=%t/%v", ok, ctx.Err())
}
}
}
func TestCanceledCapacityWaitDispatchesNothing(t *testing.T) {
calls := 0
coordinator := testCoordinator(t, func(*http.Request) (*http.Response, error) {
calls++
return response(200, `{"choices":[{"message":{"content":"ok"}}]}`), nil
})
release, err := coordinator.manager.Acquire(context.Background(), "limited")
if err != nil {
t.Fatal(err)
}
defer release()
ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1)
go func() { _, err := coordinator.Generate(ctx, testRequest(2)); result <- err }()
cancel()
if err := <-result; !errors.Is(err, context.Canceled) || calls != 0 {
t.Fatalf("capacity cancellation: error=%v calls=%d", err, calls)
}
}
func TestBuiltInRetryFreesPermitAndFreezesCredential(t *testing.T) {
const envName = "PROMPTKIT_GENERATION_RETRY_TEST_KEY"
t.Setenv(envName, "first-key")
var calls atomic.Int64
var firstAuth, retryAuth string
coordinator := testCoordinator(t, func(req *http.Request) (*http.Response, error) {
switch calls.Add(1) {
case 1:
firstAuth = req.Header.Get("Authorization")
return response(503, `{"error":{}}`), nil
case 2:
return response(200, `{"choices":[{"message":{"content":"other"}}]}`), nil
default:
retryAuth = req.Header.Get("Authorization")
return response(200, `{"choices":[{"message":{"content":"retry"}}]}`), nil
}
})
entered := make(chan struct{})
resume := make(chan struct{})
coordinator.wait = func(ctx context.Context, _ time.Duration) error {
close(entered)
select {
case <-resume:
return ctx.Err()
case <-ctx.Done():
return ctx.Err()
}
}
req := testRequest(1)
req.Target.APIKeyEnv = envName
first := make(chan error, 1)
go func() { _, err := coordinator.Generate(context.Background(), req); first <- err }()
<-entered
t.Setenv(envName, "later-key")
other, err := coordinator.Generate(context.Background(), testRequest(0))
if err != nil || other == nil || other.Content != "other" {
t.Fatalf("other call while backoff blocked = %+v, %v", other, err)
}
close(resume)
if err := <-first; err != nil {
t.Fatal(err)
}
if calls.Load() != 3 || firstAuth != "Bearer first-key" || retryAuth != firstAuth {
t.Fatalf("calls=%d, first auth=%q, retry auth=%q", calls.Load(), firstAuth, retryAuth)
}
}
func TestInjectedClientDispatchesOnceAndReleasesAfterPanic(t *testing.T) {
manager, err := capacity.NewManager(map[string]domain.BackendCapacityPolicy{"limited": {ConcurrencyLimit: 1}})
if err != nil {
t.Fatal(err)
}
client := &injectedClient{panicFirst: true}
coordinator := NewInjected(manager, client)
func() {
defer func() {
if recover() == nil {
t.Fatal("expected injected panic")
}
}()
_, _ = coordinator.Generate(context.Background(), testRequest(5))
}()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
result, err := coordinator.Generate(ctx, testRequest(5))
if err != nil || result == nil || client.calls != 2 {
t.Fatalf("after panic result=%+v error=%v calls=%d", result, err, client.calls)
}
}
type injectedClient struct {
calls int
panicFirst bool
}
func (c *injectedClient) Generate(context.Context, domain.GenerateRequest) (*domain.GenerateResponse, error) {
c.calls++
if c.panicFirst && c.calls == 1 {
panic("injected panic")
}
return &domain.GenerateResponse{Content: "ok"}, nil
}

View File

@@ -0,0 +1,201 @@
package generation
import (
"context"
"errors"
"net/http"
"testing"
"time"
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
"gitea.maximumdirect.net/eric/promptkit/internal/observation"
)
func recordEvents(t *testing.T, ctx context.Context, events *[]domain.ExecutionEvent) context.Context {
t.Helper()
recorder, err := observation.New(func(event domain.ExecutionEvent) { *events = append(*events, event) })
if err != nil {
t.Fatal(err)
}
return observation.WithRecorder(ctx, recorder)
}
func TestObservedRetryAndExhaustion(t *testing.T) {
for _, status := range []int{200, 503} {
calls := 0
coordinator := testCoordinator(t, func(*http.Request) (*http.Response, error) {
calls++
if calls == 1 || status == 503 {
return response(503, `{"error":{}}`), nil
}
return response(200, `{"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":0,"completion_tokens":2}}`), nil
})
var events []domain.ExecutionEvent
ctx := recordEvents(t, context.Background(), &events)
_, err := coordinator.Generate(ctx, testRequest(1))
if status == 200 && err != nil {
t.Fatal(err)
}
if status == 503 {
var exhausted *ExhaustedError
if !errors.As(err, &exhausted) {
t.Fatalf("error=%v", err)
}
}
if len(events) != 3 || events[0].Kind != domain.ExecutionAttempt || events[1].Kind != domain.ExecutionRetryDelay || events[2].Kind != domain.ExecutionAttempt {
t.Fatalf("events=%+v", events)
}
if events[0].Attempt != 1 || events[1].Attempt != 1 || events[2].Attempt != 2 || !events[0].Dispatched || !events[2].Dispatched {
t.Fatalf("indexes=%+v", events)
}
if events[0].Failure != domain.ExecutionFailureProviderStatus || events[1].RetryDelay < 0 {
t.Fatalf("first=%+v delay=%+v", events[0], events[1])
}
if status == 200 && (!events[2].Usage.PromptTokens.Known || events[2].Usage.PromptTokens.Value != 0 || events[2].Usage.TotalTokens.Known) {
t.Fatalf("usage=%+v", events[2].Usage)
}
}
}
func TestObservedInterruptedWaitAndCanceledCapacity(t *testing.T) {
coordinator := testCoordinator(t, func(*http.Request) (*http.Response, error) { return response(503, `{"error":{}}`), nil })
ctx, cancel := context.WithCancel(context.Background())
var events []domain.ExecutionEvent
ctx = recordEvents(t, ctx, &events)
coordinator.wait = func(ctx context.Context, _ time.Duration) error { cancel(); return ctx.Err() }
_, err := coordinator.Generate(ctx, testRequest(1))
if !errors.Is(err, context.Canceled) || len(events) != 2 || events[1].Kind != domain.ExecutionRetryDelay || events[1].Failure != domain.ExecutionFailureCanceled {
t.Fatalf("error=%v events=%+v", err, events)
}
coordinator = testCoordinator(t, func(*http.Request) (*http.Response, error) { t.Fatal("unexpected dispatch"); return nil, nil })
release, err := coordinator.manager.Acquire(context.Background(), "limited")
if err != nil {
t.Fatal(err)
}
defer release()
ctx, cancel = context.WithCancel(context.Background())
events = nil
ctx = recordEvents(t, ctx, &events)
entered := make(chan struct{})
ctx = &notifyingContext{Context: ctx, entered: entered}
done := make(chan error, 1)
go func() { _, err := coordinator.Generate(ctx, testRequest(0)); done <- err }()
<-entered
cancel()
if err := <-done; !errors.Is(err, context.Canceled) {
t.Fatalf("error=%v", err)
}
if len(events) != 1 || events[0].Dispatched || events[0].Failure != domain.ExecutionFailureCanceled {
t.Fatalf("events=%+v", events)
}
}
type notifyingContext struct {
context.Context
entered chan struct{}
}
func (c *notifyingContext) Done() <-chan struct{} {
select {
case <-c.entered:
default:
close(c.entered)
}
return c.Context.Done()
}
func TestObservedCallbackReleasesPermitAndCanCancelRetry(t *testing.T) {
coordinator := testCoordinator(t, func(*http.Request) (*http.Response, error) { return response(503, `{"error":{}}`), nil })
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var events []domain.ExecutionEvent
recorder, err := observation.New(func(event domain.ExecutionEvent) {
events = append(events, event)
if event.Kind != domain.ExecutionAttempt {
return
}
permitCtx, stop := context.WithTimeout(context.Background(), time.Second)
defer stop()
release, err := coordinator.manager.Acquire(permitCtx, "limited")
if err != nil {
t.Errorf("callback could not acquire permit: %v", err)
} else {
release()
}
cancel()
})
if err != nil {
t.Fatal(err)
}
_, err = coordinator.Generate(observation.WithRecorder(ctx, recorder), testRequest(2))
if !errors.Is(err, context.Canceled) || len(events) != 1 {
t.Fatalf("error=%v events=%+v", err, events)
}
}
type fixedClient struct {
response *domain.GenerateResponse
err error
}
func (c fixedClient) Generate(context.Context, domain.GenerateRequest) (*domain.GenerateResponse, error) {
return c.response, c.err
}
func TestObservedInjectedUsageAndRepairGeneration(t *testing.T) {
manager, err := capacity.NewManager(nil)
if err != nil {
t.Fatal(err)
}
var events []domain.ExecutionEvent
ctx := recordEvents(t, context.Background(), &events)
legacy := NewInjected(manager, fixedClient{response: &domain.GenerateResponse{Usage: domain.TokenUsage{PromptTokens: 3}}})
if _, err := legacy.Generate(ctx, testRequest(4)); err != nil {
t.Fatal(err)
}
presence := &domain.TokenUsagePresence{CompletionTokens: true}
repair := NewInjected(manager, fixedClient{response: &domain.GenerateResponse{UsagePresence: presence}})
req := testRequest(4)
req.Generation = 2
if _, err := repair.Generate(ctx, req); err != nil {
t.Fatal(err)
}
if len(events) != 2 || !events[0].Usage.PromptTokens.Known || events[0].Usage.CompletionTokens.Known || !events[1].Usage.CompletionTokens.Known || events[1].Usage.CompletionTokens.Value != 0 || events[1].Usage.PromptTokens.Known {
t.Fatalf("events=%+v", events)
}
if events[1].Generation != 2 || events[1].Attempt != 1 {
t.Fatalf("repair indexes=%+v", events[1])
}
}
func TestObservedSuccessfulGenerationBeforeRepairFailure(t *testing.T) {
manager, err := capacity.NewManager(nil)
if err != nil {
t.Fatal(err)
}
var events []domain.ExecutionEvent
recorder, err := observation.New(func(event domain.ExecutionEvent) { events = append(events, event) })
if err != nil {
t.Fatal(err)
}
ctx := observation.WithRecorder(context.Background(), recorder)
initial := NewInjected(manager, fixedClient{response: &domain.GenerateResponse{Usage: domain.TokenUsage{PromptTokens: 5}}})
if _, err := initial.Generate(ctx, testRequest(0)); err != nil {
t.Fatal(err)
}
repair := NewInjected(manager, fixedClient{err: errors.New("repair unavailable")})
req := testRequest(0)
req.Generation = 2
if _, err := repair.Generate(ctx, req); err == nil {
t.Fatal("expected repair failure")
}
recorder.Finish(observation.FinishData{Outcome: domain.ExecutionOutcomeError, Failure: domain.ExecutionFailureGeneration})
if len(events) != 3 || events[0].Outcome != domain.ExecutionOutcomeSuccess || events[1].Outcome != domain.ExecutionOutcomeError || events[2].Attempts != 2 || events[2].RepairCalls != 1 {
t.Fatalf("events=%+v", events)
}
if got := events[2].Usage.PromptTokens; got.Value != 5 || !got.Known || got.Complete {
t.Fatalf("prompt usage=%+v", got)
}
}

View File

@@ -7,9 +7,11 @@ import (
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
@@ -21,6 +23,7 @@ var (
ErrInvalidConfig = errors.New("invalid llm client configuration")
ErrInvalidRequest = errors.New("invalid generate request")
ErrRequestFailed = errors.New("llm request failed")
ErrTransport = errors.New("llm transport failed")
ErrUnexpectedStatus = errors.New("llm returned non-success status")
ErrMalformedResponse = errors.New("malformed llm response")
)
@@ -36,7 +39,7 @@ func (e *requestFailedError) Error() string {
}
func (e *requestFailedError) Unwrap() []error {
return []error{ErrRequestFailed, e.cause}
return []error{ErrRequestFailed, ErrTransport, e.cause}
}
type OpenAICompatibleConfig struct {
@@ -86,6 +89,40 @@ func NewOpenAICompatibleClient(cfg OpenAICompatibleConfig) (*OpenAICompatibleCli
}
func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error) {
prepared, err := c.PrepareGeneration(req)
if err != nil {
return nil, err
}
outcome := prepared.Execute(ctx)
if outcome.Err != nil {
return nil, outcome.Err
}
return outcome.Response, nil
}
// PreparedGeneration holds one encoded request and resolved authentication for
// repeated attempts. Each Execute call creates a fresh HTTP request and context.
type PreparedGeneration struct {
endpoint string
payload []byte
apiKey string
timeoutSeconds int
httpClient *http.Client
}
// AttemptOutcome describes one HTTP execution without exposing response bodies
// or headers to the orchestration layer.
type AttemptOutcome struct {
Response *domain.GenerateResponse
Err error
StatusCode int
Usage domain.TokenUsage
UsagePresence *domain.TokenUsagePresence
RetryNotBefore time.Time
}
// PrepareGeneration validates and freezes one built-in request before dispatch.
func (c *OpenAICompatibleClient) PrepareGeneration(req domain.GenerateRequest) (*PreparedGeneration, error) {
if err := domain.ValidateExecutionTargetSettings(req.Target); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
}
@@ -117,22 +154,6 @@ func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.Genera
if err != nil {
return nil, fmt.Errorf("%w: failed to encode request: %v", ErrRequestFailed, err)
}
requestContext := ctx
if req.Target.TimeoutSeconds > 0 {
var cancel context.CancelFunc
requestContext, cancel = context.WithTimeout(
ctx,
time.Duration(req.Target.TimeoutSeconds)*time.Second,
)
defer cancel()
}
httpReq, err := http.NewRequestWithContext(requestContext, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("%w: failed to create request: %v", ErrRequestFailed, err)
}
httpReq.Header.Set("Content-Type", "application/json")
apiKey := strings.TrimSpace(req.Target.APIKey)
envName := strings.TrimSpace(req.Target.APIKeyEnv)
if apiKey == "" && envName != "" {
@@ -144,55 +165,140 @@ func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.Genera
}
return nil, fmt.Errorf("%w: api key is required", ErrInvalidRequest)
}
if apiKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
}
httpClient := c.httpClient
if httpClient == nil {
httpClient = &http.Client{Timeout: defaults.LLMRequestTimeoutDefault}
}
return &PreparedGeneration{
endpoint: endpoint, payload: payload, apiKey: apiKey,
timeoutSeconds: req.Target.TimeoutSeconds, httpClient: httpClient,
}, nil
}
httpResp, err := httpClient.Do(httpReq)
if err != nil {
return nil, &requestFailedError{cause: err}
// Execute dispatches one attempt. The returned response is non-nil only when
// the successful envelope contains a usable first choice.
func (p *PreparedGeneration) Execute(ctx context.Context) AttemptOutcome {
requestContext := ctx
if p.timeoutSeconds > 0 {
var cancel context.CancelFunc
requestContext, cancel = context.WithTimeout(ctx, time.Duration(p.timeoutSeconds)*time.Second)
defer cancel()
}
httpReq, err := http.NewRequestWithContext(requestContext, http.MethodPost, p.endpoint, bytes.NewReader(p.payload))
if err != nil {
return AttemptOutcome{Err: fmt.Errorf("%w: failed to create request: %v", ErrRequestFailed, err)}
}
httpReq.Header.Set("Content-Type", "application/json")
if p.apiKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)
}
httpResp, err := p.httpClient.Do(httpReq)
if err != nil {
return AttemptOutcome{Err: &requestFailedError{cause: err}}
}
receivedAt := time.Now()
defer httpResp.Body.Close()
outcome := AttemptOutcome{StatusCode: httpResp.StatusCode}
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, providerHTTPErrorFromBody(
outcome.RetryNotBefore = retryNotBefore(httpResp.Header.Values("Retry-After"), receivedAt)
outcome.Err = providerHTTPErrorFromBody(
httpResp.StatusCode,
httpResp.ContentLength,
httpResp.Body,
)
if err := observedContextError(requestContext, ctx, outcome.Err); err != nil {
outcome.Err = err
}
return outcome
}
if httpResp.ContentLength > maxOpenAIChatResponseBytes {
return nil, openAIChatResponseTooLargeError()
outcome.Err = openAIChatResponseTooLargeError()
return outcome
}
wireResp, err := decodeOpenAIChatResponse(httpResp.Body)
if err != nil {
return nil, err
outcome.Err = err
if contextErr := observedContextError(requestContext, ctx, err); contextErr != nil {
outcome.Err = contextErr
}
return outcome
}
outcome.Usage = domain.TokenUsage{
PromptTokens: tokenCount(wireResp.Usage.PromptTokens), CompletionTokens: tokenCount(wireResp.Usage.CompletionTokens),
TotalTokens: tokenCount(wireResp.Usage.TotalTokens), CachedTokens: tokenCount(wireResp.Usage.PromptTokensDetails.CachedTokens),
CacheWriteTokens: tokenCount(wireResp.Usage.CacheWriteTokens),
}
outcome.UsagePresence = &domain.TokenUsagePresence{
PromptTokens: wireResp.Usage.PromptTokens != nil, CompletionTokens: wireResp.Usage.CompletionTokens != nil,
TotalTokens: wireResp.Usage.TotalTokens != nil, CachedTokens: wireResp.Usage.PromptTokensDetails.CachedTokens != nil,
CacheWriteTokens: wireResp.Usage.CacheWriteTokens != nil,
}
if len(wireResp.Choices) == 0 {
return nil, fmt.Errorf("%w: no choices returned", ErrMalformedResponse)
outcome.Err = fmt.Errorf("%w: no choices returned", ErrMalformedResponse)
return outcome
}
content := wireResp.Choices[0].Message.Content
if content == nil {
return nil, fmt.Errorf("%w: first choice has missing message content", ErrMalformedResponse)
outcome.Err = fmt.Errorf("%w: first choice has missing message content", ErrMalformedResponse)
return outcome
}
outcome.Response = &domain.GenerateResponse{Content: *content, Usage: outcome.Usage, UsagePresence: outcome.UsagePresence}
return outcome
}
return &domain.GenerateResponse{
Content: *content,
Usage: domain.TokenUsage{
PromptTokens: wireResp.Usage.PromptTokens,
CompletionTokens: wireResp.Usage.CompletionTokens,
TotalTokens: wireResp.Usage.TotalTokens,
CachedTokens: wireResp.Usage.PromptTokensDetails.CachedTokens,
CacheWriteTokens: wireResp.Usage.CacheWriteTokens,
},
}, nil
func observedContextError(attempt, caller context.Context, cause error) error {
if err := caller.Err(); err != nil {
return &requestFailedError{cause: err}
}
if err := attempt.Err(); err != nil {
return &requestFailedError{cause: err}
}
if errors.Is(cause, context.Canceled) || errors.Is(cause, context.DeadlineExceeded) {
return &requestFailedError{cause: cause}
}
return nil
}
func retryNotBefore(values []string, receivedAt time.Time) time.Time {
if len(values) == 0 {
return time.Time{}
}
value := strings.TrimSpace(values[0])
if value == "" {
return time.Time{}
}
digits := true
for _, r := range value {
if r < '0' || r > '9' {
digits = false
break
}
}
if digits {
seconds, err := strconv.ParseUint(value, 10, 64)
if err != nil || seconds > uint64(math.MaxInt64/int64(time.Second)) {
return receivedAt.Add(time.Duration(math.MaxInt64))
}
return receivedAt.Add(time.Duration(seconds) * time.Second)
}
if when, err := http.ParseTime(value); err == nil {
if when.Before(receivedAt) {
return receivedAt
}
return when
}
return time.Time{}
}
func tokenCount(value *int) int {
if value == nil {
return 0
}
return *value
}
func decodeOpenAIChatResponse(body io.Reader) (openAIChatResponse, error) {
@@ -204,6 +310,9 @@ func decodeOpenAIChatResponse(body io.Reader) (openAIChatResponse, error) {
var response openAIChatResponse
if err := decoder.Decode(&response); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return openAIChatResponse{}, &requestFailedError{cause: err}
}
if limited.N == 0 {
return openAIChatResponse{}, openAIChatResponseTooLargeError()
}
@@ -215,6 +324,9 @@ func decodeOpenAIChatResponse(body io.Reader) (openAIChatResponse, error) {
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return openAIChatResponse{}, &requestFailedError{cause: err}
}
if limited.N == 0 {
return openAIChatResponse{}, openAIChatResponseTooLargeError()
}
@@ -388,13 +500,13 @@ type openAIChatResponse struct {
Message openAIChatResponseMessage `json:"message"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
PromptTokens *int `json:"prompt_tokens"`
CompletionTokens *int `json:"completion_tokens"`
TotalTokens *int `json:"total_tokens"`
PromptTokensDetails struct {
CachedTokens int `json:"cached_tokens"`
CachedTokens *int `json:"cached_tokens"`
} `json:"prompt_tokens_details"`
CacheWriteTokens int `json:"cache_write_tokens"`
CacheWriteTokens *int `json:"cache_write_tokens"`
} `json:"usage"`
}

View File

@@ -881,6 +881,44 @@ func checkCacheUsageMapping(t *testing.T) {
}
}
func TestOpenAICompatibleUsagePresence(t *testing.T) {
for _, tc := range []struct {
name string
usage string
want domain.TokenUsage
presence domain.TokenUsagePresence
}{
{name: "absent"},
{name: "null", usage: `"usage":null,`},
{name: "partial nested", usage: `"usage":{"prompt_tokens":7,"prompt_tokens_details":{"cached_tokens":0}},`,
want: domain.TokenUsage{PromptTokens: 7}, presence: domain.TokenUsagePresence{PromptTokens: true, CachedTokens: true}},
{name: "explicit null fields", usage: `"usage":{"prompt_tokens":null,"completion_tokens":0,"prompt_tokens_details":{"cached_tokens":null}},`,
presence: domain.TokenUsagePresence{CompletionTokens: true}},
{name: "all reported", usage: `"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3,"prompt_tokens_details":{"cached_tokens":4},"cache_write_tokens":5},`,
want: domain.TokenUsage{PromptTokens: 1, CompletionTokens: 2, TotalTokens: 3, CachedTokens: 4, CacheWriteTokens: 5},
presence: domain.TokenUsagePresence{PromptTokens: true, CompletionTokens: true, TotalTokens: true, CachedTokens: true, CacheWriteTokens: true}},
} {
t.Run(tc.name, func(t *testing.T) {
provider := newRecordingProvider(t)
provider.respond(http.StatusOK, `{`+tc.usage+`"choices":[{"message":{"content":"ok"}}]}`)
client := newProviderClient(t, provider, OpenAICompatibleConfig{Model: "model"})
resp, err := client.Generate(context.Background(), ordinaryGenerateRequest())
if err != nil {
t.Fatal(err)
}
if resp.Usage != tc.want || resp.UsagePresence == nil || *resp.UsagePresence != tc.presence {
t.Fatalf("usage=%+v, presence=%+v", resp.Usage, resp.UsagePresence)
}
})
}
provider := newRecordingProvider(t)
provider.respond(http.StatusOK, `{"usage":{"prompt_tokens":"seven"},"choices":[{"message":{"content":"ok"}}]}`)
client := newProviderClient(t, provider, OpenAICompatibleConfig{Model: "model"})
if resp, err := client.Generate(context.Background(), ordinaryGenerateRequest()); resp != nil || !errors.Is(err, ErrMalformedResponse) {
t.Fatalf("invalid numeric usage response=%+v, error=%v", resp, err)
}
}
func checkOptionalResponseFieldOmission(t *testing.T) {
provider := newRecordingProvider(t)
client := newProviderClient(t, provider, OpenAICompatibleConfig{})

View File

@@ -0,0 +1,225 @@
package llm
import (
"context"
"errors"
"io"
"net/http"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
)
func TestPreparedGenerationFreezesPayloadAndAuthentication(t *testing.T) {
for _, tc := range []struct {
name string
initialKey string
wantAuth string
}{
{name: "absent optional key"},
{name: "present key", initialKey: "original-key", wantAuth: "Bearer original-key"},
} {
t.Run(tc.name, func(t *testing.T) {
const envName = "PROMPTKIT_PREPARED_GENERATION_TEST_KEY"
t.Setenv(envName, tc.initialKey)
var bodies, auth, urls []string
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
BaseURL: "https://example.invalid/v1", Model: "default-model",
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
body, err := io.ReadAll(req.Body)
if err != nil {
t.Fatal(err)
}
bodies = append(bodies, string(body))
auth = append(auth, req.Header.Get("Authorization"))
urls = append(urls, req.URL.String())
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"content":"ok"}}]}`))}, nil
})},
})
if err != nil {
t.Fatal(err)
}
req := domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "original"}}},
Target: domain.ExecutionTarget{APIKeyEnv: envName},
}
prepared, err := client.PrepareGeneration(req)
if err != nil {
t.Fatal(err)
}
req.Prompt.Messages[0].Content = "changed"
t.Setenv(envName, "later-key")
for range 2 {
outcome := prepared.Execute(context.Background())
if outcome.Err != nil || outcome.Response == nil || outcome.Response.Content != "ok" {
t.Fatalf("Execute = %+v", outcome)
}
}
if len(bodies) != 2 || bodies[0] != bodies[1] || !strings.Contains(bodies[0], "original") || strings.Contains(bodies[0], "changed") || !reflect.DeepEqual(auth, []string{tc.wantAuth, tc.wantAuth}) || !reflect.DeepEqual(urls, []string{"https://example.invalid/v1/chat/completions", "https://example.invalid/v1/chat/completions"}) {
t.Fatalf("bodies=%v auth=%v urls=%v", bodies, auth, urls)
}
})
}
}
func TestPreparedGenerationAttemptsHaveIndependentContextsAndCloseBodies(t *testing.T) {
var contexts []context.Context
var closed int
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
BaseURL: "https://example.invalid/v1", Model: "model",
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if err := req.Context().Err(); err != nil {
return nil, err
}
contexts = append(contexts, req.Context())
return &http.Response{StatusCode: http.StatusOK, Body: &countingBody{
Reader: strings.NewReader(`{"choices":[{"message":{"content":"ok"}}]}`),
closed: &closed,
}}, nil
})},
})
if err != nil {
t.Fatal(err)
}
prepared, err := client.PrepareGeneration(domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
Target: domain.ExecutionTarget{TimeoutSeconds: 1},
})
if err != nil {
t.Fatal(err)
}
for range 2 {
if outcome := prepared.Execute(context.Background()); outcome.Err != nil {
t.Fatal(outcome.Err)
}
}
if len(contexts) != 2 || contexts[0] == contexts[1] || closed != 2 {
t.Fatalf("contexts=%d, closed=%d", len(contexts), closed)
}
for _, ctx := range contexts {
if _, ok := ctx.Deadline(); !ok || !errors.Is(ctx.Err(), context.Canceled) {
t.Fatalf("attempt context deadline/error = %v/%v", ok, ctx.Err())
}
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if outcome := prepared.Execute(ctx); !errors.Is(outcome.Err, context.Canceled) {
t.Fatalf("canceled attempt = %+v", outcome)
}
if outcome := prepared.Execute(context.Background()); outcome.Err != nil || outcome.Response == nil {
t.Fatalf("later attempt = %+v", outcome)
}
}
func TestPreparedGenerationRetainsUsageFromMalformedChoice(t *testing.T) {
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
BaseURL: "https://example.invalid/v1", Model: "model",
HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"usage":{"prompt_tokens":0},"choices":[]}`))}, nil
})},
})
if err != nil {
t.Fatal(err)
}
prepared, err := client.PrepareGeneration(domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
})
if err != nil {
t.Fatal(err)
}
outcome := prepared.Execute(context.Background())
if !errors.Is(outcome.Err, ErrMalformedResponse) || outcome.Response != nil || outcome.UsagePresence == nil || !outcome.UsagePresence.PromptTokens || outcome.Usage.PromptTokens != 0 {
t.Fatalf("malformed choice = %+v", outcome)
}
}
func TestPreparedGenerationDoesNotUseInvalidlyFramedUsage(t *testing.T) {
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
BaseURL: "https://example.invalid/v1", Model: "model",
HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"usage":{"prompt_tokens":9},"choices":[]}{}`))}, nil
})},
})
if err != nil {
t.Fatal(err)
}
prepared, err := client.PrepareGeneration(domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
})
if err != nil {
t.Fatal(err)
}
outcome := prepared.Execute(context.Background())
if !errors.Is(outcome.Err, ErrMalformedResponse) || outcome.UsagePresence != nil || outcome.Usage != (domain.TokenUsage{}) {
t.Fatalf("invalid framing = %+v", outcome)
}
}
func TestPreparedGenerationRetainsReadCancellation(t *testing.T) {
closed := false
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
BaseURL: "https://example.invalid/v1", Model: "model",
HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: &readErrorBody{err: context.Canceled, closed: &closed}}, nil
})},
})
if err != nil {
t.Fatal(err)
}
prepared, err := client.PrepareGeneration(domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
})
if err != nil {
t.Fatal(err)
}
outcome := prepared.Execute(context.Background())
if !errors.Is(outcome.Err, context.Canceled) || !errors.Is(outcome.Err, ErrTransport) || errors.Is(outcome.Err, ErrMalformedResponse) || !closed {
t.Fatalf("read cancellation = %+v, closed=%t", outcome, closed)
}
}
func TestRetryNotBefore(t *testing.T) {
received := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC)
for _, tc := range []struct {
name string
value string
want time.Time
}{
{name: "seconds", value: " 12 ", want: received.Add(12 * time.Second)},
{name: "date", value: "Wed, 23 Sep 2026 12:00:15 GMT", want: received.Add(15 * time.Second)},
{name: "past date", value: "Wed, 23 Sep 2026 11:59:59 GMT", want: received},
{name: "negative", value: "-2"},
{name: "fractional", value: "1.5"},
{name: "malformed", value: "tomorrow"},
} {
t.Run(tc.name, func(t *testing.T) {
if got := retryNotBefore([]string{tc.value, "999"}, received); !got.Equal(tc.want) {
t.Fatalf("not before = %v, want %v", got, tc.want)
}
})
}
if got := retryNotBefore([]string{"999999999999999999999999999999999999"}, received); !got.After(received) {
t.Fatalf("large valid delay wrapped: %v", got)
}
}
type countingBody struct {
io.Reader
closed *int
}
func (b *countingBody) Close() error {
*b.closed++
return nil
}
type readErrorBody struct {
err error
closed *bool
}
func (b *readErrorBody) Read([]byte) (int, error) { return 0, b.err }
func (b *readErrorBody) Close() error { *b.closed = true; return nil }

View File

@@ -0,0 +1,318 @@
// Package observation owns invocation-local execution identities, metadata
// accounting, and synchronous event delivery.
package observation
import (
"context"
"crypto/rand"
"fmt"
"math"
"time"
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
)
// Recorder accumulates one execution's measured attempts and usage. Its owner
// calls methods in execution order; it has no shared history or background work.
type Recorder struct {
runID string
startedAt time.Time
now func() time.Time
observer domain.ExecutionObserver
selection Selection
attempts uint64
retries uint64
repairCalls uint64
lastRepairGeneration uint64
finished bool
capacityWait time.Duration
generationDuration time.Duration
retryDelay time.Duration
usage usageTotals
}
// Selection contains resolved identities only. Unresolved fields stay empty.
type Selection struct {
PromptID string
PromptVersion string
ProfileID string
BackendID string
Model string
}
// AttemptData describes one completed active-permit acquisition and optional
// generation dispatch. Non-dispatched attempts contribute no token usage.
type AttemptData struct {
Generation uint64
Attempt uint64
Dispatched bool
CapacityWait time.Duration
GenerationDuration time.Duration
Outcome domain.ExecutionOutcome
Failure domain.ExecutionFailureKind
StatusCode int
Usage domain.TokenUsage
UsagePresence *domain.TokenUsagePresence
}
// DelayData describes one completed or interrupted retry wait.
type DelayData struct {
Generation uint64
Attempt uint64
Elapsed time.Duration
Failure domain.ExecutionFailureKind
}
// FinishData describes the terminal outcome after resource release.
type FinishData struct {
Outcome domain.ExecutionOutcome
Failure domain.ExecutionFailureKind
StatusCode int
RetriesExhausted bool
}
// New creates an invocation-local recorder and run ID. It returns the recorder
// even if ID creation fails, allowing an error terminal event with an empty ID.
func New(observer domain.ExecutionObserver) (*Recorder, error) {
runID, err := NewRunID()
return newRecorder(runID, time.Now, observer), err
}
func newRecorder(runID string, now func() time.Time, observer domain.ExecutionObserver) *Recorder {
return &Recorder{runID: runID, startedAt: now(), now: now, observer: observer,
usage: newUsageTotals()}
}
// NewRunID creates the identity shared by observations and successful results.
func NewRunID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
// RunID returns this invocation's identity.
func (r *Recorder) RunID() string { return r.runID }
// Start delivers the opening event with only identities already resolved.
func (r *Recorder) Start() {
event := r.event(domain.ExecutionStarted)
r.deliver(event)
}
// UpdateSelection replaces the known resolved identities for later events.
func (r *Recorder) UpdateSelection(selection Selection) { r.selection = selection }
// Attempt records one completed acquisition and optional dispatch.
func (r *Recorder) Attempt(data AttemptData) {
r.capacityWait = addDuration(r.capacityWait, data.CapacityWait)
if data.Dispatched {
r.attempts++
if data.Attempt > 1 {
r.retries++
}
if data.Generation > 1 && data.Generation != r.lastRepairGeneration {
r.repairCalls++
r.lastRepairGeneration = data.Generation
}
r.generationDuration = addDuration(r.generationDuration, data.GenerationDuration)
r.usage.add(data.Usage, data.UsagePresence)
}
event := r.event(domain.ExecutionAttempt)
event.Generation = data.Generation
event.Attempt = data.Attempt
event.Dispatched = data.Dispatched
event.CapacityWait = data.CapacityWait
if data.Dispatched {
event.GenerationDuration = data.GenerationDuration
}
event.Outcome = data.Outcome
event.Failure = data.Failure
event.StatusCode = data.StatusCode
if data.Dispatched {
event.Usage = observedUsage(data.Usage, data.UsagePresence)
}
r.deliver(event)
}
// Delay records the measured time spent waiting after one failed attempt.
func (r *Recorder) Delay(data DelayData) {
r.retryDelay = addDuration(r.retryDelay, data.Elapsed)
event := r.event(domain.ExecutionRetryDelay)
event.Generation = data.Generation
event.Attempt = data.Attempt
event.RetryDelay = data.Elapsed
event.Failure = data.Failure
r.deliver(event)
}
// Finish delivers terminal totals once on a normal return. Its caller owns the
// operation's resource cleanup and failure classification.
func (r *Recorder) Finish(data FinishData) {
if r.finished {
return
}
r.finished = true
event := r.event(domain.ExecutionFinished)
event.Outcome = data.Outcome
event.Failure = data.Failure
event.StatusCode = data.StatusCode
event.RetriesExhausted = data.RetriesExhausted
event.Attempts = r.attempts
event.TransientRetries = r.retries
event.RepairCalls = r.repairCalls
event.CapacityWait = r.capacityWait
event.GenerationDuration = r.generationDuration
event.RetryDelay = r.retryDelay
event.Duration = event.Time.Sub(r.startedAt)
if event.Duration < 0 {
event.Duration = 0
}
event.Usage = r.usage.snapshot(r.attempts)
r.deliver(event)
}
func (r *Recorder) event(kind domain.ExecutionEventKind) domain.ExecutionEvent {
return domain.ExecutionEvent{
Kind: kind, RunID: r.runID, Time: r.now(), PromptID: r.selection.PromptID,
PromptVersion: r.selection.PromptVersion, ProfileID: r.selection.ProfileID,
BackendID: r.selection.BackendID, Model: r.selection.Model,
}
}
func (r *Recorder) deliver(event domain.ExecutionEvent) {
if r.observer != nil {
r.observer(event)
}
}
type contextKey struct{}
// WithoutExecutionState starts a public operation without inheriting another
// invocation's recorder or prepared-claim sink. Other context values and all
// cancellation/deadline behavior remain with the caller's context.
func WithoutExecutionState(ctx context.Context) context.Context {
return executionBoundary{Context: ctx}
}
type executionBoundary struct{ context.Context }
func (c executionBoundary) Value(key any) any {
switch key.(type) {
case contextKey, claimSinkKey:
return nil
default:
return c.Context.Value(key)
}
}
// WithRecorder carries one invocation's recorder through internal client calls.
func WithRecorder(ctx context.Context, recorder *Recorder) context.Context {
return context.WithValue(ctx, contextKey{}, recorder)
}
// FromContext returns the invocation-local recorder, if one was installed.
func FromContext(ctx context.Context) *Recorder {
recorder, _ := ctx.Value(contextKey{}).(*Recorder)
return recorder
}
type claimSinkKey struct{}
// WithClaimSink lets the facade learn which invocation actually claimed a
// prepared handle, so it can emit the terminal event after public mapping.
func WithClaimSink(ctx context.Context, sink **Recorder) context.Context {
return context.WithValue(ctx, claimSinkKey{}, sink)
}
// RegisterClaim records an invocation after its prepared handle is claimed.
func RegisterClaim(ctx context.Context, recorder *Recorder) {
if sink, ok := ctx.Value(claimSinkKey{}).(**Recorder); ok && sink != nil {
*sink = recorder
}
}
type usageField struct {
value int
known, complete bool
}
type usageTotals struct {
prompt, completion, total, cached, cacheWrite usageField
}
func newUsageTotals() usageTotals {
return usageTotals{prompt: usageField{complete: true}, completion: usageField{complete: true},
total: usageField{complete: true}, cached: usageField{complete: true}, cacheWrite: usageField{complete: true}}
}
func (u *usageTotals) add(usage domain.TokenUsage, presence *domain.TokenUsagePresence) {
known := usagePresence(usage, presence)
u.prompt.add(usage.PromptTokens, known.PromptTokens)
u.completion.add(usage.CompletionTokens, known.CompletionTokens)
u.total.add(usage.TotalTokens, known.TotalTokens)
u.cached.add(usage.CachedTokens, known.CachedTokens)
u.cacheWrite.add(usage.CacheWriteTokens, known.CacheWriteTokens)
}
func (u usageTotals) snapshot(attempts uint64) domain.ObservedTokenUsage {
return domain.ObservedTokenUsage{
PromptTokens: u.prompt.snapshot(attempts), CompletionTokens: u.completion.snapshot(attempts),
TotalTokens: u.total.snapshot(attempts), CachedTokens: u.cached.snapshot(attempts),
CacheWriteTokens: u.cacheWrite.snapshot(attempts),
}
}
func (f *usageField) add(value int, known bool) {
if known {
f.value += value
f.known = true
} else {
f.complete = false
}
}
func (f usageField) snapshot(attempts uint64) domain.ObservedTokenCount {
if attempts == 0 {
return domain.ObservedTokenCount{Known: true, Complete: true}
}
return domain.ObservedTokenCount{Value: f.value, Known: f.known, Complete: f.complete}
}
func observedUsage(usage domain.TokenUsage, presence *domain.TokenUsagePresence) domain.ObservedTokenUsage {
known := usagePresence(usage, presence)
return domain.ObservedTokenUsage{
PromptTokens: observedCount(usage.PromptTokens, known.PromptTokens),
CompletionTokens: observedCount(usage.CompletionTokens, known.CompletionTokens),
TotalTokens: observedCount(usage.TotalTokens, known.TotalTokens),
CachedTokens: observedCount(usage.CachedTokens, known.CachedTokens),
CacheWriteTokens: observedCount(usage.CacheWriteTokens, known.CacheWriteTokens),
}
}
func usagePresence(usage domain.TokenUsage, presence *domain.TokenUsagePresence) domain.TokenUsagePresence {
if presence != nil {
return *presence
}
return domain.TokenUsagePresence{
PromptTokens: usage.PromptTokens != 0, CompletionTokens: usage.CompletionTokens != 0,
TotalTokens: usage.TotalTokens != 0, CachedTokens: usage.CachedTokens != 0,
CacheWriteTokens: usage.CacheWriteTokens != 0,
}
}
func observedCount(value int, known bool) domain.ObservedTokenCount {
if !known {
return domain.ObservedTokenCount{}
}
return domain.ObservedTokenCount{Value: value, Known: true, Complete: true}
}
func addDuration(total, next time.Duration) time.Duration {
if next > 0 && total > time.Duration(math.MaxInt64)-next {
return time.Duration(math.MaxInt64)
}
return total + next
}

View File

@@ -0,0 +1,159 @@
package observation
import (
"context"
"errors"
"reflect"
"testing"
"time"
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
)
func TestExecutionBoundaryPreservesCallerContext(t *testing.T) {
type callerKey struct{}
parent, stop := context.WithTimeout(context.WithValue(context.Background(), callerKey{}, "caller"), time.Minute)
defer stop()
parent, cancel := context.WithCancelCause(parent)
defer cancel(nil)
recorder := newRecorder("outer", time.Now, nil)
sink := recorder
parent = WithClaimSink(WithRecorder(parent, recorder), &sink)
child := WithoutExecutionState(parent)
if FromContext(child) != nil || FromContext(parent) != recorder {
t.Fatal("execution boundary did not isolate recorder state")
}
RegisterClaim(child, nil)
if sink != recorder {
t.Fatal("execution boundary retained the parent claim sink")
}
wantDeadline, _ := parent.Deadline()
gotDeadline, ok := child.Deadline()
if !ok || gotDeadline != wantDeadline || child.Value(callerKey{}) != "caller" || child.Done() != parent.Done() {
t.Fatal("execution boundary changed caller context")
}
cause := errors.New("caller canceled")
cancel(cause)
if !errors.Is(child.Err(), context.Canceled) || context.Cause(child) != cause {
t.Fatal("execution boundary lost cancellation or its cause")
}
}
func TestRecorderAccountsDispatchedAttemptsAndReportedUsage(t *testing.T) {
start := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC)
tick := 0
now := func() time.Time {
value := start.Add(time.Duration(tick) * time.Second)
tick++
return value
}
var events []domain.ExecutionEvent
recorder := newRecorder("run-1", now, func(event domain.ExecutionEvent) { events = append(events, event) })
recorder.Start()
recorder.UpdateSelection(Selection{PromptID: "prompt", PromptVersion: "v1", ProfileID: "profile", BackendID: "backend", Model: "model"})
recorder.Attempt(AttemptData{
Generation: 1, Attempt: 1, Dispatched: true,
CapacityWait: 2 * time.Millisecond, GenerationDuration: 10 * time.Millisecond,
Outcome: domain.ExecutionOutcomeSuccess,
UsagePresence: &domain.TokenUsagePresence{PromptTokens: true},
})
recorder.Delay(DelayData{Generation: 1, Attempt: 1, Elapsed: 7 * time.Millisecond})
recorder.Attempt(AttemptData{
Generation: 1, Attempt: 2, Dispatched: true,
CapacityWait: 3 * time.Millisecond, GenerationDuration: 20 * time.Millisecond,
Outcome: domain.ExecutionOutcomeSuccess,
Usage: domain.TokenUsage{PromptTokens: 2, TotalTokens: 5},
UsagePresence: &domain.TokenUsagePresence{PromptTokens: true, TotalTokens: true},
})
recorder.Attempt(AttemptData{
Generation: 2, Attempt: 1, Dispatched: false,
CapacityWait: 4 * time.Millisecond, GenerationDuration: 99 * time.Millisecond,
Outcome: domain.ExecutionOutcomeError, Failure: domain.ExecutionFailureCanceled,
Usage: domain.TokenUsage{PromptTokens: 99},
})
recorder.Attempt(AttemptData{
Generation: 2, Attempt: 1, Dispatched: true,
CapacityWait: 5 * time.Millisecond, GenerationDuration: 30 * time.Millisecond,
Outcome: domain.ExecutionOutcomeError, Failure: domain.ExecutionFailureProviderStatus,
StatusCode: 503, Usage: domain.TokenUsage{PromptTokens: 3},
})
recorder.Finish(FinishData{Outcome: domain.ExecutionOutcomeError, Failure: domain.ExecutionFailureProviderStatus, StatusCode: 503, RetriesExhausted: true})
recorder.Finish(FinishData{})
if len(events) != 7 {
t.Fatalf("event count=%d", len(events))
}
if events[0].PromptID != "" || events[1].PromptID != "prompt" || events[0].RunID != "run-1" || events[1].RunID != "run-1" {
t.Fatalf("identity snapshots=%+v, %+v", events[0], events[1])
}
if events[1].Usage.PromptTokens != (domain.ObservedTokenCount{Known: true, Complete: true}) ||
events[4].Dispatched || events[4].GenerationDuration != 0 || events[4].Usage.PromptTokens.Known {
t.Fatalf("attempt usage or undispatched event=%+v, %+v", events[1], events[4])
}
last := events[len(events)-1]
if last.Attempts != 3 || last.TransientRetries != 1 || last.RepairCalls != 1 ||
last.CapacityWait != 14*time.Millisecond || last.GenerationDuration != 60*time.Millisecond ||
last.RetryDelay != 7*time.Millisecond || last.Duration != 7*time.Second {
t.Fatalf("terminal counters=%+v", last)
}
if last.Usage.PromptTokens != (domain.ObservedTokenCount{Value: 5, Known: true, Complete: true}) ||
last.Usage.TotalTokens != (domain.ObservedTokenCount{Value: 5, Known: true, Complete: false}) ||
last.Usage.CachedTokens != (domain.ObservedTokenCount{}) {
t.Fatalf("aggregate usage=%+v", last.Usage)
}
if last.Failure != domain.ExecutionFailureProviderStatus || !last.RetriesExhausted || last.StatusCode != 503 {
t.Fatalf("terminal failure=%+v", last)
}
if events[1].Attempts != 0 || events[1].TransientRetries != 0 || events[1].RepairCalls != 0 {
t.Fatalf("attempt event carried terminal totals=%+v", events[1])
}
}
func TestRecorderZeroAttemptsAndExplicitUnknownUsage(t *testing.T) {
var events []domain.ExecutionEvent
recorder := newRecorder("", time.Now, func(event domain.ExecutionEvent) { events = append(events, event) })
recorder.Finish(FinishData{Outcome: domain.ExecutionOutcomeError, Failure: domain.ExecutionFailureOther})
if len(events) != 1 || events[0].RunID != "" || events[0].Attempts != 0 ||
events[0].Usage.PromptTokens != (domain.ObservedTokenCount{Known: true, Complete: true}) ||
events[0].Usage.CacheWriteTokens != (domain.ObservedTokenCount{Known: true, Complete: true}) {
t.Fatalf("zero-attempt terminal=%+v", events)
}
recorder = newRecorder("run-2", time.Now, func(event domain.ExecutionEvent) { events = append(events, event) })
recorder.Attempt(AttemptData{Dispatched: true, Generation: 1, Attempt: 1,
Usage: domain.TokenUsage{PromptTokens: 7}, UsagePresence: &domain.TokenUsagePresence{}})
recorder.Finish(FinishData{Outcome: domain.ExecutionOutcomeSuccess})
last := events[len(events)-1]
if last.Usage.PromptTokens != (domain.ObservedTokenCount{}) {
t.Fatalf("explicitly unknown nonzero field=%+v", last.Usage.PromptTokens)
}
_ = newRecorder("run-3", time.Now, nil) // A nil callback still permits recording.
}
func TestRecorderOrderContextAndCallbackPanic(t *testing.T) {
var kinds []domain.ExecutionEventKind
recorder := newRecorder("run", time.Now, func(event domain.ExecutionEvent) { kinds = append(kinds, event.Kind) })
ctx := WithRecorder(context.Background(), recorder)
if FromContext(ctx) != recorder || FromContext(context.Background()) != nil {
t.Fatal("recorder context propagation failed")
}
recorder.Start()
recorder.Attempt(AttemptData{Generation: 1, Attempt: 1})
recorder.Delay(DelayData{Generation: 1, Attempt: 1})
recorder.Finish(FinishData{Outcome: domain.ExecutionOutcomeSuccess})
if !reflect.DeepEqual(kinds, []domain.ExecutionEventKind{
domain.ExecutionStarted, domain.ExecutionAttempt, domain.ExecutionRetryDelay, domain.ExecutionFinished,
}) {
t.Fatalf("callback order=%v", kinds)
}
recorder = newRecorder("panic", time.Now, func(domain.ExecutionEvent) { panic("observer panic") })
func() {
defer func() {
if recover() == nil {
t.Fatal("observer panic was swallowed")
}
}()
recorder.Start()
}()
}

View File

@@ -8,6 +8,8 @@ func newGenerationRequest(
target domain.ExecutionTarget,
targetPresence domain.ExecutionTargetPresence,
structuredOutput *domain.StructuredOutputSpec,
transientRetries uint64,
generation uint64,
) domain.GenerateRequest {
prompt.SessionID = sessionID
return domain.GenerateRequest{
@@ -15,5 +17,7 @@ func newGenerationRequest(
Target: target,
TargetPresence: targetPresence,
StructuredOutput: structuredOutput,
TransientRetries: transientRetries,
Generation: generation,
}
}

View File

@@ -8,6 +8,7 @@ import (
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
"gitea.maximumdirect.net/eric/promptkit/internal/observation"
"gitea.maximumdirect.net/eric/promptkit/internal/validate"
)
@@ -29,9 +30,11 @@ type PreparedExecution struct {
}
type preparedExecutionPayload struct {
prepared *domain.PreparedRun
validation validate.PreparedValidation
directKey string
prepared *domain.PreparedRun
validation validate.PreparedValidation
directKey string
transientRetries uint64
observer domain.ExecutionObserver
}
// PrepareExecution completes preparation without generation or admission and
@@ -62,9 +65,11 @@ func (r *Runner) PrepareExecution(ctx context.Context, req domain.RunRequest) (*
state: preparedExecutionReady,
details: details,
payload: &preparedExecutionPayload{
prepared: executionSnapshot,
validation: operation.validation,
directKey: state.effectiveModel.APIKey,
prepared: executionSnapshot,
validation: operation.validation,
directKey: state.effectiveModel.APIKey,
transientRetries: state.transientRetries,
observer: req.Observer,
},
}, nil
}
@@ -112,9 +117,28 @@ func (r *Runner) RunPrepared(ctx context.Context, prepared *PreparedExecution) (
}
defer payload.clear()
runID, err := newRunID()
if err != nil {
return nil, fmt.Errorf("failed to create run id: %w", err)
var recorder *observation.Recorder
var runID string
if payload.observer != nil {
var idErr error
recorder, idErr = observation.New(payload.observer)
observation.RegisterClaim(ctx, recorder)
ctx = observation.WithRecorder(ctx, recorder)
selected := payload.prepared
recorder.UpdateSelection(observation.Selection{PromptID: selected.PromptID,
PromptVersion: selected.PromptVersion, ProfileID: selected.SelectedProfileID,
BackendID: selected.SelectedBackendID, Model: selected.EffectiveModelParams.Model})
recorder.Start()
if idErr != nil {
return nil, fmt.Errorf("failed to create run id: %w", idErr)
}
runID = recorder.RunID()
} else {
var err error
runID, err = observation.NewRunID()
if err != nil {
return nil, fmt.Errorf("failed to create run id: %w", err)
}
}
start := time.Now().UTC()
@@ -129,7 +153,7 @@ func (r *Runner) RunPrepared(ctx context.Context, prepared *PreparedExecution) (
}
defer release()
return r.executePreparedRun(ctx, payload.prepared, payload.directKey, runID, start, func(
return r.executePreparedRun(ctx, payload.prepared, payload.directKey, payload.transientRetries, runID, start, func(
ctx context.Context,
artifact *domain.Artifact,
attemptsUsed int,
@@ -169,6 +193,8 @@ func (p *preparedExecutionPayload) clear() {
p.prepared = nil
p.validation = nil
p.directKey = ""
p.transientRetries = 0
p.observer = nil
}
type noOpPreparedValidation struct {

View File

@@ -31,6 +31,7 @@ type RepairRequest struct {
Target domain.ExecutionTarget
TargetPresence domain.ExecutionTargetPresence
StructuredOutput *domain.StructuredOutputSpec
TransientRetries uint64
Attempt int
MaxAttempts int
Mode domain.ValidationMode
@@ -91,6 +92,8 @@ func (r *defaultOutputRepairer) Repair(ctx context.Context, req RepairRequest) (
req.Target,
req.TargetPresence,
req.StructuredOutput,
req.TransientRetries,
uint64(req.Attempt)+1,
))
if err != nil {
return nil, err

View File

@@ -2,7 +2,6 @@ package usecase
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
@@ -22,6 +21,7 @@ import (
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
"gitea.maximumdirect.net/eric/promptkit/internal/observation"
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
"gitea.maximumdirect.net/eric/promptkit/internal/prompt"
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
@@ -43,15 +43,16 @@ var (
// Runner executes the Promptkit core use case.
type Runner struct {
promptDefs promptdef.Repository
profiles profile.Repository
backends BackendResolver
artifacts artifact.Reader
renderer prompt.Renderer
llm llm.Client
validator validate.Validator
repairer OutputRepairer
admitter RunAdmitter
promptDefs promptdef.Repository
profiles profile.Repository
backends BackendResolver
artifacts artifact.Reader
renderer prompt.Renderer
llm llm.Client
validator validate.Validator
repairer OutputRepairer
admitter RunAdmitter
defaultTransientRetries uint64
}
// BackendResolver resolves one normalized backend ID.
@@ -73,6 +74,7 @@ type preparationState struct {
targetPresence domain.ExecutionTargetPresence
effectiveContract domain.OutputContract
start time.Time
transientRetries uint64
}
type preparedOperation struct {
@@ -113,24 +115,37 @@ func NewRunnerWithRepairer(
validator validate.Validator,
repairer OutputRepairer,
admitter RunAdmitter,
defaultTransientRetries ...uint64,
) *Runner {
retries := uint64(0)
if len(defaultTransientRetries) > 0 {
retries = defaultTransientRetries[0]
}
return &Runner{
promptDefs: promptDefs,
profiles: profiles,
backends: backends,
artifacts: artifacts,
renderer: renderer,
llm: llmClient,
validator: validator,
repairer: repairer,
admitter: admitter,
promptDefs: promptDefs,
profiles: profiles,
backends: backends,
artifacts: artifacts,
renderer: renderer,
llm: llmClient,
validator: validator,
repairer: repairer,
admitter: admitter,
defaultTransientRetries: retries,
}
}
func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunResult, error) {
runID, err := newRunID()
if err != nil {
return nil, fmt.Errorf("failed to create run id: %w", err)
recorder := observation.FromContext(ctx)
runID := ""
if recorder != nil {
runID = recorder.RunID()
} else {
var err error
runID, err = observation.NewRunID()
if err != nil {
return nil, fmt.Errorf("failed to create run id: %w", err)
}
}
start := time.Now().UTC()
@@ -152,7 +167,7 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunRes
}
directAPIKey := state.effectiveModel.APIKey
return r.executePreparedRun(ctx, operation.run, directAPIKey, runID, start, func(
return r.executePreparedRun(ctx, operation.run, directAPIKey, state.transientRetries, runID, start, func(
ctx context.Context,
artifact *domain.Artifact,
attemptsUsed int,
@@ -173,6 +188,7 @@ func (r *Runner) executePreparedRun(
ctx context.Context,
prepared *domain.PreparedRun,
directAPIKey string,
transientRetries uint64,
runID string,
start time.Time,
validateArtifact preparedValidationFunc,
@@ -185,6 +201,8 @@ func (r *Runner) executePreparedRun(
executionTarget,
prepared.TargetPresence,
prepared.StructuredOutput,
transientRetries,
1,
))
if err != nil {
return nil, wrapGenerationError(err)
@@ -213,6 +231,7 @@ func (r *Runner) executePreparedRun(
Target: executionTarget,
TargetPresence: prepared.TargetPresence,
StructuredOutput: prepared.StructuredOutput,
TransientRetries: transientRetries,
Attempt: attemptsUsed,
MaxAttempts: prepared.OutputContract.RepairAttempts,
Mode: prepared.OutputContract.ValidationMode,
@@ -295,6 +314,10 @@ func (r *Runner) resolvePreparation(
req domain.RunRequest,
start time.Time,
) (*preparationState, error) {
transientRetries, err := domain.ResolveTransientRetries(r.defaultTransientRetries, req.TransientRetries)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrInvalidRequest, err)
}
if strings.TrimSpace(req.PromptID) == "" {
return nil, fmt.Errorf("%w: prompt id is required", ErrInvalidRequest)
}
@@ -308,6 +331,9 @@ func (r *Runner) resolvePreparation(
return nil, err
}
def := promptSelection.definition
if recorder := observation.FromContext(ctx); recorder != nil {
recorder.UpdateSelection(observation.Selection{PromptID: def.ID, PromptVersion: def.Version})
}
promptDefinitionHash := promptSelection.hash
effectiveContract, err := resolveOutputContract(def, req.Validation)
if err != nil {
@@ -326,6 +352,9 @@ func (r *Runner) resolvePreparation(
if err != nil {
return nil, err
}
if recorder := observation.FromContext(ctx); recorder != nil {
recorder.UpdateSelection(observation.Selection{PromptID: def.ID, PromptVersion: def.Version, ProfileID: selection.id})
}
effectiveModel, targetPresence := resolveExecutionTarget(selection.backend, selection.profile, req.Execution)
effectiveModel.APIKey = req.APIKey
@@ -336,6 +365,10 @@ func (r *Runner) resolvePreparation(
if err := validateAPIKey(effectiveModel.APIKeyEnv, effectiveModel.APIKey, effectiveModel.APIKeyRequired); err != nil {
return nil, fmt.Errorf("%w: %w", ErrInvalidRequest, err)
}
if recorder := observation.FromContext(ctx); recorder != nil {
recorder.UpdateSelection(observation.Selection{PromptID: def.ID, PromptVersion: def.Version,
ProfileID: selection.id, BackendID: effectiveModel.BackendID, Model: effectiveModel.Model})
}
return &preparationState{
definition: def,
@@ -346,6 +379,7 @@ func (r *Runner) resolvePreparation(
targetPresence: targetPresence,
effectiveContract: effectiveContract,
start: start,
transientRetries: transientRetries,
}, nil
}
@@ -782,22 +816,3 @@ func hashPromptDefinition(def *domain.PromptDefinition) (string, error) {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:]), nil
}
func newRunID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
// UUID v4 (RFC 4122 variant).
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
b[0:4],
b[4:6],
b[6:8],
b[8:10],
b[10:16],
), nil
}

View File

@@ -12,6 +12,36 @@ import (
"gitea.maximumdirect.net/eric/promptkit"
)
func TestGenerateResponseUsagePresenceJSON(t *testing.T) {
value := promptkit.GenerateResponse{
Content: "ok",
Usage: promptkit.TokenUsage{TotalTokens: 5},
UsagePresence: &promptkit.TokenUsagePresence{
PromptTokens: true, CompletionTokens: true, TotalTokens: true,
CachedTokens: true, CacheWriteTokens: true,
},
}
payload, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
object := decodeJSONObject(t, payload)
if _, ok := object["usage_presence"]; !ok {
t.Fatalf("missing usage_presence: %s", payload)
}
var decoded promptkit.GenerateResponse
if err := json.Unmarshal(payload, &decoded); err != nil || !reflect.DeepEqual(decoded, value) {
t.Fatalf("round trip = %+v, %v", decoded, err)
}
payload, err = json.Marshal(promptkit.GenerateResponse{})
if err != nil {
t.Fatal(err)
}
if _, ok := decodeJSONObject(t, payload)["usage_presence"]; ok {
t.Fatalf("nil usage_presence was serialized: %s", payload)
}
}
func TestPreparedRunJSONContractRoundTripsAllFields(t *testing.T) {
value := fullyPopulatedPreparedRun()
payload, err := json.Marshal(value)

View File

@@ -2,9 +2,11 @@ package promptkit
import (
"context"
"errors"
"fmt"
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
)
type publicLLMClientAdapter struct {
@@ -13,11 +15,28 @@ type publicLLMClientAdapter struct {
func (a publicLLMClientAdapter) Generate(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error) {
resp, err := a.client.Generate(ctx, fromDomainGenerateRequest(req))
if err != nil {
return nil, err
if err == nil && resp == nil {
err = fmt.Errorf("%w: %w: llm client returned nil response", ErrLLMGenerate, ErrMalformedResponse)
}
if resp == nil {
return nil, fmt.Errorf("%w: llm client returned nil response", ErrLLMGenerate)
if err != nil {
switch {
case errors.Is(err, ErrTransport):
return nil, classifiedClientError{cause: err, category: llm.ErrTransport}
case errors.Is(err, ErrMalformedResponse):
return nil, classifiedClientError{cause: err, category: llm.ErrMalformedResponse}
}
return nil, err
}
return toDomainGenerateResponse(resp), nil
}
// classifiedClientError makes an explicit public category available internally
// without replacing the client's diagnostic or original error chain.
type classifiedClientError struct {
cause error
category error
}
func (e classifiedClientError) Error() string { return e.cause.Error() }
func (e classifiedClientError) Unwrap() error { return e.cause }
func (e classifiedClientError) Is(target error) bool { return target == e.category }

View File

@@ -47,6 +47,30 @@ func TestPublicLLMClientAdapterGivesClientOwnedNestedValues(t *testing.T) {
}
}
func TestPublicLLMClientAdapterCopiesUsagePresence(t *testing.T) {
presence := &TokenUsagePresence{PromptTokens: true}
client := &usagePresenceClient{response: &GenerateResponse{
Content: "ok",
Usage: TokenUsage{PromptTokens: 0, TotalTokens: 5},
UsagePresence: presence,
}}
adapter := publicLLMClientAdapter{client: client}
got, err := adapter.Generate(context.Background(), domain.GenerateRequest{})
if err != nil {
t.Fatal(err)
}
presence.PromptTokens = false
if got.UsagePresence == nil || !got.UsagePresence.PromptTokens || got.UsagePresence.TotalTokens || got.Usage.TotalTokens != 5 {
t.Fatalf("copied response = %+v", got)
}
}
type usagePresenceClient struct{ response *GenerateResponse }
func (c *usagePresenceClient) Generate(context.Context, GenerateRequest) (*GenerateResponse, error) {
return c.response, nil
}
type retainingMutatingLLMClient struct {
retained GenerateRequest
}

View File

@@ -0,0 +1,175 @@
package promptkit_test
import (
"context"
"errors"
"fmt"
"testing"
"time"
"gitea.maximumdirect.net/eric/promptkit"
)
type observationClientFunc func(context.Context, promptkit.GenerateRequest) (*promptkit.GenerateResponse, error)
func (f observationClientFunc) Generate(ctx context.Context, req promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
return f(ctx, req)
}
func TestDelegatedOperationsIsolateObservations(t *testing.T) {
for _, mode := range []string{"run", "observed run", "prepared", "observed prepared", "prepare", "prepare execution"} {
t.Run(mode, func(t *testing.T) {
type contextKey struct{}
ctx, cancel := context.WithTimeout(context.WithValue(context.Background(), contextKey{}, "caller-value"), time.Minute)
defer cancel()
deadline, _ := ctx.Deadline()
child, err := promptkit.NewEngine(promptkit.Config{},
promptkit.WithPromptFS(contractPromptFS("child", "profile", "child message"), "."),
promptkit.WithProfiles(promptkit.Profile{ID: "profile", Endpoint: "https://example.invalid/v1", Model: "child-model"}),
promptkit.WithLLMClient(observationClientFunc(func(callCtx context.Context, _ promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
gotDeadline, ok := callCtx.Deadline()
if !ok || gotDeadline != deadline || callCtx.Value(contextKey{}) != "caller-value" || callCtx.Done() != ctx.Done() {
t.Error("delegation lost caller context behavior")
}
return &promptkit.GenerateResponse{Content: "child", Usage: promptkit.TokenUsage{PromptTokens: 7}}, nil
})))
if err != nil {
t.Fatal(err)
}
var childEvents []promptkit.ExecutionEvent
var childResult *promptkit.RunResult
outer := observationEngine(t, observationClientFunc(func(callCtx context.Context, _ promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
req := promptkit.RunRequest{PromptID: "child"}
if mode == "observed run" || mode == "observed prepared" {
req.Observer = func(event promptkit.ExecutionEvent) { childEvents = append(childEvents, event) }
}
var callErr error
switch mode {
case "run", "observed run":
childResult, callErr = child.Run(callCtx, req)
case "prepared", "observed prepared", "prepare execution":
var handle *promptkit.PreparedExecution
handle, callErr = child.PrepareExecution(callCtx, req)
if callErr == nil {
defer handle.Discard()
if mode != "prepare execution" {
childResult, callErr = child.RunPrepared(callCtx, handle)
}
}
case "prepare":
_, callErr = child.Prepare(callCtx, req)
}
return &promptkit.GenerateResponse{Content: "outer", Usage: promptkit.TokenUsage{PromptTokens: 1}}, callErr
}))
var outerEvents []promptkit.ExecutionEvent
result, err := outer.Run(ctx, promptkit.RunRequest{PromptID: "prompt", Observer: func(event promptkit.ExecutionEvent) { outerEvents = append(outerEvents, event) }})
if err != nil {
t.Fatal(err)
}
if len(outerEvents) != 3 {
t.Fatalf("outer events=%+v", outerEvents)
}
for _, event := range outerEvents[1:] {
if event.RunID != result.RunID || event.PromptID != "prompt" || event.Model != "model" || event.Usage.PromptTokens.Value != 1 {
t.Fatalf("child contaminated outer event: %+v", event)
}
}
if outerEvents[2].Attempts != 1 {
t.Fatalf("outer attempts=%d", outerEvents[2].Attempts)
}
if childResult != nil && childResult.RunID == result.RunID {
t.Fatal("delegated run reused outer run ID")
}
if mode == "observed run" || mode == "observed prepared" {
if len(childEvents) != 3 || childEvents[0].Kind != promptkit.ExecutionStarted || childEvents[2].Kind != promptkit.ExecutionFinished || childEvents[2].Attempts != 1 || childEvents[2].Usage.PromptTokens.Value != 7 || childEvents[2].RunID != childResult.RunID {
t.Fatalf("child lifecycle=%+v", childEvents)
}
} else if len(childEvents) != 0 {
t.Fatalf("unobserved child events=%+v", childEvents)
}
})
}
}
func TestConcurrentDelegationDoesNotShareRecorder(t *testing.T) {
child := observationEngine(t, observationClientFunc(func(context.Context, promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
return &promptkit.GenerateResponse{Content: "child"}, nil
}))
ids := make(chan string, 2)
outer := observationEngine(t, observationClientFunc(func(ctx context.Context, _ promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
errs := make(chan error, 2)
for range 2 {
go func() {
r, err := child.Run(ctx, promptkit.RunRequest{PromptID: "prompt"})
if r != nil {
ids <- r.RunID
}
errs <- err
}()
}
return &promptkit.GenerateResponse{Content: "outer"}, errors.Join(<-errs, <-errs)
}))
var events []promptkit.ExecutionEvent
r, err := outer.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", Observer: func(e promptkit.ExecutionEvent) { events = append(events, e) }})
if err != nil {
t.Fatal(err)
}
if len(events) != 3 || events[2].Attempts != 1 {
t.Fatalf("outer events=%+v", events)
}
first, second := <-ids, <-ids
if first == second || first == r.RunID || second == r.RunID {
t.Fatal("delegated identities are not independent")
}
}
type classifiedObservationTestError struct{ cause error }
func (e *classifiedObservationTestError) Error() string { return "injected failure" }
func (e *classifiedObservationTestError) Unwrap() error { return e.cause }
func TestInjectedAttemptAndTerminalFailureCategoriesAgree(t *testing.T) {
for _, tc := range []struct {
name string
cause error
want promptkit.ExecutionFailureKind
}{
{"nil success", nil, promptkit.ExecutionFailureMalformedResponse},
{"malformed", promptkit.ErrMalformedResponse, promptkit.ExecutionFailureMalformedResponse},
{"transport", promptkit.ErrTransport, promptkit.ExecutionFailureTransport},
{"arbitrary", errors.New("transport failed"), promptkit.ExecutionFailureGeneration},
{"canceled", errors.Join(promptkit.ErrTransport, context.Canceled), promptkit.ExecutionFailureCanceled},
{"deadline", errors.Join(promptkit.ErrMalformedResponse, context.DeadlineExceeded), promptkit.ExecutionFailureDeadline},
} {
t.Run(tc.name, func(t *testing.T) {
var injected error
var typed *classifiedObservationTestError
if tc.cause != nil {
typed = &classifiedObservationTestError{cause: tc.cause}
injected = fmt.Errorf("wrapped: %w", typed)
}
calls := 0
engine := observationEngine(t, observationClientFunc(func(context.Context, promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
calls++
return nil, injected
}))
var events []promptkit.ExecutionEvent
retries := 3
result, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", TransientRetries: &retries, Observer: func(e promptkit.ExecutionEvent) { events = append(events, e) }})
if result != nil || !errors.Is(err, promptkit.ErrLLMGenerate) || calls != 1 || len(events) != 3 {
t.Fatalf("result=%+v err=%v calls=%d events=%+v", result, err, calls, events)
}
if events[1].Failure != tc.want || events[2].Failure != tc.want {
t.Fatalf("attempt=%s terminal=%s want=%s", events[1].Failure, events[2].Failure, tc.want)
}
if injected != nil {
var got *classifiedObservationTestError
if !errors.Is(err, injected) || !errors.Is(err, tc.cause) || !errors.As(err, &got) || got != typed {
t.Fatalf("lost injected identity: %v", err)
}
} else if !errors.Is(err, promptkit.ErrMalformedResponse) {
t.Fatalf("nil success error=%v", err)
}
})
}
}

View File

@@ -0,0 +1,233 @@
package promptkit_test
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"testing/fstest"
"gitea.maximumdirect.net/eric/promptkit"
)
func observationEngine(t *testing.T, client promptkit.LLMClient) *promptkit.Engine {
t.Helper()
engine, err := promptkit.NewEngine(promptkit.Config{},
promptkit.WithPromptFS(contractPromptFS("prompt", "profile", "hello"), "."),
promptkit.WithProfiles(promptkit.Profile{ID: "profile", Endpoint: "https://example.invalid/v1", Model: "model"}),
promptkit.WithLLMClient(client))
if err != nil {
t.Fatal(err)
}
return engine
}
func TestRunObservationCoversFacadeErrors(t *testing.T) {
for _, tc := range []struct {
name string
engine *promptkit.Engine
request promptkit.RunRequest
failure promptkit.ExecutionFailureKind
}{
{name: "nil engine", failure: promptkit.ExecutionFailureInvalidConfig},
{name: "conversion", request: promptkit.RunRequest{AppendedMessages: []promptkit.RenderedMessage{{Role: "invalid"}}}, failure: promptkit.ExecutionFailureInvalidRequest},
} {
t.Run(tc.name, func(t *testing.T) {
var events []promptkit.ExecutionEvent
request := tc.request
request.Observer = func(event promptkit.ExecutionEvent) { events = append(events, event) }
engine := tc.engine
if tc.name == "conversion" {
engine = observationEngine(t, &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}})
}
result, err := engine.Run(context.Background(), request)
if result != nil || err == nil || len(events) != 2 || events[0].Kind != promptkit.ExecutionStarted || events[1].Kind != promptkit.ExecutionFinished || events[1].Failure != tc.failure || events[1].Attempts != 0 {
t.Fatalf("result=%+v error=%v events=%+v", result, err, events)
}
if events[0].PromptID != "" || events[1].PromptID != "" || events[0].RunID == "" || events[0].RunID != events[1].RunID {
t.Fatalf("identities=%+v", events)
}
})
}
}
func TestRunObservationHasResolvedIdentitiesAndAccounting(t *testing.T) {
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok", Usage: promptkit.TokenUsage{PromptTokens: 3}}}
engine := observationEngine(t, client)
var events []promptkit.ExecutionEvent
request := promptkit.RunRequest{PromptID: "prompt", Observer: func(event promptkit.ExecutionEvent) { events = append(events, event) }}
result, err := engine.Run(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if len(events) != 3 || events[0].Kind != promptkit.ExecutionStarted || events[1].Kind != promptkit.ExecutionAttempt || events[2].Kind != promptkit.ExecutionFinished {
t.Fatalf("events=%+v", events)
}
if events[0].PromptID != "" || events[1].PromptID != "prompt" || events[1].PromptVersion != "1" || events[1].ProfileID != "profile" || events[1].Model != "model" {
t.Fatalf("selection=%+v", events)
}
if result.RunID == "" || events[0].RunID != result.RunID || events[2].RunID != result.RunID || events[2].Attempts != 1 || events[2].TransientRetries != 0 || events[2].RepairCalls != 0 || events[2].Outcome != promptkit.ExecutionOutcomeSuccess {
t.Fatalf("result=%+v terminal=%+v", result, events[2])
}
if got := events[2].Usage.PromptTokens; got.Value != 3 || !got.Known || !got.Complete {
t.Fatalf("usage=%+v", got)
}
if got := events[2].Usage.CompletionTokens; got.Known || got.Complete {
t.Fatalf("unknown usage=%+v", got)
}
encoded, err := json.Marshal(request)
if err != nil {
t.Fatal(err)
}
if string(encoded) == "" || json.Valid(encoded) == false {
t.Fatalf("request JSON=%q", encoded)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(encoded, &fields); err != nil {
t.Fatal(err)
}
if _, ok := fields["Observer"]; ok {
t.Fatalf("observer leaked into JSON: %s", encoded)
}
}
func TestRunObservationClassifiesInvalidOutputAndGenerationFailure(t *testing.T) {
for _, tc := range []struct {
name, content string
err error
validation promptkit.ValidationMode
outcome promptkit.ExecutionOutcome
failure promptkit.ExecutionFailureKind
}{
{name: "invalid", content: "not json", validation: promptkit.ValidationJSON, outcome: promptkit.ExecutionOutcomeInvalidOutput},
{name: "skipped", content: "anything", validation: promptkit.ValidationNone, outcome: promptkit.ExecutionOutcomeSuccess},
{name: "generation error", err: errors.New("private model detail"), outcome: promptkit.ExecutionOutcomeError, failure: promptkit.ExecutionFailureGeneration},
} {
t.Run(tc.name, func(t *testing.T) {
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: tc.content}, err: tc.err}
engine := observationEngine(t, client)
var events []promptkit.ExecutionEvent
request := promptkit.RunRequest{PromptID: "prompt", Observer: func(event promptkit.ExecutionEvent) { events = append(events, event) }}
if tc.validation != "" {
request.Validation = &promptkit.OutputContract{Format: promptkit.FormatText, ValidationMode: tc.validation}
}
result, err := engine.Run(context.Background(), request)
if tc.err != nil && (result != nil || !errors.Is(err, promptkit.ErrLLMGenerate)) {
t.Fatalf("result=%+v error=%v", result, err)
}
if tc.err == nil && (err != nil || result == nil) {
t.Fatalf("result=%+v error=%v", result, err)
}
terminal := events[len(events)-1]
if terminal.Outcome != tc.outcome || terminal.Failure != tc.failure || terminal.Attempts != 1 {
t.Fatalf("terminal=%+v", terminal)
}
})
}
}
func TestRunObservationPromptFailureAndPanicCleanup(t *testing.T) {
engine, err := promptkit.NewEngine(promptkit.Config{}, promptkit.WithPromptFS(fstest.MapFS{}, "."))
if err != nil {
t.Fatal(err)
}
var events []promptkit.ExecutionEvent
_, err = engine.Run(context.Background(), promptkit.RunRequest{PromptID: "missing", Observer: func(event promptkit.ExecutionEvent) { events = append(events, event) }})
if !errors.Is(err, promptkit.ErrPromptNotFound) || len(events) != 2 || events[1].Failure != promptkit.ExecutionFailurePromptNotFound || events[1].PromptID != "" {
t.Fatalf("error=%v events=%+v", err, events)
}
engine = observationEngine(t, &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}})
func() {
defer func() {
if recover() == nil {
t.Error("expected observer panic")
}
}()
_, _ = engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", Observer: func(event promptkit.ExecutionEvent) {
if event.Kind == promptkit.ExecutionAttempt {
panic("callback")
}
}})
}()
result, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt"})
if err != nil || result == nil {
t.Fatalf("run after callback panic: result=%+v error=%v", result, err)
}
}
func TestRunObservationIncludesCompletedUsageBeforeRepairFailure(t *testing.T) {
client := &fakeLLMClient{responses: []*promptkit.GenerateResponse{
{Content: "invalid json", Usage: promptkit.TokenUsage{PromptTokens: 7}}, nil,
}}
engine := observationEngine(t, client)
var events []promptkit.ExecutionEvent
result, err := engine.Run(context.Background(), promptkit.RunRequest{
PromptID: "prompt", Validation: &promptkit.OutputContract{Format: promptkit.FormatJSON,
ValidationMode: promptkit.ValidationJSON, RepairAttempts: 1},
Observer: func(event promptkit.ExecutionEvent) { events = append(events, event) },
})
if result != nil || !errors.Is(err, promptkit.ErrLLMGenerate) {
t.Fatalf("result=%+v error=%v", result, err)
}
if len(events) != 4 || events[1].Outcome != promptkit.ExecutionOutcomeSuccess || events[2].Generation != 2 || events[2].Outcome != promptkit.ExecutionOutcomeError {
t.Fatalf("events=%+v", events)
}
terminal := events[3]
if terminal.Attempts != 2 || terminal.RepairCalls != 1 || terminal.Failure != promptkit.ExecutionFailureMalformedResponse || terminal.Usage.PromptTokens.Value != 7 || terminal.Usage.PromptTokens.Complete {
t.Fatalf("terminal=%+v", terminal)
}
}
func TestRunObservationValidationPreparationError(t *testing.T) {
engine := observationEngine(t, &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}})
var events []promptkit.ExecutionEvent
result, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", Validation: &promptkit.OutputContract{Format: promptkit.FormatJSON, ValidationMode: promptkit.ValidationJSONSchema, SchemaPath: "missing-schema.json"}, Observer: func(event promptkit.ExecutionEvent) { events = append(events, event) }})
if result != nil || !errors.Is(err, promptkit.ErrValidation) || len(events) != 2 || events[1].Failure != promptkit.ExecutionFailureValidation || events[1].Attempts != 0 {
t.Fatalf("result=%+v error=%v events=%+v", result, err, events)
}
}
func TestRunObservationAdmissionRejection(t *testing.T) {
artifactRelease := make(chan struct{})
reader := &capacityArtifactReader{entered: make(chan struct{}, 2), release: artifactRelease}
client := newCapacityGateClient(closedCapacityChannel(), 2)
engine := newBackendCapacityEngine(t, client, 1, capacityInt(0), reader)
first := make(chan capacityRunResult, 1)
go runCapacityRequest(engine, context.Background(), capacityInputRequest("http://first.example/v1"), first)
awaitCapacitySignal(t, reader.entered, "first artifact read")
var events []promptkit.ExecutionEvent
request := capacityInputRequest("http://second.example/v1")
request.Observer = func(event promptkit.ExecutionEvent) { events = append(events, event) }
result, err := engine.Run(context.Background(), request)
close(artifactRelease)
if firstResult := awaitCapacityRun(t, first); firstResult.err != nil {
t.Fatalf("first run: %v", firstResult.err)
}
if result != nil || !errors.Is(err, promptkit.ErrCapacityExceeded) || len(events) != 2 || events[1].Failure != promptkit.ExecutionFailureCapacity || events[1].BackendID != "limited" || events[1].Attempts != 0 {
t.Fatalf("result=%+v error=%v events=%+v", result, err, events)
}
}
func TestRunObservationValuesCannotChangeExecutionAndHideDiagnostics(t *testing.T) {
const privateDetail = "provider-private-marker"
client := &fakeLLMClient{err: errors.New(privateDetail)}
engine := observationEngine(t, client)
var events []promptkit.ExecutionEvent
_, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt", Observer: func(event promptkit.ExecutionEvent) {
events = append(events, event)
event.Model = "changed by callback"
event.RunID = "changed by callback"
}})
if !errors.Is(err, promptkit.ErrLLMGenerate) || len(events) != 3 || events[1].RunID != events[2].RunID || events[2].Model != "model" {
t.Fatalf("error=%v events=%+v", err, events)
}
encoded, err := json.Marshal(events)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), privateDetail) || strings.Contains(string(encoded), "https://example.invalid") {
t.Fatalf("sensitive data in events: %s", encoded)
}
}

198
observations.go Normal file
View File

@@ -0,0 +1,198 @@
package promptkit
import (
"context"
"errors"
"time"
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
)
// ExecutionObserver receives immutable metadata events synchronously. Callers
// must synchronize shared state if separate runs use the same observer. A
// callback must return for its run to progress; cancellation cannot interrupt
// consumer code already in the callback. Do not block on work requiring the
// same run's admission slot. Panics propagate without a promise of a terminal
// event, and Promptkit keeps no asynchronous queue or durable history.
type ExecutionObserver func(ExecutionEvent)
// ExecutionEventKind identifies one point in an execution's lifecycle.
type ExecutionEventKind string
const (
ExecutionStarted ExecutionEventKind = "started"
ExecutionAttempt ExecutionEventKind = "attempt"
ExecutionRetryDelay ExecutionEventKind = "retry_delay"
ExecutionFinished ExecutionEventKind = "finished"
)
// ExecutionOutcome reports whether the operation or attempt succeeded.
type ExecutionOutcome string
const (
ExecutionOutcomeSuccess ExecutionOutcome = "success"
ExecutionOutcomeInvalidOutput ExecutionOutcome = "invalid_output"
ExecutionOutcomeError ExecutionOutcome = "error"
)
// ExecutionFailureKind is a content-free classification of an error.
type ExecutionFailureKind string
const (
ExecutionFailureNone ExecutionFailureKind = ""
ExecutionFailureCanceled ExecutionFailureKind = "canceled"
ExecutionFailureDeadline ExecutionFailureKind = "deadline"
ExecutionFailureTransport ExecutionFailureKind = "transport"
ExecutionFailureProviderStatus ExecutionFailureKind = "provider_status"
ExecutionFailureMalformedResponse ExecutionFailureKind = "malformed_response"
ExecutionFailureCapacity ExecutionFailureKind = "capacity"
ExecutionFailureInvalidConfig ExecutionFailureKind = "invalid_config"
ExecutionFailureInvalidRequest ExecutionFailureKind = "invalid_request"
ExecutionFailurePromptNotFound ExecutionFailureKind = "prompt_not_found"
ExecutionFailureProfileNotFound ExecutionFailureKind = "profile_not_found"
ExecutionFailurePromptLoad ExecutionFailureKind = "prompt_load"
ExecutionFailureProfileLoad ExecutionFailureKind = "profile_load"
ExecutionFailureArtifactLoad ExecutionFailureKind = "artifact_load"
ExecutionFailurePromptRender ExecutionFailureKind = "prompt_render"
ExecutionFailureValidation ExecutionFailureKind = "validation"
ExecutionFailureGeneration ExecutionFailureKind = "generation"
ExecutionFailureOther ExecutionFailureKind = "other"
)
// ObservedTokenCount distinguishes an omitted count from a reported zero.
// On an attempt, Known and Complete are true together only for a reported
// non-null integer. An aggregate Value sums known contributions; Known means
// at least one attempt reported the field, and Complete means every dispatched
// attempt did. With no dispatched attempts, aggregate zero is known and
// complete. Unknown fields have Value zero.
type ObservedTokenCount struct {
Value int
Known bool
Complete bool
}
// ObservedTokenUsage contains presence-aware usage for one attempt or an
// aggregate. It does not prove complete provider billing.
type ObservedTokenUsage struct {
PromptTokens ObservedTokenCount
CompletionTokens ObservedTokenCount
TotalTokens ObservedTokenCount
CachedTokens ObservedTokenCount
CacheWriteTokens ObservedTokenCount
}
// ExecutionEvent contains metadata only, not provider text, request content,
// credentials, URLs, headers, or error objects. It has no stable JSON contract.
// Identifiers themselves may be sensitive. Nonterminal aggregate counters are
// zero; a terminal usage total must not be added to attempt usage.
//
// Generation is one for the initial generation and increases for repairs;
// Attempt is one-based within each generation. Dispatched means Promptkit
// invoked an HTTP attempt or injected Generate, not that a network packet was
// sent or billable work occurred. A canceled capacity acquisition emits a
// non-dispatched attempt with no usage. A retry-delay event describes the
// completed or interrupted wait after the indexed failed attempt.
//
// GenerationDuration excludes capacity waiting, retry delay, and callback
// work. Terminal phase durations sum measured phases; terminal Duration is
// wall time and may also include preparation, validation, and callbacks.
// Attempt success means generation succeeded, even if later validation rejects
// its output. Failure is classified metadata; RetriesExhausted is a separate
// flag and does not replace the underlying failure kind.
type ExecutionEvent struct {
Kind ExecutionEventKind
RunID string
Time time.Time
PromptID string
PromptVersion string
ProfileID string
BackendID string
Model string
Generation uint64
Attempt uint64
Dispatched bool
CapacityWait time.Duration
GenerationDuration time.Duration
RetryDelay time.Duration
Duration time.Duration
Outcome ExecutionOutcome
Failure ExecutionFailureKind
StatusCode int
RetriesExhausted bool
Usage ObservedTokenUsage
Attempts uint64
TransientRetries uint64
RepairCalls uint64
}
func fromDomainExecutionEvent(value domain.ExecutionEvent) ExecutionEvent {
count := func(c domain.ObservedTokenCount) ObservedTokenCount {
return ObservedTokenCount{Value: c.Value, Known: c.Known, Complete: c.Complete}
}
return ExecutionEvent{
Kind: ExecutionEventKind(value.Kind), RunID: value.RunID, Time: value.Time,
PromptID: value.PromptID, PromptVersion: value.PromptVersion, ProfileID: value.ProfileID,
BackendID: value.BackendID, Model: value.Model, Generation: value.Generation,
Attempt: value.Attempt, Dispatched: value.Dispatched, CapacityWait: value.CapacityWait,
GenerationDuration: value.GenerationDuration, RetryDelay: value.RetryDelay, Duration: value.Duration,
Outcome: ExecutionOutcome(value.Outcome), Failure: ExecutionFailureKind(value.Failure),
StatusCode: value.StatusCode, RetriesExhausted: value.RetriesExhausted,
Usage: ObservedTokenUsage{PromptTokens: count(value.Usage.PromptTokens), CompletionTokens: count(value.Usage.CompletionTokens),
TotalTokens: count(value.Usage.TotalTokens), CachedTokens: count(value.Usage.CachedTokens), CacheWriteTokens: count(value.Usage.CacheWriteTokens)},
Attempts: value.Attempts, TransientRetries: value.TransientRetries, RepairCalls: value.RepairCalls,
}
}
func observedFinish(result *RunResult, err error) domain.ExecutionEvent {
if err == nil {
outcome := domain.ExecutionOutcomeSuccess
if result.Validation.Status == ValidationFailed {
outcome = domain.ExecutionOutcomeInvalidOutput
}
return domain.ExecutionEvent{Outcome: outcome}
}
value := domain.ExecutionEvent{Outcome: domain.ExecutionOutcomeError}
var exhausted *RetryExhaustedError
value.RetriesExhausted = errors.As(err, &exhausted)
var provider *GenerationError
if errors.As(err, &provider) {
value.StatusCode = provider.StatusCode()
}
switch {
case errors.Is(err, context.Canceled):
value.Failure = domain.ExecutionFailureCanceled
case errors.Is(err, context.DeadlineExceeded):
value.Failure = domain.ExecutionFailureDeadline
case errors.Is(err, ErrTransport):
value.Failure = domain.ExecutionFailureTransport
case value.StatusCode != 0:
value.Failure = domain.ExecutionFailureProviderStatus
case errors.Is(err, ErrMalformedResponse):
value.Failure = domain.ExecutionFailureMalformedResponse
case errors.Is(err, ErrCapacityExceeded):
value.Failure = domain.ExecutionFailureCapacity
case errors.Is(err, ErrInvalidConfig):
value.Failure = domain.ExecutionFailureInvalidConfig
case errors.Is(err, ErrPromptNotFound):
value.Failure = domain.ExecutionFailurePromptNotFound
case errors.Is(err, ErrProfileNotFound):
value.Failure = domain.ExecutionFailureProfileNotFound
case errors.Is(err, ErrPromptLoad):
value.Failure = domain.ExecutionFailurePromptLoad
case errors.Is(err, ErrProfileLoad):
value.Failure = domain.ExecutionFailureProfileLoad
case errors.Is(err, ErrInvalidRequest):
value.Failure = domain.ExecutionFailureInvalidRequest
case errors.Is(err, ErrArtifactLoad):
value.Failure = domain.ExecutionFailureArtifactLoad
case errors.Is(err, ErrPromptRender):
value.Failure = domain.ExecutionFailurePromptRender
case errors.Is(err, ErrValidation):
value.Failure = domain.ExecutionFailureValidation
case errors.Is(err, ErrLLMGenerate):
value.Failure = domain.ExecutionFailureGeneration
default:
value.Failure = domain.ExecutionFailureOther
}
return value
}

View File

@@ -11,6 +11,9 @@ const preparedExecutionString = "promptkit.PreparedExecution{opaque}"
// PreparedExecution contains no supported serializable state and cannot be
// used as a restartable job. Copying the value preserves the same shared
// lifecycle; it does not create another execution attempt.
// A successful PrepareExecution may retain an Observer function in this
// handle's private payload. The callback and other private execution settings
// are cleared on claim completion or Discard. Details never exposes them.
type PreparedExecution struct {
internal *usecase.PreparedExecution
}

View File

@@ -0,0 +1,186 @@
package promptkit_test
import (
"context"
"errors"
"sync"
"testing"
"gitea.maximumdirect.net/eric/promptkit"
)
func TestPreparedObservationStartsOnlyAfterClaimAndKeepsObserver(t *testing.T) {
engine := newPreparedContractEngine(t, &preparedRecordingClient{response: &promptkit.GenerateResponse{Content: "ok"}}, "hello")
var events []promptkit.ExecutionEvent
request := promptkit.RunRequest{PromptID: "prepared", Observer: func(event promptkit.ExecutionEvent) { events = append(events, event) }}
prepared, err := engine.PrepareExecution(context.Background(), request)
if err != nil {
t.Fatal(err)
}
request.Observer = func(promptkit.ExecutionEvent) { t.Error("later observer was invoked") }
if len(events) != 0 {
t.Fatalf("preparation emitted events: %+v", events)
}
result, err := engine.RunPrepared(context.Background(), prepared)
if err != nil {
t.Fatal(err)
}
if len(events) != 3 || events[0].Kind != promptkit.ExecutionStarted || events[1].Kind != promptkit.ExecutionAttempt || events[2].Kind != promptkit.ExecutionFinished {
t.Fatalf("events=%+v", events)
}
if events[0].PromptID != "prepared" || events[0].ProfileID != "profile" || events[0].Model != "model" || events[0].RunID != result.RunID || events[2].RunID != result.RunID || events[2].Attempts != 1 {
t.Fatalf("result=%+v events=%+v", result, events)
}
if again, err := engine.RunPrepared(context.Background(), prepared); again != nil || !errors.Is(err, promptkit.ErrInvalidRequest) || len(events) != 3 {
t.Fatalf("reused result=%+v error=%v events=%+v", again, err, events)
}
}
func TestPreparedObservationInvalidForeignAndDiscardedHandlesAreSilent(t *testing.T) {
engine := newPreparedContractEngine(t, &preparedRecordingClient{response: &promptkit.GenerateResponse{Content: "ok"}}, "hello")
foreign := newPreparedContractEngine(t, &preparedRecordingClient{response: &promptkit.GenerateResponse{Content: "ok"}}, "hello")
var events []promptkit.ExecutionEvent
request := promptkit.RunRequest{PromptID: "prepared", Observer: func(event promptkit.ExecutionEvent) { events = append(events, event) }}
prepared, err := engine.PrepareExecution(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if result, err := foreign.RunPrepared(context.Background(), prepared); result != nil || !errors.Is(err, promptkit.ErrInvalidRequest) || len(events) != 0 {
t.Fatalf("foreign result=%+v error=%v events=%+v", result, err, events)
}
prepared.Discard()
if result, err := engine.RunPrepared(context.Background(), prepared); result != nil || !errors.Is(err, promptkit.ErrInvalidRequest) || len(events) != 0 {
t.Fatalf("discarded result=%+v error=%v events=%+v", result, err, events)
}
if result, err := engine.RunPrepared(context.Background(), nil); result != nil || !errors.Is(err, promptkit.ErrInvalidRequest) || len(events) != 0 {
t.Fatalf("nil result=%+v error=%v events=%+v", result, err, events)
}
}
func TestPreparedObservationCountsCompletedUsageBeforeRepairFailure(t *testing.T) {
client := &preparedRecordingClient{responses: []*promptkit.GenerateResponse{{Content: "invalid json", Usage: promptkit.TokenUsage{PromptTokens: 4}}, nil}}
engine := newPreparedContractEngine(t, client, "hello")
var events []promptkit.ExecutionEvent
prepared, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prepared", Validation: &promptkit.OutputContract{Format: promptkit.FormatJSON, ValidationMode: promptkit.ValidationJSON, RepairAttempts: 1}, Observer: func(event promptkit.ExecutionEvent) { events = append(events, event) }})
if err != nil {
t.Fatal(err)
}
result, err := engine.RunPrepared(context.Background(), prepared)
if result != nil || !errors.Is(err, promptkit.ErrLLMGenerate) || len(events) != 4 {
t.Fatalf("result=%+v error=%v events=%+v", result, err, events)
}
terminal := events[3]
if terminal.Outcome != promptkit.ExecutionOutcomeError || terminal.Failure != promptkit.ExecutionFailureMalformedResponse || terminal.Attempts != 2 || terminal.RepairCalls != 1 || terminal.Usage.PromptTokens.Value != 4 || terminal.Usage.PromptTokens.Complete {
t.Fatalf("terminal=%+v", terminal)
}
}
func TestPreparedObservationClaimRaceAndPanicCleanup(t *testing.T) {
engine := newPreparedContractEngine(t, &preparedRecordingClient{response: &promptkit.GenerateResponse{Content: "ok"}}, "hello")
var mu sync.Mutex
var events []promptkit.ExecutionEvent
prepared, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prepared", Observer: func(event promptkit.ExecutionEvent) { mu.Lock(); events = append(events, event); mu.Unlock() }})
if err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
var successes, invalid int
var countMu sync.Mutex
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
_, err := engine.RunPrepared(context.Background(), prepared)
countMu.Lock()
defer countMu.Unlock()
if err == nil {
successes++
} else if errors.Is(err, promptkit.ErrInvalidRequest) {
invalid++
} else {
t.Errorf("unexpected error: %v", err)
}
}()
}
wg.Wait()
if successes != 1 || invalid != 7 || len(events) != 3 {
t.Fatalf("successes=%d invalid=%d events=%+v", successes, invalid, events)
}
panicking, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prepared", Observer: func(event promptkit.ExecutionEvent) {
if event.Kind == promptkit.ExecutionStarted {
panic("callback")
}
}})
if err != nil {
t.Fatal(err)
}
func() {
defer func() {
if recover() == nil {
t.Error("expected panic")
}
}()
_, _ = engine.RunPrepared(context.Background(), panicking)
}()
if result, err := engine.RunPrepared(context.Background(), panicking); result != nil || !errors.Is(err, promptkit.ErrInvalidRequest) {
t.Fatalf("reused after panic: result=%+v error=%v", result, err)
}
}
func TestPreparedObservationFinishesAfterCredentialAndCapacityFailures(t *testing.T) {
const envName = "PROMPTKIT_PREPARED_OBSERVATION_KEY"
t.Setenv(envName, "available")
engine, err := promptkit.NewEngine(promptkit.Config{},
promptkit.WithPromptFS(contractPromptFS("prepared", "profile", "hello"), "."),
promptkit.WithProfiles(promptkit.Profile{ID: "profile", Endpoint: "https://example.invalid/v1", Model: "model", APIKeyRequired: true}),
promptkit.WithLLMClient(&fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}}))
if err != nil {
t.Fatal(err)
}
var events []promptkit.ExecutionEvent
prepared, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prepared", Execution: &promptkit.ExecutionTargetOverride{APIKeyEnv: envName}, Observer: func(event promptkit.ExecutionEvent) { events = append(events, event) }})
if err != nil {
t.Fatal(err)
}
t.Setenv(envName, "")
result, err := engine.RunPrepared(context.Background(), prepared)
if result != nil || !errors.Is(err, promptkit.ErrInvalidRequest) || len(events) != 2 || events[1].Failure != promptkit.ExecutionFailureInvalidRequest || events[1].Attempts != 0 {
t.Fatalf("credential result=%+v error=%v events=%+v", result, err, events)
}
release := make(chan struct{})
client := newCapacityGateClient(release, 2)
limited := newBackendCapacityEngine(t, client, 1, capacityInt(0), nil)
first := make(chan capacityRunResult, 1)
go runCapacityRequest(limited, context.Background(), promptkit.RunRequest{PromptID: "prompt"}, first)
awaitCapacityRequest(t, client.started)
events = nil
prepared, err = limited.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prompt", Observer: func(event promptkit.ExecutionEvent) { events = append(events, event) }})
if err != nil {
t.Fatal(err)
}
result, err = limited.RunPrepared(context.Background(), prepared)
close(release)
if firstResult := awaitCapacityRun(t, first); firstResult.err != nil {
t.Fatalf("first: %v", firstResult.err)
}
if result != nil || !errors.Is(err, promptkit.ErrCapacityExceeded) || len(events) != 2 || events[1].Failure != promptkit.ExecutionFailureCapacity || events[1].Attempts != 0 {
t.Fatalf("capacity result=%+v error=%v events=%+v", result, err, events)
}
}
func TestPreparedObservationCanceledClaimStillFinishes(t *testing.T) {
engine := newPreparedContractEngine(t, &preparedRecordingClient{response: &promptkit.GenerateResponse{Content: "ok"}}, "hello")
var events []promptkit.ExecutionEvent
prepared, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prepared", Observer: func(event promptkit.ExecutionEvent) { events = append(events, event) }})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
result, err := engine.RunPrepared(ctx, prepared)
if result != nil || !errors.Is(err, context.Canceled) || len(events) != 3 || events[1].Dispatched || events[2].Failure != promptkit.ExecutionFailureCanceled || events[2].Attempts != 0 {
t.Fatalf("result=%+v error=%v events=%+v", result, err, events)
}
}

View File

@@ -0,0 +1,347 @@
package promptkit_test
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"sync/atomic"
"testing"
"testing/fstest"
"time"
"gitea.maximumdirect.net/eric/promptkit"
)
func observedHTTPResponse(status int, body string) *http.Response {
return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}
}
func TestPublicRetryRepairSequenceKeepsUsageAndEventsConsistent(t *testing.T) {
responses := []*http.Response{
observedHTTPResponse(503, `{"error":{"message":"private-provider-marker"}}`),
observedHTTPResponse(200, `{"choices":[{"message":{"content":"not json"}}],"usage":{"prompt_tokens":0,"completion_tokens":2}}`),
observedHTTPResponse(503, `{"error":{"message":"private-provider-marker"}}`),
observedHTTPResponse(200, `{"choices":[{"message":{"content":"{\"ok\":true}"}}],"usage":{"prompt_tokens":3,"total_tokens":5,"cache_write_tokens":0}}`),
}
var calls int
one := 1
config := contractConfig(frameworkSchemaDir)
config.TransientRetries = &one
config.HTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
if calls >= len(responses) {
t.Error("extra HTTP attempt")
return nil, errors.New("extra attempt")
}
r := responses[calls]
calls++
return r, nil
})}
engine, err := promptkit.NewEngine(config)
if err != nil {
t.Fatal(err)
}
var events []promptkit.ExecutionEvent
req := generationErrorRunRequest()
req.Validation = &promptkit.OutputContract{Format: promptkit.FormatJSON, ValidationMode: promptkit.ValidationJSON, RepairAttempts: 1}
req.Observer = func(event promptkit.ExecutionEvent) { events = append(events, event) }
result, err := engine.Run(context.Background(), req)
if err != nil || result == nil || !result.Validation.IsValid || calls != 4 {
t.Fatalf("result=%+v error=%v calls=%d", result, err, calls)
}
if len(events) != 8 {
t.Fatalf("events=%+v", events)
}
for index, want := range []promptkit.ExecutionEventKind{promptkit.ExecutionStarted, promptkit.ExecutionAttempt, promptkit.ExecutionRetryDelay, promptkit.ExecutionAttempt, promptkit.ExecutionAttempt, promptkit.ExecutionRetryDelay, promptkit.ExecutionAttempt, promptkit.ExecutionFinished} {
if events[index].Kind != want {
t.Fatalf("event %d = %+v", index, events[index])
}
}
if events[1].Generation != 1 || events[3].Attempt != 2 || events[4].Generation != 2 || events[4].Attempt != 1 || events[6].Generation != 2 || events[6].Attempt != 2 {
t.Fatalf("attempt indexes=%+v", events)
}
terminal := events[7]
if terminal.RunID != result.RunID || terminal.Attempts != 4 || terminal.TransientRetries != 2 || terminal.RepairCalls != 1 || terminal.Outcome != promptkit.ExecutionOutcomeSuccess || terminal.RetriesExhausted {
t.Fatalf("terminal=%+v result=%+v", terminal, result)
}
if terminal.Usage.PromptTokens.Value != 3 || !terminal.Usage.PromptTokens.Known || terminal.Usage.PromptTokens.Complete || terminal.Usage.CompletionTokens.Value != 2 || terminal.Usage.CompletionTokens.Complete || terminal.Usage.TotalTokens.Value != 5 || terminal.Usage.TotalTokens.Complete || !events[3].Usage.PromptTokens.Known || events[3].Usage.PromptTokens.Value != 0 {
t.Fatalf("observed usage=%+v", terminal.Usage)
}
if result.Usage.PromptTokens != 3 || result.Usage.CompletionTokens != 2 || result.Usage.TotalTokens != 5 || result.Usage.CacheWriteTokens != 0 {
t.Fatalf("result usage=%+v", result.Usage)
}
encoded, err := json.Marshal(events)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), "private-provider-marker") || strings.Contains(string(encoded), "not json") {
t.Fatalf("events leaked content: %s", encoded)
}
}
func TestProfilesSharingBackendCoordinateRetryRepairAndAdmission(t *testing.T) {
firstA := make(chan struct{})
enteredB := make(chan struct{})
releaseB := make(chan struct{})
var callsA, callsB, active atomic.Int64
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
if active.Add(1) != 1 {
t.Error("overlapping HTTP attempts on limited backend")
}
defer active.Add(-1)
var payload struct {
Model string `json:"model"`
}
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
return nil, err
}
switch payload.Model {
case "model-a":
switch callsA.Add(1) {
case 1:
response := observedHTTPResponse(503, `{"error":{}}`)
response.Header.Set("Retry-After", "1")
close(firstA)
return response, nil
case 2:
return observedHTTPResponse(200, `{"choices":[{"message":{"content":"invalid json"}}]}`), nil
default:
return observedHTTPResponse(200, `{"choices":[{"message":{"content":"{\"ok\":true}"}}]}`), nil
}
case "model-b":
callsB.Add(1)
close(enteredB)
select {
case <-releaseB:
case <-req.Context().Done():
return nil, req.Context().Err()
}
return observedHTTPResponse(200, `{"choices":[{"message":{"content":"done"}}]}`), nil
default:
return nil, errors.New("unexpected model")
}
})
one := 1
engine, err := promptkit.NewEngine(promptkit.Config{HTTPClient: &http.Client{Transport: transport}, TransientRetries: &one},
promptkit.WithPromptFS(contractPromptFS("prompt", "profile-a", "hello"), "."),
promptkit.WithBackend(promptkit.Backend{ID: "limited", Endpoint: "https://example.invalid/v1", ConcurrencyLimit: 1, QueueCapacity: &one}),
promptkit.WithProfiles(promptkit.Profile{ID: "profile-a", BackendID: "limited", Model: "model-a"}, promptkit.Profile{ID: "profile-b", BackendID: "limited", Model: "model-b"}))
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
resultA := make(chan capacityRunResult, 1)
requestA := promptkit.RunRequest{PromptID: "prompt", Validation: &promptkit.OutputContract{Format: promptkit.FormatJSON, ValidationMode: promptkit.ValidationJSON, RepairAttempts: 1}}
go runCapacityRequest(engine, ctx, requestA, resultA)
select {
case <-firstA:
case <-ctx.Done():
t.Fatal("first model attempt did not start")
}
resultB := make(chan capacityRunResult, 1)
go runCapacityRequest(engine, ctx, promptkit.RunRequest{PromptID: "prompt", ProfileID: "profile-b"}, resultB)
select {
case <-enteredB:
case <-ctx.Done():
t.Fatal("second profile did not acquire permit during backoff")
}
third, thirdErr := engine.Run(ctx, promptkit.RunRequest{PromptID: "prompt", ProfileID: "profile-b"})
if third != nil || !errors.Is(thirdErr, promptkit.ErrCapacityExceeded) {
t.Fatalf("admission during two active runs: result=%+v error=%v", third, thirdErr)
}
close(releaseB)
if outcome := <-resultB; outcome.err != nil || outcome.result == nil {
t.Fatalf("second profile: %+v", outcome)
}
if outcome := <-resultA; outcome.err != nil || outcome.result == nil || !outcome.result.Validation.IsValid {
t.Fatalf("retry and repair: %+v", outcome)
}
if callsA.Load() != 3 || callsB.Load() != 1 {
t.Fatalf("calls A=%d B=%d", callsA.Load(), callsB.Load())
}
}
func TestDifferentBackendPoolsCanDispatchTogether(t *testing.T) {
entered := make(chan string, 2)
release := make(chan struct{})
var active atomic.Int64
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
var payload struct {
Model string `json:"model"`
}
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
return nil, err
}
active.Add(1)
defer active.Add(-1)
entered <- payload.Model
select {
case <-release:
case <-req.Context().Done():
return nil, req.Context().Err()
}
return observedHTTPResponse(200, `{"choices":[{"message":{"content":"ok"}}]}`), nil
})
zero := 0
engine, err := promptkit.NewEngine(promptkit.Config{HTTPClient: &http.Client{Transport: transport}, TransientRetries: &zero},
promptkit.WithPromptFS(contractPromptFS("prompt", "profile-a", "hello"), "."),
promptkit.WithBackend(promptkit.Backend{ID: "backend-a", Endpoint: "https://example.invalid/v1", ConcurrencyLimit: 1, QueueCapacity: &zero}),
promptkit.WithBackend(promptkit.Backend{ID: "backend-b", Endpoint: "https://example.invalid/v1", ConcurrencyLimit: 1, QueueCapacity: &zero}),
promptkit.WithProfiles(promptkit.Profile{ID: "profile-a", BackendID: "backend-a", Model: "model-a"}, promptkit.Profile{ID: "profile-b", BackendID: "backend-b", Model: "model-b"}))
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
results := make(chan capacityRunResult, 2)
go runCapacityRequest(engine, ctx, promptkit.RunRequest{PromptID: "prompt"}, results)
select {
case model := <-entered:
if model != "model-a" {
t.Fatalf("first model=%q", model)
}
case <-ctx.Done():
t.Fatal("first backend did not dispatch")
}
go runCapacityRequest(engine, ctx, promptkit.RunRequest{PromptID: "prompt", ProfileID: "profile-b"}, results)
select {
case model := <-entered:
if model != "model-b" {
t.Fatalf("second model=%q", model)
}
case <-ctx.Done():
t.Fatal("independent backend did not dispatch")
}
if active.Load() != 2 {
t.Fatalf("active HTTP attempts=%d", active.Load())
}
close(release)
for range 2 {
if outcome := <-results; outcome.err != nil || outcome.result == nil {
t.Fatalf("outcome=%+v", outcome)
}
}
}
func TestPreparedRetriesFreezeSourceAndCredentialAtExecution(t *testing.T) {
const envName = "PROMPTKIT_PREPARED_RETRY_KEY"
t.Setenv(envName, "first-key")
source := contractPromptFS("prompt", "profile", "original message")
var calls int
var auth, content []string
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
auth = append(auth, req.Header.Get("Authorization"))
var payload struct {
Messages []struct {
Content string `json:"content"`
} `json:"messages"`
}
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
return nil, err
}
content = append(content, payload.Messages[0].Content)
if calls == 1 {
t.Setenv(envName, "later-key")
return observedHTTPResponse(503, `{"error":{}}`), nil
}
return observedHTTPResponse(200, `{"choices":[{"message":{"content":"ok"}}]}`), nil
})
one := 1
engine, err := promptkit.NewEngine(promptkit.Config{HTTPClient: &http.Client{Transport: transport}, TransientRetries: &one},
promptkit.WithPromptFS(source, "."), promptkit.WithProfiles(promptkit.Profile{ID: "profile", Endpoint: "https://example.invalid/v1", Model: "model"}))
if err != nil {
t.Fatal(err)
}
var events []promptkit.ExecutionEvent
prepared, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prompt", Execution: &promptkit.ExecutionTargetOverride{APIKeyEnv: envName}, Observer: func(event promptkit.ExecutionEvent) { events = append(events, event) }})
if err != nil {
t.Fatal(err)
}
source["prompt.yaml"] = &fstest.MapFile{Data: []byte(`id: prompt
version: "1"
default_profile: profile
messages:
- role: user
content: changed message
output:
format: text
validation_mode: none
`)}
result, err := engine.RunPrepared(context.Background(), prepared)
if err != nil || result == nil || calls != 2 {
t.Fatalf("result=%+v error=%v calls=%d", result, err, calls)
}
for index := range auth {
if auth[index] != "Bearer first-key" || content[index] != "original message" {
t.Fatalf("attempt %d auth=%q content=%q", index, auth[index], content[index])
}
}
if events[len(events)-1].Attempts != 2 || events[len(events)-1].TransientRetries != 1 {
t.Fatalf("terminal=%+v", events[len(events)-1])
}
}
func TestPublicNonretryableAndInjectedAttemptsStaySingle(t *testing.T) {
three := 3
engine, calls := retryTestEngine(t, &three, func(int) *http.Response { return retryHTTPResponse(400, "") })
var events []promptkit.ExecutionEvent
req := generationErrorRunRequest()
req.Observer = func(event promptkit.ExecutionEvent) { events = append(events, event) }
result, err := engine.Run(context.Background(), req)
if result != nil || !errors.Is(err, promptkit.ErrLLMGenerate) || errors.Is(err, promptkit.ErrRetriesExhausted) || *calls != 1 || len(events) != 3 {
t.Fatalf("result=%+v error=%v calls=%d events=%+v", result, err, *calls, events)
}
if terminal := events[2]; terminal.Attempts != 1 || terminal.TransientRetries != 0 || terminal.RetriesExhausted || terminal.Failure != promptkit.ExecutionFailureProviderStatus || terminal.StatusCode != 400 {
t.Fatalf("terminal=%+v", terminal)
}
client := &fakeLLMClient{err: errors.New("opaque injected failure")}
injected := observationEngine(t, client)
events = nil
request := promptkit.RunRequest{PromptID: "prompt", TransientRetries: &three, Observer: func(event promptkit.ExecutionEvent) { events = append(events, event) }}
result, err = injected.Run(context.Background(), request)
if result != nil || !errors.Is(err, client.err) || len(client.requests) != 1 || len(events) != 3 || events[2].Attempts != 1 || events[2].TransientRetries != 0 || events[2].Failure != promptkit.ExecutionFailureGeneration {
t.Fatalf("result=%+v error=%v events=%+v", result, err, events)
}
}
func TestPublicExhaustionObservationRetainsProviderCause(t *testing.T) {
one := 1
engine, calls := retryTestEngine(t, &one, func(int) *http.Response { return retryHTTPResponse(503, "") })
var events []promptkit.ExecutionEvent
req := generationErrorRunRequest()
req.Observer = func(event promptkit.ExecutionEvent) { events = append(events, event) }
result, err := engine.Run(context.Background(), req)
var exhausted *promptkit.RetryExhaustedError
var provider *promptkit.GenerationError
if result != nil || *calls != 2 || !errors.As(err, &exhausted) || !errors.As(err, &provider) || exhausted.Attempts() != 2 || provider.StatusCode() != 503 {
t.Fatalf("result=%+v error=%v calls=%d", result, err, *calls)
}
terminal := events[len(events)-1]
if !terminal.RetriesExhausted || terminal.Failure != promptkit.ExecutionFailureProviderStatus || terminal.StatusCode != 503 || terminal.Attempts != 2 || terminal.TransientRetries != 1 {
t.Fatalf("terminal=%+v", terminal)
}
}
func TestPublicObserverCancellationStopsBeforeRetry(t *testing.T) {
one := 1
engine, calls := retryTestEngine(t, &one, func(int) *http.Response { return retryHTTPResponse(503, "") })
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var events []promptkit.ExecutionEvent
req := generationErrorRunRequest()
req.Observer = func(event promptkit.ExecutionEvent) {
events = append(events, event)
if event.Kind == promptkit.ExecutionAttempt {
cancel()
}
}
result, err := engine.Run(ctx, req)
if result != nil || !errors.Is(err, context.Canceled) || *calls != 1 || len(events) != 3 || events[2].Failure != promptkit.ExecutionFailureCanceled || events[2].Attempts != 1 || events[2].RetriesExhausted {
t.Fatalf("result=%+v error=%v calls=%d events=%+v", result, err, *calls, events)
}
}

49
retry_exhausted_error.go Normal file
View File

@@ -0,0 +1,49 @@
package promptkit
import "fmt"
// RetryExhaustedError reports a built-in generation sequence whose positive
// transient retry budget ended with an eligible provider status. Its cause
// remains available through Unwrap, including GenerationError accessors.
// Engine-produced values are immutable. Default and Go-syntax formatting
// redact provider details, and there is no stable JSON representation.
type RetryExhaustedError struct {
attempts uint64
cause error
}
func newRetryExhaustedError(attempts uint64, cause error) *RetryExhaustedError {
return &RetryExhaustedError{attempts: attempts, cause: cause}
}
// Attempts returns the number of model attempts actually dispatched.
func (e *RetryExhaustedError) Attempts() uint64 {
if e == nil {
return 0
}
return e.attempts
}
// Error returns a redacted diagnostic. It is safe on a nil receiver.
func (e *RetryExhaustedError) Error() string {
if e == nil || e.attempts == 0 {
return ErrRetriesExhausted.Error()
}
return fmt.Sprintf("%s after %d attempts", ErrRetriesExhausted, e.attempts)
}
// GoString returns the same redacted diagnostic as Error.
func (e *RetryExhaustedError) GoString() string { return e.Error() }
// Unwrap returns the final failure cause, if present.
func (e *RetryExhaustedError) Unwrap() error {
if e == nil {
return nil
}
return e.cause
}
// Is matches ErrRetriesExhausted, including on nil and zero values.
func (e *RetryExhaustedError) Is(target error) bool {
return target == ErrRetriesExhausted
}

152
scripts/release-check.sh Executable file
View File

@@ -0,0 +1,152 @@
#!/bin/sh
# Run local checks without publishing or changing files.
set -eu
cd "$(dirname "$0")/.."
go test -race -vet=off ./...
go vet ./...
python3 - <<'PY'
import json
import subprocess
def example(name):
return json.loads(subprocess.check_output(
["go", "run", "./examples/go-library/" + name], text=True))
prepared = example("prepare")
assert prepared["prompt_id"] == "example.prepare", prepared
assert prepared["selected_profile"] == "offline-example", prepared
assert prepared["model"] == "offline-model", prepared
assert prepared["message_count"] > 0, prepared
result = example("run")
assert result["output"] == "Ada finished the migration review.", result
assert result["is_valid"] is True, result
assert result["validation_status"] == "passed", result
assert result["model"] == "offline-model", result
assert result["total_tokens"] == 18, result
observed = example("observe")
assert observed["proposal_attempts"] == 2, observed
assert observed["proposal_transient_retries"] == 1, observed
assert observed["proposal_usage_complete"] is False, observed
assert observed["assessment_attempts"] == 1, observed
assert observed["assessment_transient_retries"] == 0, observed
assert observed["assessment_outcome"] == "accepted", observed
PY
unformatted=$(
git ls-files '*.go' |
while IFS= read -r go_file
do
gofmt -l "$go_file"
done
)
test -z "$unformatted"
python3 - <<'PY'
from pathlib import Path
import re
import subprocess
import sys
from urllib.parse import unquote
root = Path.cwd().resolve()
markdown_files = [
root / name
for name in subprocess.check_output(
["git", "ls-files", "*.md"], text=True
).splitlines()
]
link_pattern = re.compile(r"!?\[[^]]*\]\(([^)]+)\)")
heading_pattern = re.compile(r"^#{1,6}\s+(.+?)\s*#*\s*$")
scheme_pattern = re.compile(r"^[a-z][a-z0-9+.-]*:", re.IGNORECASE)
def markdown_lines(path):
in_fence = False
fence = ""
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.lstrip()
marker = stripped[:3]
if marker in {"```", "~~~"}:
if not in_fence:
in_fence = True
fence = marker
elif marker == fence:
in_fence = False
fence = ""
continue
if not in_fence:
yield line
anchor_cache = {}
def anchors(path):
if path in anchor_cache:
return anchor_cache[path]
found = set()
counts = {}
for line in markdown_lines(path):
match = heading_pattern.match(line)
if not match:
continue
heading = re.sub(r"<[^>]+>", "", match.group(1)).replace("`", "")
base = re.sub(r"[^\w\- ]", "", heading.lower()).replace(" ", "-")
count = counts.get(base, 0)
counts[base] = count + 1
found.add(base if count == 0 else f"{base}-{count}")
anchor_cache[path] = found
return found
failures = []
for source in markdown_files:
text = "\n".join(markdown_lines(source))
for match in link_pattern.finditer(text):
target = match.group(1).strip()
if target.startswith("<") and target.endswith(">"):
target = target[1:-1]
if scheme_pattern.match(target) or target.startswith("//"):
continue
path_text, separator, fragment = target.partition("#")
destination = source if not path_text else source.parent / unquote(path_text)
try:
destination = destination.resolve()
destination.relative_to(root)
except ValueError:
failures.append(f"{source.relative_to(root)}: escapes repository: {target}")
continue
if not destination.exists():
failures.append(f"{source.relative_to(root)}: missing target: {target}")
continue
if separator and destination.suffix.lower() == ".md":
fragment = unquote(fragment).lower()
if fragment not in anchors(destination):
failures.append(f"{source.relative_to(root)}: missing anchor: {target}")
if failures:
print("\n".join(failures), file=sys.stderr)
raise SystemExit(1)
PY
case "$(go env GOWORK)" in
''|off) ;;
*) printf '%s\n' 'an active Go workspace is not allowed' >&2; exit 1 ;;
esac
test -z "$(git ls-files go.work go.work.sum)"
test ! -e vendor
if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod
then
printf '%s\n' 'go.mod contains a replacement' >&2
exit 1
fi
git diff --check
git diff --cached --check
credential_pattern='-----BEGIN ([A-Z0-9]+ )?PRIV''ATE KEY-----|AKI''A[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{36,}|sk-[A-Za-z0-9]{32,}'
if git grep -nEI -e "$credential_pattern" -- .
then
printf '%s\n' 'possible credential found' >&2
exit 1
fi

57
scripts/release.sh Executable file
View File

@@ -0,0 +1,57 @@
#!/bin/sh
# Validate one committed candidate and publish only its selected annotated tag.
set -eu
cd "$(dirname "$0")/.."
release_version=${1:?usage: scripts/release.sh vMAJOR.MINOR.PATCH}
printf '%s\n' "$release_version" |
grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
release_commit=$(git rev-parse --verify 'HEAD^{commit}')
check_candidate() {
test "$(git branch --show-current)" = main
test -z "$(git status --porcelain)"
test "$(git rev-parse HEAD)" = "$release_commit"
git fetch origin main
test "$(git rev-parse refs/remotes/origin/main)" = "$release_commit"
if git show-ref --verify --quiet "refs/tags/$release_version"; then
printf '%s\n' "local tag already exists: $release_version" >&2
exit 1
fi
remote_refs=$(git ls-remote --tags origin "refs/tags/$release_version" "refs/tags/$release_version^{}")
test -z "$remote_refs"
}
check_candidate
python3 - "$release_version" <<'PY'
from pathlib import Path
import sys
version = sys.argv[1]
note = Path("docs/releases") / (version + ".md")
lines = note.read_text(encoding="utf-8").splitlines()
assert lines and lines[0] == "# Promptkit " + version, f"wrong release title: {note}"
headings = set()
fence = None
for line in lines:
marker = line.lstrip()[:3]
if marker in {"```", "~~~"}:
if fence is None:
fence = marker
elif fence == marker:
fence = None
elif fence is None:
headings.add(line)
for section in ("Summary", "Compatibility", "Upgrade", "Changes"):
assert "## " + section in headings, f"missing {section}: {note}"
PY
./scripts/release-check.sh
check_candidate
release_note=$(git show "$release_commit:docs/releases/$release_version.md")
printf '%s\n\nValidated commit: %s\n' "$release_note" "$release_commit" |
git tag --annotate --cleanup=verbatim "$release_version" --file - "$release_commit"
test "$(git cat-file -t "refs/tags/$release_version")" = tag
test "$(git rev-parse "refs/tags/$release_version^{commit}")" = "$release_commit"
git push origin "refs/tags/$release_version:refs/tags/$release_version"
printf '%s\n' "Released $release_version. No CI or module-availability wait is required."

View File

@@ -0,0 +1,171 @@
package promptkit_test
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"testing"
"gitea.maximumdirect.net/eric/promptkit"
)
func retryHTTPResponse(status int, content string) *http.Response {
body := `{"error":{"message":"private-provider-marker"}}`
if status == http.StatusOK {
body = `{"choices":[{"message":{"content":` + strconv.Quote(content) + `}}]}`
}
return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body))}
}
func retryTestEngine(t *testing.T, budget *int, next func(int) *http.Response) (*promptkit.Engine, *int) {
t.Helper()
calls := 0
config := contractConfig(frameworkSchemaDir)
config.TransientRetries = budget
config.HTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
calls++
return next(calls), nil
})}
engine, err := promptkit.NewEngine(config)
if err != nil {
t.Fatal(err)
}
return engine, &calls
}
func TestBuiltInRetryDefaultsAndRequestOverride(t *testing.T) {
engine, calls := retryTestEngine(t, nil, func(call int) *http.Response {
if call < 3 {
return retryHTTPResponse(503, "")
}
return retryHTTPResponse(200, "ok")
})
result, err := engine.Run(context.Background(), generationErrorRunRequest())
if err != nil || result == nil || result.RawOutput != "ok" || *calls != 3 {
t.Fatalf("default result=%+v, error=%v, calls=%d", result, err, *calls)
}
engineBudget := 1
engine, calls = retryTestEngine(t, &engineBudget, func(int) *http.Response { return retryHTTPResponse(503, "") })
engineBudget = 5 // Construction copied the configured value.
zero := 0
req := generationErrorRunRequest()
req.TransientRetries = &zero
_, err = engine.Run(context.Background(), req)
if !errors.Is(err, promptkit.ErrLLMGenerate) || errors.Is(err, promptkit.ErrRetriesExhausted) || *calls != 1 {
t.Fatalf("zero override: error=%v, calls=%d", err, *calls)
}
req.TransientRetries = nil
_, err = engine.Run(context.Background(), req)
var exhausted *promptkit.RetryExhaustedError
if !errors.As(err, &exhausted) || exhausted.Attempts() != 2 || *calls != 3 {
t.Fatalf("inherited budget: error=%v, calls=%d", err, *calls)
}
}
func TestRetryValidationAndPreparedFreezing(t *testing.T) {
negative := -1
config := contractConfig(frameworkSchemaDir)
config.TransientRetries = &negative
if _, err := promptkit.NewEngine(config); !errors.Is(err, promptkit.ErrInvalidConfig) {
t.Fatalf("negative engine budget = %v", err)
}
engine, calls := retryTestEngine(t, nil, func(int) *http.Response { return retryHTTPResponse(503, "") })
req := generationErrorRunRequest()
req.TransientRetries = &negative
if _, err := engine.Prepare(context.Background(), req); !errors.Is(err, promptkit.ErrInvalidRequest) {
t.Fatalf("Prepare negative budget = %v", err)
}
if _, err := engine.PrepareExecution(context.Background(), req); !errors.Is(err, promptkit.ErrInvalidRequest) {
t.Fatalf("PrepareExecution negative budget = %v", err)
}
if _, err := engine.Run(context.Background(), req); !errors.Is(err, promptkit.ErrInvalidRequest) {
t.Fatalf("Run negative budget = %v", err)
}
if *calls != 0 {
t.Fatalf("invalid requests dispatched %d calls", *calls)
}
maximum := int(^uint(0) >> 1)
largeBudgetEngine, largeCalls := retryTestEngine(t, &maximum, func(int) *http.Response {
return retryHTTPResponse(200, "ok")
})
if _, err := largeBudgetEngine.Run(context.Background(), generationErrorRunRequest()); err != nil || *largeCalls != 1 {
t.Fatalf("large valid budget: error=%v, calls=%d", err, *largeCalls)
}
zero := 0
req.TransientRetries = &zero
prepared, err := engine.PrepareExecution(context.Background(), req)
if err != nil {
t.Fatal(err)
}
zero = 2 // The handle retains the selected zero.
_, err = engine.RunPrepared(context.Background(), prepared)
if !errors.Is(err, promptkit.ErrLLMGenerate) || errors.Is(err, promptkit.ErrRetriesExhausted) || *calls != 1 {
t.Fatalf("frozen prepared budget: error=%v, calls=%d", err, *calls)
}
}
func TestBuiltInRetryBudgetAppliesToRepairGeneration(t *testing.T) {
one := 1
engine, calls := retryTestEngine(t, &one, func(call int) *http.Response {
switch call {
case 1, 3:
return retryHTTPResponse(503, "")
case 2:
return retryHTTPResponse(200, "not-json")
default:
return retryHTTPResponse(200, `{"ok":true}`)
}
})
req := generationErrorRunRequest()
req.Validation = &promptkit.OutputContract{Format: promptkit.FormatJSON, ValidationMode: promptkit.ValidationJSON, RepairAttempts: 1}
result, err := engine.Run(context.Background(), req)
if err != nil || result == nil || !result.Validation.IsValid || *calls != 4 {
t.Fatalf("repair result=%+v, error=%v, calls=%d", result, err, *calls)
}
}
func TestRetryExhaustionRetainsPublicProviderCause(t *testing.T) {
one := 1
engine, calls := retryTestEngine(t, &one, func(int) *http.Response { return retryHTTPResponse(503, "") })
result, err := engine.Run(context.Background(), generationErrorRunRequest())
if result != nil || *calls != 2 || !errors.Is(err, promptkit.ErrRetriesExhausted) || !errors.Is(err, promptkit.ErrLLMGenerate) {
t.Fatalf("exhaustion result=%+v, error=%v, calls=%d", result, err, *calls)
}
var exhausted *promptkit.RetryExhaustedError
var provider *promptkit.GenerationError
if !errors.As(err, &exhausted) || exhausted.Attempts() != 2 || !errors.As(err, &provider) || provider.StatusCode() != 503 || provider.ProviderMessage() != "private-provider-marker" {
t.Fatalf("exhaustion cause=%v", err)
}
for _, rendered := range []string{fmt.Sprintf("%v", err), fmt.Sprintf("%#v", err)} {
if strings.Contains(rendered, "private-provider-marker") {
t.Fatalf("provider detail leaked: %s", rendered)
}
}
var zero *promptkit.RetryExhaustedError
if zero.Attempts() != 0 || !errors.Is(zero, promptkit.ErrRetriesExhausted) || zero.Error() == "" || zero.GoString() == "" || zero.Unwrap() != nil {
t.Fatalf("nil error behavior = %v", zero)
}
zeroValue := &promptkit.RetryExhaustedError{}
if zeroValue.Attempts() != 0 || !errors.Is(zeroValue, promptkit.ErrRetriesExhausted) || zeroValue.Error() == "" || zeroValue.GoString() == "" || zeroValue.Unwrap() != nil {
t.Fatalf("zero error behavior = %v", zeroValue)
}
}
func TestInjectedClientIsNotAutomaticallyRetried(t *testing.T) {
client := &fakeLLMClient{err: errors.New("injected failure")}
engine := newContractEngineWithOptions(t, frameworkSchemaDir, promptkit.WithLLMClient(client))
count := 3
req := generationErrorRunRequest()
req.TransientRetries = &count
_, err := engine.Run(context.Background(), req)
if len(client.requests) != 1 || !errors.Is(err, client.err) || errors.Is(err, promptkit.ErrRetriesExhausted) {
t.Fatalf("injected calls=%d, error=%v", len(client.requests), err)
}
}

View File

@@ -138,6 +138,17 @@ type RunRequest struct {
// source or model work. Malformed values return an error matching
// ErrInvalidRequest.
AppendedMessages []RenderedMessage
// TransientRetries overrides the engine's built-in HTTP retry budget for
// this execution. Nil inherits the engine value; zero disables retries.
// Negative values are invalid for Prepare, PrepareExecution, and Run.
// Injected LLMClient implementations remain single-dispatch.
TransientRetries *int
// Observer receives metadata about an execution invocation synchronously. It is
// excluded from JSON and is not invoked by Prepare or PrepareExecution.
// Successful PrepareExecution retains the function for its RunPrepared call;
// Discard or a completed claim clears that reference. The closure itself is
// not copied, so callers must synchronize its shared state.
Observer ExecutionObserver `json:"-"`
}
// PreparedRun contains prepared prompt execution state returned by
@@ -709,8 +720,13 @@ type StructuredOutputJSONSpec struct {
//
// An arbitrary returned error makes Run or RunPrepared return ErrLLMGenerate
// while preserving the client error through errors.Is rather than translating
// it. A nil response with a nil error also produces ErrLLMGenerate. Promptkit
// copies the non-nil response before returning from either method.
// it. A nil response with a nil error also produces ErrLLMGenerate and
// ErrMalformedResponse. Promptkit copies the non-nil response before returning
// from either method. When UsagePresence is nil, nonzero usage counts are
// treated as known and zero counts as unknown for observation only; the legacy
// interface cannot distinguish omitted zero from measured zero. An explicit
// UsagePresence can report known zero counts. Result usage sums the supplied
// Usage values independently of presence.
type LLMClient interface {
Generate(context.Context, GenerateRequest) (*GenerateResponse, error)
}
@@ -741,6 +757,19 @@ type GenerateResponse struct {
Content string `json:"content"`
// Usage is the client's token accounting.
Usage TokenUsage `json:"usage"`
// UsagePresence states which usage fields were reported, including zeros.
// Nil uses the legacy observation fallback described by LLMClient.
UsagePresence *TokenUsagePresence `json:"usage_presence,omitempty"`
}
// TokenUsagePresence identifies provider-reported token counts for observation.
// It does not change TokenUsage values or successful-result accounting.
type TokenUsagePresence struct {
PromptTokens bool `json:"prompt_tokens"`
CompletionTokens bool `json:"completion_tokens"`
TotalTokens bool `json:"total_tokens"`
CachedTokens bool `json:"cached_tokens"`
CacheWriteTokens bool `json:"cache_write_tokens"`
}
// File returns a file-backed artifact reference whose URI is path.