Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db05b56166 | |||
| 0c08038b4f | |||
| 48378a3269 | |||
| cef8524b31 | |||
| 1cc754c29e | |||
| 93e53d1199 | |||
| 63bedf2a97 | |||
| 0e1ad784d1 | |||
| be07f6d873 | |||
| 836919ec4b | |||
| 07a131bd33 | |||
| ae4540822e | |||
| 65d5894648 | |||
| d31a563ca1 | |||
| ea94d66fc2 | |||
| 056bfd0289 | |||
| bd975f17f2 | |||
| 4431f34bc9 | |||
| 1580b25ea6 | |||
| 6dfd6bd719 | |||
| 9f69da716b | |||
| 5dd5b2f3cc |
34
AGENTS.md
34
AGENTS.md
@@ -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
674
COPYING
Normal 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
391
LICENSE
@@ -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.
|
||||
|
||||
20
README.md
20
README.md
@@ -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.
|
||||
|
||||
25
convert.go
25
convert.go
@@ -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
3
doc.go
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
50
docs/internal/generation.md
Normal file
50
docs/internal/generation.md
Normal 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.
|
||||
@@ -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;
|
||||
|
||||
48
docs/internal/observation.md
Normal file
48
docs/internal/observation.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
88
docs/policy/agents.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
146
docs/policy/release.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
320
docs/release.md
320
docs/release.md
@@ -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
76
docs/releases/v0.11.0.md
Normal 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
109
engine.go
@@ -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
|
||||
}
|
||||
|
||||
21
errors.go
21
errors.go
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
157
examples/go-library/observe/main.go
Normal file
157
examples/go-library/observe/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
17
examples/go-library/observe/prompts/assessment.yaml
Normal file
17
examples/go-library/observe/prompts/assessment.yaml
Normal 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
|
||||
17
examples/go-library/observe/prompts/proposal.yaml
Normal file
17
examples/go-library/observe/prompts/proposal.yaml
Normal 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
|
||||
10
examples/go-library/observe/schemas/assessment.schema.json
Normal file
10
examples/go-library/observe/schemas/assessment.schema.json
Normal 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
|
||||
}
|
||||
10
examples/go-library/observe/schemas/proposal.schema.json
Normal file
10
examples/go-library/observe/schemas/proposal.schema.json
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
90
internal/domain/observation.go
Normal file
90
internal/domain/observation.go
Normal 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
|
||||
}
|
||||
15
internal/domain/retries.go
Normal file
15
internal/domain/retries.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
294
internal/generation/coordinator.go
Normal file
294
internal/generation/coordinator.go
Normal 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()
|
||||
}
|
||||
}
|
||||
278
internal/generation/coordinator_test.go
Normal file
278
internal/generation/coordinator_test.go
Normal 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
|
||||
}
|
||||
201
internal/generation/observation_test.go
Normal file
201
internal/generation/observation_test.go
Normal 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 = ¬ifyingContext{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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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{})
|
||||
|
||||
225
internal/llm/prepared_generation_test.go
Normal file
225
internal/llm/prepared_generation_test.go
Normal 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 }
|
||||
318
internal/observation/recorder.go
Normal file
318
internal/observation/recorder.go
Normal 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
|
||||
}
|
||||
159
internal/observation/recorder_test.go
Normal file
159
internal/observation/recorder_test.go
Normal 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()
|
||||
}()
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
175
observation_boundary_contract_test.go
Normal file
175
observation_boundary_contract_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
233
observation_contract_test.go
Normal file
233
observation_contract_test.go
Normal 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
198
observations.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
186
prepared_observation_contract_test.go
Normal file
186
prepared_observation_contract_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
347
reliable_execution_contract_test.go
Normal file
347
reliable_execution_contract_test.go
Normal 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
49
retry_exhausted_error.go
Normal 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
152
scripts/release-check.sh
Executable 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
57
scripts/release.sh
Executable 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."
|
||||
171
transient_retries_contract_test.go
Normal file
171
transient_retries_contract_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
33
types.go
33
types.go
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user