├── .github └── workflows │ └── go.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── go.mod ├── go.sum ├── list.go ├── main.go ├── prompts ├── 0007-initial.md ├── 0008-persist.md ├── 0009-list.md ├── 0010-favicon.md ├── 0011-gzip.md ├── 0012-save-url.md ├── 0013-show-url.md ├── 0014-add-css.md ├── 0015-measure-time.md ├── 0016-no-results.md ├── 0017-convert-timestamp.md ├── 0018-show.md ├── 0019-move-show.md ├── 0020-show-request.md ├── 0021-show-response.md ├── 0022-listen-on.md ├── 0023-db-file.md ├── 0024-api-key.md ├── 0025-api-key-flag.md ├── 0026-openai-org.md ├── 0027-fix-temperature.md └── README.md └── show.go /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: '1.19' 23 | 24 | - name: Build 25 | run: go build -v ./... 26 | 27 | - name: Test 28 | run: go test -v ./... 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /sfgateway 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | CGO_ENABLED=0 go build -o sfgateway 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sfgateway 2 | 3 | sfgateway serves as an API gateway and reverse proxy for OpenAI APIs, designed 4 | to facilitate debugging, accounting, auditing, and monitoring when utilizing LLM 5 | APIs in both development and production settings. 6 | 7 | Current features include: 8 | 9 | - API access for the internal network, without revealing the actual API key 10 | - Persistence of requests and responses, serving debugging and auditing purposes 11 | - Web-based inspection of request and response fields and nested structures 12 | 13 | We have a roadmap of additional features we plan to incorporate: 14 | 15 | - Integration with internal Single Sign-On (SSO) systems 16 | - Statistics including byte/token sizes, latencies, and error rates 17 | - Expanded support for more LLM backends 18 | 19 | We welcome your contributions to add features that you find useful. 20 | 21 | ## Usage 22 | 23 | Build: 24 | 25 | make 26 | 27 | It produces an executable `sfgateway`, which takes a few command-line flags: 28 | 29 | ``` 30 | $ ./sfgateway --help 31 | Usage of ./sfgateway: 32 | -api_key string 33 | API key 34 | -backend string 35 | address of the backend service (default "https://api.openai.com/v1") 36 | -db_file string 37 | path to the requests.db file (default "./requests.db") 38 | -listen_on string 39 | address to listen on (default ":8090") 40 | -openai_org string 41 | OpenAI Organization 42 | ``` 43 | 44 | All flags are optional and self-explanatory. 45 | 46 | You can just run: 47 | 48 | ./sfgateway 49 | 50 | And it's going to get the API key from the environment variable 51 | `OPENAI_API_KEY`. 52 | 53 | Alternatively, you can specify the API key with the `-api_key` flag: 54 | 55 | ./sfgateway -api_key "sk-hKsxS7FEbVx3iFJWh3nxRqMA1byLnVQT3B29QtyCm3iZflbk" 56 | 57 | By default it listens on `*:8090` and you can open 58 | [http://localhost:8090/\_list](http://localhost:8090/_list) 59 | to see the list of requests. 60 | 61 | To use sfgateway when using the official Python library: 62 | 63 | ```py 64 | openai.api_base = "http://localhost:8090" 65 | ``` 66 | 67 | ## Screenshots 68 | 69 | ![1](https://github.com/specful-ai/sfgateway/assets/196279/5a4c6bba-e938-4621-bf77-1866da808648) 70 | 71 | ![2](https://github.com/specful-ai/sfgateway/assets/196279/bf40fb65-d9d2-4aad-bcd2-57da80cb8115) 72 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/specful-ai/sfgateway 2 | 3 | go 1.19 4 | 5 | require github.com/glebarez/go-sqlite v1.21.2 6 | 7 | require ( 8 | github.com/dustin/go-humanize v1.0.1 // indirect 9 | github.com/google/uuid v1.3.0 // indirect 10 | github.com/mattn/go-isatty v0.0.17 // indirect 11 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 12 | golang.org/x/sys v0.7.0 // indirect 13 | modernc.org/libc v1.22.5 // indirect 14 | modernc.org/mathutil v1.5.0 // indirect 15 | modernc.org/memory v1.5.0 // indirect 16 | modernc.org/sqlite v1.23.1 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 2 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 3 | github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= 4 | github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= 5 | github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= 6 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 7 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 8 | github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= 9 | github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 10 | github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 11 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 12 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 13 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 14 | golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= 15 | golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 16 | modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= 17 | modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= 18 | modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= 19 | modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= 20 | modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= 21 | modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= 22 | modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= 23 | modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= 24 | -------------------------------------------------------------------------------- /list.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "database/sql" 7 | "fmt" 8 | "html" 9 | "io" 10 | "log" 11 | "net/http" 12 | "strings" 13 | "time" 14 | "unicode/utf8" 15 | ) 16 | 17 | func ListHandler(w http.ResponseWriter, r *http.Request, db *sql.DB) { 18 | rows, err := db.Query("SELECT id, timestamp, url_path, duration_ms, request, response FROM requests ORDER BY timestamp DESC LIMIT 1000") 19 | if err != nil { 20 | log.Println("Failed to query database:", err) 21 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 22 | return 23 | } 24 | defer rows.Close() 25 | 26 | var ( 27 | id int 28 | timestamp time.Time 29 | urlPath string 30 | duration int 31 | request string 32 | response string 33 | ) 34 | 35 | var tableRows []string 36 | for rows.Next() { 37 | err := rows.Scan(&id, ×tamp, &urlPath, &duration, &request, &response) 38 | if err != nil { 39 | log.Println("Failed to scan database row:", err) 40 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 41 | return 42 | } 43 | 44 | if bytes.HasPrefix([]byte(response), []byte{0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00}) { 45 | gzipReader, err := gzip.NewReader(bytes.NewReader([]byte(response))) 46 | if err != nil { 47 | log.Println("Failed to create gzip reader:", err) 48 | return 49 | } 50 | defer gzipReader.Close() 51 | 52 | uncompressedBody, err := io.ReadAll(gzipReader) 53 | if err != nil { 54 | log.Println("Failed to uncompress response body:", err) 55 | return 56 | } 57 | 58 | response = string(uncompressedBody) 59 | } else if !utf8.Valid([]byte(response)) { 60 | response = "(invalid utf8)" 61 | } 62 | tableRow := fmt.Sprintf( 63 | "
#%d\n\n%s\n\n%s\n\n%d ms
request: %d bytes\n\n%s
response: %d bytes\n\n%s
", 64 | id, id, 65 | convertTimestamp(timestamp).Format("2006-01-02 15:04:05 -07:00"), 66 | urlPath, 67 | duration, 68 | len(request), 69 | html.EscapeString(truncateString(request, 600)), 70 | len(response), 71 | html.EscapeString(truncateString(response, 400))) 72 | tableRows = append(tableRows, tableRow) 73 | } 74 | 75 | if len(tableRows) == 0 { 76 | tableRows = append(tableRows, "No results") 77 | } 78 | 79 | html := fmt.Sprintf("%s
", ` 80 | table { 81 | border-collapse: collapse; 82 | } 83 | td { 84 | border: 1px solid black; 85 | padding: 8px; 86 | vertical-align: top; 87 | } 88 | pre { 89 | overflow: hidden; 90 | white-space: pre-wrap; 91 | max-width: 50ch; 92 | }`, strings.Join(tableRows, "")) 93 | w.Header().Set("Content-Type", "text/html") 94 | _, err = w.Write([]byte(html)) 95 | if err != nil { 96 | log.Println("Failed to write response body:", err) 97 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 98 | return 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "database/sql" 7 | "flag" 8 | "fmt" 9 | "io" 10 | "log" 11 | "net/http" 12 | "net/url" 13 | "os" 14 | "strings" 15 | "time" 16 | 17 | _ "github.com/glebarez/go-sqlite" 18 | ) 19 | 20 | func main() { 21 | backend := flag.String("backend", "https://api.openai.com/v1", "address of the backend service") 22 | listenOn := flag.String("listen_on", ":8090", "address to listen on") 23 | dbFile := flag.String("db_file", "./requests.db", "path to the requests.db file") 24 | apiKey := flag.String("api_key", "", "API key") 25 | openaiOrg := flag.String("openai_org", "", "OpenAI Organization") 26 | flag.Parse() 27 | 28 | _, err := url.Parse(*backend) 29 | if err != nil { 30 | log.Fatal(err) 31 | } 32 | 33 | db, err := sql.Open("sqlite", *dbFile) 34 | if err != nil { 35 | log.Fatal(err) 36 | } 37 | defer db.Close() 38 | 39 | // Create requests table if it doesn't exist 40 | _, err = db.Exec(`CREATE TABLE IF NOT EXISTS requests ( 41 | id INTEGER PRIMARY KEY AUTOINCREMENT, 42 | timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, 43 | request TEXT, 44 | response TEXT, 45 | url_path TEXT, 46 | duration_ms INTEGER 47 | )`) 48 | if err != nil { 49 | log.Fatal(err) 50 | } 51 | 52 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 53 | if strings.HasPrefix(r.URL.Path, "/_show/") && r.Method == http.MethodGet { 54 | ShowHandler(w, r, db) 55 | return 56 | } 57 | 58 | if r.URL.Path == "/_list" && r.Method == http.MethodGet { 59 | ListHandler(w, r, db) 60 | return 61 | } 62 | 63 | if r.URL.Path == "/favicon.ico" { 64 | // Return an empty/blank logo 65 | w.Header().Set("Content-Type", "image/x-icon") 66 | _, err := w.Write([]byte{}) 67 | if err != nil { 68 | log.Println("Failed to write response body:", err) 69 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 70 | return 71 | } 72 | return 73 | } 74 | 75 | fmt.Println(r.URL.Path) 76 | 77 | // Read the request body 78 | body, err := io.ReadAll(r.Body) 79 | if err != nil { 80 | log.Println("Failed to read request body:", err) 81 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 82 | return 83 | } 84 | 85 | // Create a new request to the backend 86 | backendReq, err := http.NewRequest(r.Method, *backend+r.URL.Path, bytes.NewReader(body)) 87 | if err != nil { 88 | log.Println("Failed to create backend request:", err) 89 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 90 | return 91 | } 92 | 93 | // Copy all the headers from the client request to the backend request 94 | for key, values := range r.Header { 95 | for _, value := range values { 96 | backendReq.Header.Add(key, value) 97 | } 98 | } 99 | 100 | // Add Authorization header with API key 101 | if *apiKey != "" { 102 | backendReq.Header.Set("Authorization", "Bearer "+*apiKey) 103 | } else { 104 | apiKey := os.Getenv("OPENAI_API_KEY") 105 | if apiKey != "" { 106 | backendReq.Header.Set("Authorization", "Bearer "+apiKey) 107 | } 108 | } 109 | 110 | // Add OpenAI-Organization header 111 | if *openaiOrg != "" { 112 | backendReq.Header.Set("OpenAI-Organization", *openaiOrg) 113 | } 114 | 115 | // Measure the time spent on calling the backend 116 | startTime := time.Now() 117 | resp, err := http.DefaultClient.Do(backendReq) 118 | duration := time.Since(startTime).Milliseconds() 119 | 120 | if err != nil { 121 | log.Println("Failed to make backend request:", err) 122 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 123 | return 124 | } 125 | defer resp.Body.Close() 126 | 127 | // Copy all the headers from the backend response to the client response 128 | for key, values := range resp.Header { 129 | for _, value := range values { 130 | w.Header().Add(key, value) 131 | } 132 | } 133 | 134 | // Persist the request, response, and duration to the database 135 | stmt, err := db.Prepare("INSERT INTO requests (request, response, url_path, duration_ms) VALUES (?, ?, ?, ?)") 136 | if err != nil { 137 | log.Println("Failed to prepare database statement:", err) 138 | return 139 | } 140 | defer stmt.Close() 141 | 142 | responseBody, err := io.ReadAll(resp.Body) 143 | if err != nil { 144 | log.Println("Failed to read response body:", err) 145 | return 146 | } 147 | 148 | os.WriteFile("/tmp/gateway-response-body.json", responseBody, os.ModePerm) 149 | 150 | insertBody := responseBody 151 | // Check if the response is gzipped 152 | if resp.Header.Get("Content-Encoding") == "gzip" { 153 | gzipReader, err := gzip.NewReader(bytes.NewReader(responseBody)) 154 | if err != nil { 155 | log.Println("Failed to create gzip reader:", err) 156 | return 157 | } 158 | defer gzipReader.Close() 159 | 160 | uncompressedBody, err := io.ReadAll(gzipReader) 161 | if err != nil { 162 | log.Println("Failed to uncompress response body:", err) 163 | return 164 | } 165 | 166 | insertBody = uncompressedBody 167 | } 168 | _, err = stmt.Exec(string(body), string(insertBody), r.URL.Path, duration) 169 | if err != nil { 170 | log.Println("Failed to execute database statement:", err) 171 | return 172 | } 173 | 174 | // Write the response body to the client 175 | _, err = w.Write(responseBody) 176 | if err != nil { 177 | log.Println("Failed to write response body:", err) 178 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 179 | return 180 | } 181 | }) 182 | 183 | log.Fatal(http.ListenAndServe(*listenOn, nil)) 184 | } 185 | 186 | func convertTimestamp(timestamp time.Time) time.Time { 187 | // Convert timestamp to current timezone 188 | location, err := time.LoadLocation("Local") 189 | if err != nil { 190 | log.Println("Failed to load timezone location:", err) 191 | return timestamp 192 | } 193 | return timestamp.In(location) 194 | } 195 | 196 | func truncateString(s string, length int) string { 197 | if len(s) <= length { 198 | return s 199 | } 200 | return s[:length] 201 | } 202 | -------------------------------------------------------------------------------- /prompts/0007-initial.md: -------------------------------------------------------------------------------- 1 | gateway 2 | 3 | Create `gateway/main.go` and implement an HTTP server. 4 | The server serves as a gateway / reverse proxy of an actual backend service. 5 | There is only one handler to handle `/` and any path is accepted. 6 | There is a command-line flag `-backend` which is the address of the backend service. 7 | By default it should be `"https://api.openai.com"`. 8 | When it receives a request, it must read all the incoming headers and the request body. 9 | Then it makes an outgoing HTTP request to the backend, using the same headers and the body. 10 | For example, if a request is received on `/whatever`, it should make a request to `/whatever`. 11 | After it receives the response from the backend, it must write the response back to its client. 12 | -------------------------------------------------------------------------------- /prompts/0008-persist.md: -------------------------------------------------------------------------------- 1 | gateway: persist requests and responses 2 | 3 | Change `gateway/main.go` to persist requests and responses. 4 | Write each request/response and its timestamp to a sqlite database. 5 | -------------------------------------------------------------------------------- /prompts/0009-list.md: -------------------------------------------------------------------------------- 1 | list requests 2 | 3 | Change `gateway/main.go` to handle HTTP GET requests to `/_list`. 4 | It queries the `requests` table and renders a HTML table as the response. 5 | Newest requests appear on the top. 6 | The HTML table should have four columns: ID, Time, Request, Response. 7 | The Time column should be in the format of `2006-01-02 15:04:05 -07:00`. 8 | The Request column is the first 30 characters of the actual request text. 9 | The Response column is the first 30 characters of the actual response text. 10 | Both the Request column and the Response column should use `
` tags to wrap the request/response texts.
11 | 


--------------------------------------------------------------------------------
/prompts/0010-favicon.md:
--------------------------------------------------------------------------------
1 | handle /favicon.ico
2 | 
3 | Change `gateway/main.go`. Return an empty/blank logo.
4 | 


--------------------------------------------------------------------------------
/prompts/0011-gzip.md:
--------------------------------------------------------------------------------
1 | handle gzipped response
2 | 
3 | Change `gateway/main.go` to check if `responseBody` contains gzipped data, and
4 | if so, uncompress it before saving into the database.
5 | 


--------------------------------------------------------------------------------
/prompts/0012-save-url.md:
--------------------------------------------------------------------------------
1 | save url in the database
2 | 
3 | Change `gateway/main.go` to also save `r.URL.Path` in the database.
4 | 


--------------------------------------------------------------------------------
/prompts/0013-show-url.md:
--------------------------------------------------------------------------------
1 | show url in `/_list`
2 | 
3 | Change `gateway/main.go` to add url_path to the HTML table.
4 | It should be added after the timestamp column and before the request column.
5 | 


--------------------------------------------------------------------------------
/prompts/0014-add-css.md:
--------------------------------------------------------------------------------
 1 | add css to `/_list`
 2 | 
 3 | Change `gateway/main.go` to add some CSS styles to `/_list`.
 4 | 
 5 | When handling `/_list`, add the following CSS styles to the HTML page.
 6 | 
 7 | ```css
 8 | table {
 9 |   border: 1px solid black;
10 | }
11 | ```
12 | 


--------------------------------------------------------------------------------
/prompts/0015-measure-time.md:
--------------------------------------------------------------------------------
1 | measure time
2 | 
3 | Change `gateway/main.go` to measure the time spent on calling the backend.
4 | Save the measured time in the database.
5 | 


--------------------------------------------------------------------------------
/prompts/0016-no-results.md:
--------------------------------------------------------------------------------
1 | handle no results
2 | 
3 | Change `gateway/main.go` to handle the no results case.
4 | When handling `/_list`, if there are no requests in the database, show `No results` in the HTML response.
5 | 


--------------------------------------------------------------------------------
/prompts/0017-convert-timestamp.md:
--------------------------------------------------------------------------------
1 | convert timestamp to current timezone
2 | 
3 | Change `gateway/main.go` so that when handling `/_list`, convert the timestamp to the current timezone before formatting it.
4 | 


--------------------------------------------------------------------------------
/prompts/0018-show.md:
--------------------------------------------------------------------------------
1 | show requests
2 | 
3 | Change `gateway/main.go` to handle HTTP GET requests to `/_show/`.
4 | It queries the `requests` table and finds the request with the specified id.
5 | It responds with a HTML containing a description list of the request.
6 | 


--------------------------------------------------------------------------------
/prompts/0019-move-show.md:
--------------------------------------------------------------------------------
1 | move show to its own file
2 | 
3 | Move the logic of handling `/_show/` from `gateway/main.go` to a new file `gateway/show.go`.
4 | 


--------------------------------------------------------------------------------
/prompts/0020-show-request.md:
--------------------------------------------------------------------------------
 1 | show detailed request
 2 | 
 3 | Change `gateway/show.go` to parse the request column as a JSON and render its details as a nested description list.
 4 | 
 5 | The request column looks like this:
 6 | 
 7 | ```json
 8 | {
 9 |   "model": "gpt-3.5-turbo-16k",
10 |   "messages": [
11 |     { "role": "user", "content": "..." },
12 |     { "role": "assistant", "content": "..." }
13 |   ],
14 |   "temperature": 0
15 | }
16 | ```
17 | 


--------------------------------------------------------------------------------
/prompts/0021-show-response.md:
--------------------------------------------------------------------------------
 1 | show detailed response
 2 | 
 3 | Change `gateway/show.go` to parse the response column as a JSON and render its details as a nested description list.
 4 | 
 5 | The response column looks like this:
 6 | 
 7 | ```json
 8 | {
 9 |   "id": "chatcmpl-7gib6PXeGPqHLRDvbYoeZSDShvHxY",
10 |   "object": "chat.completion",
11 |   "created": 1690415564,
12 |   "model": "gpt-3.5-turbo-16k-0613",
13 |   "choices": [
14 |     {
15 |       "index": 0,
16 |       "message": {
17 |         "role": "assistant",
18 |         "content": "..."
19 |       },
20 |       "finish_reason": "stop"
21 |     }
22 |   ],
23 |   "usage": {
24 |     "prompt_tokens": 1274,
25 |     "completion_tokens": 839,
26 |     "total_tokens": 2113
27 |   }
28 | }
29 | ```
30 | 


--------------------------------------------------------------------------------
/prompts/0022-listen-on.md:
--------------------------------------------------------------------------------
1 | add -listen_on flag
2 | 
3 | Change `gateway/main.go` to add a command-line flag `-listen_on` and pass it to `http.ListenAndServe`
4 | 


--------------------------------------------------------------------------------
/prompts/0023-db-file.md:
--------------------------------------------------------------------------------
1 | add -db_file flag
2 | 
3 | Change `gateway/main.go` to add a command-line flag `-db_file` which specifies the path to the requests.db file
4 | 


--------------------------------------------------------------------------------
/prompts/0024-api-key.md:
--------------------------------------------------------------------------------
1 | use api key
2 | 
3 | Change `gateway/main.go` to read the environment variable `OPENAI_API_KEY` and
4 | use its value to add a header `Authorization: Bearer $OPENAI_API_KEY` to
5 | the headers copied from the client request to the backend request.
6 | 


--------------------------------------------------------------------------------
/prompts/0025-api-key-flag.md:
--------------------------------------------------------------------------------
1 | add -api_key flag
2 | 
3 | Change `gateway/main.go` to add a command-line flag `-api_key` which must be used even if the `OPENAI_API_KEY` environment variable is set.
4 | 


--------------------------------------------------------------------------------
/prompts/0026-openai-org.md:
--------------------------------------------------------------------------------
1 | add -openai_org flag
2 | 
3 | Change `gateway/main.go` to add a command-line flag `-openai_org` and
4 | use its value to add a header `OpenAI-Organization: ...` to
5 | the headers copied from the client request to the backend request.
6 | 


--------------------------------------------------------------------------------
/prompts/0027-fix-temperature.md:
--------------------------------------------------------------------------------
1 | fix temperature type
2 | 
3 | Fix the following error when running `gateway/show.go`:
4 | 
5 |     Failed to unmarshal request: json: cannot unmarshal number 0.7 into Go struct field Request.temperature of type int
6 | 


--------------------------------------------------------------------------------
/prompts/README.md:
--------------------------------------------------------------------------------
1 | When I began this project, I wrote most of the code using a code generation tool
2 | that I am currently developing. This folder has some of the prompts I used while
3 | working on this project.
4 | 


--------------------------------------------------------------------------------
/show.go:
--------------------------------------------------------------------------------
  1 | package main
  2 | 
  3 | import (
  4 | 	"database/sql"
  5 | 	"encoding/json"
  6 | 	"fmt"
  7 | 	"html"
  8 | 	"log"
  9 | 	"net/http"
 10 | 	"strconv"
 11 | 	"strings"
 12 | 	"time"
 13 | )
 14 | 
 15 | type Request struct {
 16 | 	Model       string    `json:"model"`
 17 | 	Messages    []Message `json:"messages"`
 18 | 	Temperature float64   `json:"temperature"`
 19 | }
 20 | 
 21 | type Message struct {
 22 | 	Role    string `json:"role"`
 23 | 	Content string `json:"content"`
 24 | }
 25 | 
 26 | type Response struct {
 27 | 	ID      string   `json:"id"`
 28 | 	Object  string   `json:"object"`
 29 | 	Created int      `json:"created"`
 30 | 	Model   string   `json:"model"`
 31 | 	Choices []Choice `json:"choices"`
 32 | 	Usage   Usage    `json:"usage"`
 33 | }
 34 | 
 35 | type Choice struct {
 36 | 	Index        int     `json:"index"`
 37 | 	Message      Message `json:"message"`
 38 | 	FinishReason string  `json:"finish_reason"`
 39 | }
 40 | 
 41 | type Usage struct {
 42 | 	PromptTokens     int `json:"prompt_tokens"`
 43 | 	CompletionTokens int `json:"completion_tokens"`
 44 | 	TotalTokens      int `json:"total_tokens"`
 45 | }
 46 | 
 47 | func ShowHandler(w http.ResponseWriter, r *http.Request, db *sql.DB) {
 48 | 	id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/_show/"))
 49 | 	if err != nil {
 50 | 		http.Error(w, "Bad Request", http.StatusBadRequest)
 51 | 		return
 52 | 	}
 53 | 	row := db.QueryRow("SELECT timestamp, url_path, duration_ms, request, response FROM requests WHERE id = ?", id)
 54 | 
 55 | 	var (
 56 | 		timestamp time.Time
 57 | 		urlPath   string
 58 | 		duration  int
 59 | 		request   string
 60 | 		response  string
 61 | 	)
 62 | 	err = row.Scan(×tamp, &urlPath, &duration, &request, &response)
 63 | 	if err != nil {
 64 | 		log.Println("Failed to query database:", err)
 65 | 		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
 66 | 		return
 67 | 	}
 68 | 
 69 | 	var req Request
 70 | 	err = json.Unmarshal([]byte(request), &req)
 71 | 	if err != nil {
 72 | 		log.Println("Failed to unmarshal request:", err)
 73 | 		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
 74 | 		return
 75 | 	}
 76 | 
 77 | 	var resp Response
 78 | 	err = json.Unmarshal([]byte(response), &resp)
 79 | 	if err != nil {
 80 | 		log.Println("Failed to unmarshal response:", err)
 81 | 		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
 82 | 		return
 83 | 	}
 84 | 
 85 | 	requestHTML := fmt.Sprintf(
 86 | 		"
Model:
%s
Messages:
%s
Temperature:
%f
", 87 | html.EscapeString(req.Model), 88 | renderMessages(req.Messages), 89 | req.Temperature, 90 | ) 91 | 92 | responseHTML := fmt.Sprintf( 93 | "
ID:
%s
Object:
%s
Created:
%d
Model:
%s
Choices:
%s
Usage:
%s
", 94 | html.EscapeString(resp.ID), 95 | html.EscapeString(resp.Object), 96 | resp.Created, 97 | html.EscapeString(resp.Model), 98 | renderChoices(resp.Choices), 99 | renderUsage(resp.Usage), 100 | ) 101 | 102 | html := fmt.Sprintf( 103 | "
ID:
%d
Timestamp:
%s
URL Path:
%s
Duration (ms):
%d
Request:

%d bytes

%s
Response:

%d bytes

%s
", ` 104 | table { 105 | border-collapse: collapse; 106 | } 107 | td { 108 | border: 1px solid black; 109 | padding: 8px; 110 | vertical-align: top; 111 | } 112 | pre { 113 | white-space: pre-wrap; 114 | }`, 115 | id, 116 | convertTimestamp(timestamp).Format("2006-01-02 15:04:05 -07:00"), 117 | urlPath, 118 | duration, 119 | len(request), 120 | requestHTML, 121 | len(response), 122 | responseHTML, 123 | ) 124 | w.Header().Set("Content-Type", "text/html") 125 | _, err = w.Write([]byte(html)) 126 | if err != nil { 127 | log.Println("Failed to write response body:", err) 128 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 129 | return 130 | } 131 | } 132 | 133 | func renderMessages(messages []Message) string { 134 | var sb strings.Builder 135 | sb.WriteString("") 136 | for _, msg := range messages { 137 | sb.WriteString(fmt.Sprintf( 138 | "", 139 | html.EscapeString(msg.Role), 140 | html.EscapeString(msg.Content))) 141 | } 142 | sb.WriteString("
%s
%s
") 143 | return sb.String() 144 | } 145 | 146 | func renderChoices(choices []Choice) string { 147 | var sb strings.Builder 148 | sb.WriteString("") 149 | for _, choice := range choices { 150 | sb.WriteString(fmt.Sprintf( 151 | "", 152 | choice.Index, 153 | html.EscapeString(choice.Message.Role), 154 | html.EscapeString(choice.Message.Content))) 155 | } 156 | sb.WriteString("
%d
%s
%s
") 157 | return sb.String() 158 | } 159 | 160 | func renderUsage(usage Usage) string { 161 | return fmt.Sprintf( 162 | "
Prompt Tokens:
%d
Completion Tokens:
%d
Total Tokens:
%d
", 163 | usage.PromptTokens, 164 | usage.CompletionTokens, 165 | usage.TotalTokens, 166 | ) 167 | } 168 | --------------------------------------------------------------------------------