feat(deps): vendor charm.land/fantasy SDK as local module
Add Fantasy SDK (v0.8.1) as a vendored local module under internal/fantasy with a replace directive in go.mod. This provides a unified multi-provider LLM abstraction (OpenAI, Anthropic, Google, Azure, Bedrock, etc.) with built-in streaming, tool calling, and retry support. Update go.mod/go.sum with new transitive dependencies including langchaingo, go-libsql, testify, and openai-go v2.
This commit is contained in:
parent
4d4e6b57b2
commit
3589adab08
467 changed files with 80888 additions and 10 deletions
32
go.mod
32
go.mod
|
|
@ -2,9 +2,11 @@ module github.com/sipeed/picoclaw
|
|||
|
||||
go 1.25.7
|
||||
|
||||
replace charm.land/fantasy v0.8.1 => ./internal/fantasy
|
||||
|
||||
require (
|
||||
charm.land/fantasy v0.8.1
|
||||
github.com/adhocore/gronx v1.19.6
|
||||
github.com/anthropics/anthropic-sdk-go v1.22.1
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/caarlos0/env/v11 v11.3.1
|
||||
github.com/chzyer/readline v1.5.1
|
||||
|
|
@ -13,23 +15,41 @@ require (
|
|||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||
github.com/mymmrac/telego v1.6.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||
github.com/openai/openai-go/v3 v3.21.0
|
||||
github.com/openai/openai-go/v2 v2.7.1
|
||||
github.com/slack-go/slack v0.17.3
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tencent-connect/botgo v0.2.1
|
||||
github.com/tmc/langchaingo v0.1.14
|
||||
github.com/tursodatabase/go-libsql v0.0.0-20251219133454-43644db490ff
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 // indirect
|
||||
github.com/charmbracelet/x/json v0.2.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dlclark/regexp2 v1.10.0 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
|
||||
github.com/go-resty/resty/v2 v2.17.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/grbit/go-json v0.11.0 // indirect
|
||||
github.com/kaptinlin/go-i18n v0.2.3 // indirect
|
||||
github.com/kaptinlin/jsonpointer v0.4.9 // indirect
|
||||
github.com/kaptinlin/jsonschema v0.6.10 // indirect
|
||||
github.com/kaptinlin/messageformat-go v0.4.9 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect
|
||||
github.com/pkoukk/tiktoken-go v0.1.6 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
|
|
@ -38,9 +58,17 @@ require (
|
|||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.69.0 // indirect
|
||||
github.com/valyala/fastjson v1.6.7 // indirect
|
||||
gitlab.com/golang-commonmark/html v0.0.0-20191124015941-a22733972181 // indirect
|
||||
gitlab.com/golang-commonmark/linkify v0.0.0-20191026162114-a0c2df6c8f82 // indirect
|
||||
gitlab.com/golang-commonmark/markdown v0.0.0-20211110145824-bf3e522c626a // indirect
|
||||
gitlab.com/golang-commonmark/mdurl v0.0.0-20191124015652-932350d1cb84 // indirect
|
||||
gitlab.com/golang-commonmark/puny v0.0.0-20191124015043-9f83538fa04f // indirect
|
||||
golang.org/x/arch v0.24.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
|
|||
82
go.sum
82
go.sum
|
|
@ -3,8 +3,8 @@ github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc
|
|||
github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0=
|
||||
github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
|
||||
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
||||
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
|
|
@ -17,6 +17,10 @@ github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5m
|
|||
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 h1:DTSZxdV9qQagD4iGcAt9RgaRBZtJl01bfKgdLzUzUPI=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
|
||||
github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
|
||||
github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
|
||||
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
|
||||
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
|
||||
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
|
||||
|
|
@ -27,11 +31,16 @@ github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI
|
|||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
|
||||
github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
|
||||
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
||||
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
|
||||
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
|
||||
|
|
@ -39,6 +48,10 @@ github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2m
|
|||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
|
|
@ -56,6 +69,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
|||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
|
|
@ -66,6 +81,14 @@ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
|
|||
github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
|
||||
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/kaptinlin/go-i18n v0.2.3 h1:jyN/YOXXLcnGRBLdU+a8+6782B97fWE5aQqAHtvvk8Q=
|
||||
github.com/kaptinlin/go-i18n v0.2.3/go.mod h1:O+Ax4HkMO0Jt4OaP4E4WCx0PAADeWkwk8Jgt9bjAU1w=
|
||||
github.com/kaptinlin/jsonpointer v0.4.9 h1:o//bYf4PCvnMJIIX8bIg77KB6DO3wBPAabRyPRKh680=
|
||||
github.com/kaptinlin/jsonpointer v0.4.9/go.mod h1:9y0LgXavlmVE5FSHShY5LRlURJJVhbyVJSRWkilrTqA=
|
||||
github.com/kaptinlin/jsonschema v0.6.10 h1:CYded7nrwVu7pU1GaIjtd9dSzgqZjh7+LTKFaWqS08I=
|
||||
github.com/kaptinlin/jsonschema v0.6.10/go.mod h1:ZXZ4K5KrRmCCF1i6dgvBsQifl+WTb8XShKj0NpQNrz8=
|
||||
github.com/kaptinlin/messageformat-go v0.4.9 h1:FR5j5n4aL4nG0afKn9vvANrKxLu7HjmbhJnw5ogIwAQ=
|
||||
github.com/kaptinlin/messageformat-go v0.4.9/go.mod h1:qZzrGrlvWDz2KyyvN3dOWcK9PVSRV1BnfnNU+zB/RWc=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
|
|
@ -75,11 +98,16 @@ github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu
|
|||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM=
|
||||
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM=
|
||||
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
|
||||
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
|
|
@ -92,13 +120,24 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
|
|||
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||
github.com/openai/openai-go/v3 v3.21.0 h1:3GpIR/W4q/v1uUOVuK3zYtQiF3DnRrZag/sxbtvEdtc=
|
||||
github.com/openai/openai-go/v3 v3.21.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
||||
github.com/openai/openai-go/v2 v2.7.1 h1:/tfvTJhfv7hTSL8mWwc5VL4WLLSDL5yn9VqVykdu9r8=
|
||||
github.com/openai/openai-go/v2 v2.7.1/go.mod h1:jrJs23apqJKKbT+pqtFgNKpRju/KP9zpUTZhz3GElQE=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkoukk/tiktoken-go v0.1.6 h1:JF0TlJzhTbrI30wCvFuiw6FzP2+/bR+FIxUdgEAcUsw=
|
||||
github.com/pkoukk/tiktoken-go v0.1.6/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
|
||||
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
|
|
@ -127,6 +166,10 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
|||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/tmc/langchaingo v0.1.14 h1:o1qWBPigAIuFvrG6cjTFo0cZPFEZ47ZqpOYMjM15yZc=
|
||||
github.com/tmc/langchaingo v0.1.14/go.mod h1:aKKYXYoqhIDEv7WKdpnnCLRaqXic69cX9MnDUk72378=
|
||||
github.com/tursodatabase/go-libsql v0.0.0-20251219133454-43644db490ff h1:Hvxz9W8fWpSg9xkiq8/q+3cVJo+MmLMfkjdS/u4nWFY=
|
||||
github.com/tursodatabase/go-libsql v0.0.0-20251219133454-43644db490ff/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
|
|
@ -140,6 +183,18 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
|
|||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
gitlab.com/golang-commonmark/html v0.0.0-20191124015941-a22733972181 h1:K+bMSIx9A7mLES1rtG+qKduLIXq40DAzYHtb0XuCukA=
|
||||
gitlab.com/golang-commonmark/html v0.0.0-20191124015941-a22733972181/go.mod h1:dzYhVIwWCtzPAa4QP98wfB9+mzt33MSmM8wsKiMi2ow=
|
||||
gitlab.com/golang-commonmark/linkify v0.0.0-20191026162114-a0c2df6c8f82 h1:oYrL81N608MLZhma3ruL8qTM4xcpYECGut8KSxRY59g=
|
||||
gitlab.com/golang-commonmark/linkify v0.0.0-20191026162114-a0c2df6c8f82/go.mod h1:Gn+LZmCrhPECMD3SOKlE+BOHwhOYD9j7WT9NUtkCrC8=
|
||||
gitlab.com/golang-commonmark/markdown v0.0.0-20211110145824-bf3e522c626a h1:O85GKETcmnCNAfv4Aym9tepU8OE0NmcZNqPlXcsBKBs=
|
||||
gitlab.com/golang-commonmark/markdown v0.0.0-20211110145824-bf3e522c626a/go.mod h1:LaSIs30YPGs1H5jwGgPhLzc8vkNc/k0rDX/fEZqiU/M=
|
||||
gitlab.com/golang-commonmark/mdurl v0.0.0-20191124015652-932350d1cb84 h1:qqjvoVXdWIcZCLPMlzgA7P9FZWdPGPvP/l3ef8GzV6o=
|
||||
gitlab.com/golang-commonmark/mdurl v0.0.0-20191124015652-932350d1cb84/go.mod h1:IJZ+fdMvbW2qW6htJx7sLJ04FEs4Ldl/MDsJtMKywfw=
|
||||
gitlab.com/golang-commonmark/puny v0.0.0-20191124015043-9f83538fa04f h1:Wku8eEdeJqIOFHtrfkYUByc4bCaTeA6fL0UJgfEiFMI=
|
||||
gitlab.com/golang-commonmark/puny v0.0.0-20191124015043-9f83538fa04f/go.mod h1:Tiuhl+njh/JIg0uS/sOJVYi0x2HEa5rc1OAaVsb5tAs=
|
||||
gitlab.com/opennota/wd v0.0.0-20180912061657-c5d65f63c638 h1:uPZaMiz6Sz0PZs3IZJWpU5qHKGNy///1pacZC9txiUI=
|
||||
gitlab.com/opennota/wd v0.0.0-20180912061657-c5d65f63c638/go.mod h1:EGRJaqe2eO9XGmFtQCvV3Lm9NLico3UhFwUpCG/+mVU=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||
|
|
@ -152,6 +207,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
|
|||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
|
|
@ -209,14 +266,17 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
|||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
|
|
@ -238,6 +298,7 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
|
|||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
|
|
@ -245,7 +306,12 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD
|
|||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
|
||||
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
|
||||
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
|
||||
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
|
||||
|
|
|
|||
29
internal/fantasy/CRUSH.md
Normal file
29
internal/fantasy/CRUSH.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# CRUSH.md - Fantasy AI SDK
|
||||
|
||||
## Build/Test/Lint Commands
|
||||
- **Build**: `go build ./...`
|
||||
- **Test all**: `task test` or `go test ./... -count=1`
|
||||
- **Test single**: `go test -run TestName ./package -v`
|
||||
- **Test with args**: `task test -- -v -run TestName`
|
||||
- **Lint**: `task lint` or `golangci-lint run`
|
||||
- **Format**: `task fmt` or `gofmt -s -w .`
|
||||
- **Modernize**: `task modernize` or `modernize -fix ./...`
|
||||
|
||||
## Code Style Guidelines
|
||||
- **Package naming**: lowercase, single word (ai, openai, anthropic, google)
|
||||
- **Imports**: standard library first, then third-party, then local packages
|
||||
- **Error handling**: Use custom error types with structured fields, wrap with context
|
||||
- **Types**: Use type aliases for function signatures (`type Option = func(*options)`)
|
||||
- **Naming**: CamelCase for exported, camelCase for unexported
|
||||
- **Constants**: Use const blocks with descriptive names (ProviderName, DefaultURL)
|
||||
- **Structs**: Embed anonymous structs for composition (APICallError embeds *AIError)
|
||||
- **Functions**: Return error as last parameter, use context.Context as first param
|
||||
- **Testing**: Use testify/assert, table-driven tests, recorder pattern for HTTP mocking
|
||||
- **Comments**: Godoc format for exported functions, explain complex logic inline
|
||||
- **JSON**: Use struct tags for marshaling, handle empty values gracefully
|
||||
|
||||
## Project Structure
|
||||
- `/ai` - Core AI abstractions and agent logic
|
||||
- `/openai`, `/anthropic`, `/google` - Provider implementations
|
||||
- `/providertests` - Cross-provider integration tests with VCR recordings
|
||||
- `/examples` - Usage examples for different patterns
|
||||
177
internal/fantasy/LICENSE
Normal file
177
internal/fantasy/LICENSE
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
13
internal/fantasy/NOTICE
Normal file
13
internal/fantasy/NOTICE
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
Copyright 2025-2026 Charmbracelet, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
98
internal/fantasy/README.md
Normal file
98
internal/fantasy/README.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# Fantasy
|
||||
|
||||
<p>
|
||||
<img width="475" alt="The Charm Fantasy logo" src="https://github.com/user-attachments/assets/b22c5862-792a-44c1-bc98-55a2e46c8fb9" /><br>
|
||||
<a href="https://github.com/charmbracelet/fantasy/releases"><img src="https://img.shields.io/github/release/charmbracelet/fantasy.svg" alt="Latest Release"></a>
|
||||
<a href="https://pkg.go.dev/charm.land/fantasy?tab=doc"><img src="https://godoc.org/charm.land/fantasy?status.svg" alt="GoDoc"></a>
|
||||
<a href="https://github.com/charmbracelet/fantasy/actions"><img src="https://github.com/charmbracelet/fantasy/actions/workflows/build.yml/badge.svg?branch=main" alt="Build Status"></a>
|
||||
</p>
|
||||
|
||||
Build AI agents with Go. Multi-provider, multi-model, one API.
|
||||
|
||||
1. Choose a model and provider
|
||||
2. Add some tools
|
||||
3. Compile to native machine code and let it rip
|
||||
|
||||
> [!NOTE]
|
||||
> Fantasy is currently a preview. Expect API changes.
|
||||
|
||||
```go
|
||||
import "charm.land/fantasy"
|
||||
import "charm.land/fantasy/providers/openrouter"
|
||||
|
||||
// Choose your fave provider.
|
||||
provider, err := openrouter.New(openrouter.WithAPIKey(myHotKey))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Whoops:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Pick your fave model.
|
||||
model, err := provider.LanguageModel(ctx, "moonshotai/kimi-k2")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Dang:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Make your own tools.
|
||||
cuteDogTool := fantasy.NewAgentTool(
|
||||
"cute_dog_tool",
|
||||
"Provide up-to-date info on cute dogs.",
|
||||
fetchCuteDogInfoFunc,
|
||||
)
|
||||
|
||||
// Equip your agent.
|
||||
agent := fantasy.NewAgent(
|
||||
model,
|
||||
fantasy.WithSystemPrompt("You are a moderately helpful, dog-centric assistant."),
|
||||
fantasy.WithTools(cuteDogTool),
|
||||
)
|
||||
|
||||
// Put that agent to work!
|
||||
const prompt = "Find all the cute dogs in Silver Lake, Los Angeles."
|
||||
result, err := agent.Generate(ctx, fantasy.AgentCall{Prompt: prompt})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Oof:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(result.Response.Content.Text())
|
||||
```
|
||||
|
||||
🍔 For the full implementation and more [see the examples directory](https://github.com/charmbracelet/fantasy/tree/main/examples).
|
||||
|
||||
## Multi-model? Multi-provider?
|
||||
|
||||
Yeah! Fantasy is designed to support a wide variety of providers and models under a single API. While many providers such as Microsoft Azure, Amazon Bedrock, and OpenRouter have dedicated packages in Fantasy, many others work just fine with `openaicompat`, the generic OpenAI-compatible layer. That said, if you find a provider that’s not compatible and needs special treatment, please let us know in an issue (or open a PR).
|
||||
|
||||
## Work in Progress
|
||||
|
||||
We built Fantasy to power [Crush](https://github.com/charmbracelet/crush), a hot coding agent for glamourously invincible development. Given that, Fantasy does not yet support things like:
|
||||
|
||||
- Image models
|
||||
- Audio models
|
||||
- PDF uploads
|
||||
- Provider tools (e.g. web_search)
|
||||
|
||||
For things you’d like to see supported, PRs are welcome.
|
||||
|
||||
## Whatcha think?
|
||||
|
||||
We’d love to hear your thoughts on this project. Need help? We gotchu. You can find us on:
|
||||
|
||||
- [Slack](https://charm.land/slack)
|
||||
- [Discord][discord]
|
||||
- [Twitter](https://twitter.com/charmcli)
|
||||
- [The Fediverse](https://mastodon.social/@charmcli)
|
||||
- [Bluesky](https://bsky.app/profile/charm.land)
|
||||
|
||||
[discord]: https://charm.land/discord
|
||||
|
||||
---
|
||||
|
||||
Part of [Charm](https://charm.land).
|
||||
|
||||
<a href="https://charm.land/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-banner-next.jpg" width="400"></a>
|
||||
|
||||
Charm热爱开源 • Charm loves open source
|
||||
43
internal/fantasy/Taskfile.yaml
Normal file
43
internal/fantasy/Taskfile.yaml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
version: "3"
|
||||
|
||||
tasks:
|
||||
fmt:
|
||||
desc: Run gofumpt for all packages
|
||||
cmds:
|
||||
- gofumpt -w .
|
||||
|
||||
modernize:
|
||||
desc: Run modernize for all packages
|
||||
cmds:
|
||||
- modernize -fix ./...
|
||||
|
||||
lint:
|
||||
desc: Run base linters for all packages
|
||||
cmds:
|
||||
- golangci-lint run
|
||||
|
||||
lint:fix:
|
||||
desc: Run base linters for all packages and applies fixes if possible
|
||||
cmds:
|
||||
- golangci-lint run --fix
|
||||
|
||||
test:
|
||||
desc: Run tests for all packages
|
||||
cmds:
|
||||
- go test ./... -count=1 {{.CLI_ARGS}}
|
||||
release:
|
||||
desc: Create and push a new tag following semver
|
||||
vars:
|
||||
NEXT:
|
||||
sh: svu next --always || go run github.com/caarlos0/svu/v3@latest next --always
|
||||
prompt: "This will release {{.NEXT}}. Continue?"
|
||||
preconditions:
|
||||
- sh: '[ $(git symbolic-ref --short HEAD) = "main" ]'
|
||||
msg: Not on main branch
|
||||
- sh: "[ $(git status --porcelain=2 | wc -l) = 0 ]"
|
||||
msg: "Git is dirty"
|
||||
cmds:
|
||||
- git commit --allow-empty -m "{{.NEXT}}"
|
||||
- git tag --annotate --sign -m "{{.NEXT}}" {{.NEXT}} {{.CLI_ARGS}}
|
||||
- echo "Pushing {{.NEXT}}..."
|
||||
- git push origin main --follow-tags
|
||||
42
internal/fantasy/VENDORING.md
Normal file
42
internal/fantasy/VENDORING.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Vendored: charm.land/fantasy v0.8.1
|
||||
|
||||
## What This Is
|
||||
|
||||
This directory contains a vendored copy of `charm.land/fantasy` v0.8.1, the Charmbracelet
|
||||
Fantasy LLM agent framework. We vendor it to enable direct modifications for PicoClaw-specific
|
||||
features (progressive disclosure, custom streaming hooks, tool call repair, etc.).
|
||||
|
||||
## How It Works
|
||||
|
||||
PicoClaw's `go.mod` contains a `replace` directive:
|
||||
|
||||
```
|
||||
replace charm.land/fantasy v0.8.1 => ./internal/fantasy
|
||||
```
|
||||
|
||||
This redirects all `charm.land/fantasy` imports to this local copy. No import paths
|
||||
need to change in either PicoClaw code or the fantasy source itself.
|
||||
|
||||
## Syncing Upstream Updates
|
||||
|
||||
To pull in a new upstream version:
|
||||
|
||||
1. Check the upstream version: `go list -m -versions charm.land/fantasy`
|
||||
2. Download it: `go mod download charm.land/fantasy@vX.Y.Z`
|
||||
3. Copy to vendor: `cp -r $(go env GOMODCACHE)/charm.land/fantasy@vX.Y.Z/* internal/fantasy/`
|
||||
4. Fix permissions: `chmod -R u+w internal/fantasy/`
|
||||
5. Re-apply local patches (see below)
|
||||
6. Update the replace directive version in `go.mod` if needed
|
||||
7. Run `go mod tidy && go test ./...`
|
||||
|
||||
## Local Patches
|
||||
|
||||
Document all local modifications here:
|
||||
|
||||
| Date | File | Description |
|
||||
|------|------|-------------|
|
||||
| (none yet) | — | Initial vendor, no patches applied |
|
||||
|
||||
## Original License
|
||||
|
||||
Fantasy is licensed under the MIT License. See `LICENSE` in this directory.
|
||||
1426
internal/fantasy/agent.go
Normal file
1426
internal/fantasy/agent.go
Normal file
File diff suppressed because it is too large
Load diff
597
internal/fantasy/agent_stream_test.go
Normal file
597
internal/fantasy/agent_stream_test.go
Normal file
|
|
@ -0,0 +1,597 @@
|
|||
package fantasy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// EchoTool is a simple tool that echoes back the input message
|
||||
type EchoTool struct {
|
||||
providerOptions ProviderOptions
|
||||
}
|
||||
|
||||
func (e *EchoTool) SetProviderOptions(opts ProviderOptions) {
|
||||
e.providerOptions = opts
|
||||
}
|
||||
|
||||
func (e *EchoTool) ProviderOptions() ProviderOptions {
|
||||
return e.providerOptions
|
||||
}
|
||||
|
||||
// Info returns the tool information
|
||||
func (e *EchoTool) Info() ToolInfo {
|
||||
return ToolInfo{
|
||||
Name: "echo",
|
||||
Description: "Echo back the provided message",
|
||||
Parameters: map[string]any{
|
||||
"message": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The message to echo back",
|
||||
},
|
||||
},
|
||||
Required: []string{"message"},
|
||||
}
|
||||
}
|
||||
|
||||
// Run executes the echo tool
|
||||
func (e *EchoTool) Run(ctx context.Context, params ToolCall) (ToolResponse, error) {
|
||||
var input struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(params.Input), &input); err != nil {
|
||||
return NewTextErrorResponse("Invalid input: " + err.Error()), nil
|
||||
}
|
||||
|
||||
if input.Message == "" {
|
||||
return NewTextErrorResponse("Message cannot be empty"), nil
|
||||
}
|
||||
|
||||
return NewTextResponse("Echo: " + input.Message), nil
|
||||
}
|
||||
|
||||
// TestStreamingAgentCallbacks tests that all streaming callbacks are called correctly
|
||||
func TestStreamingAgentCallbacks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Track which callbacks were called
|
||||
callbacks := make(map[string]bool)
|
||||
|
||||
// Create a mock language model that returns various stream parts
|
||||
mockModel := &mockLanguageModel{
|
||||
streamFunc: func(ctx context.Context, call Call) (StreamResponse, error) {
|
||||
return func(yield func(StreamPart) bool) {
|
||||
// Test all stream part types
|
||||
if !yield(StreamPart{Type: StreamPartTypeWarnings, Warnings: []CallWarning{{Type: CallWarningTypeOther, Message: "test warning"}}}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeTextStart, ID: "text-1"}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeTextDelta, ID: "text-1", Delta: "Hello"}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeTextEnd, ID: "text-1"}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeReasoningStart, ID: "reasoning-1"}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeReasoningDelta, ID: "reasoning-1", Delta: "thinking..."}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeReasoningEnd, ID: "reasoning-1"}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeToolInputStart, ID: "tool-1", ToolCallName: "test_tool"}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeToolInputDelta, ID: "tool-1", Delta: `{"param"`}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeToolInputEnd, ID: "tool-1"}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeSource, ID: "source-1", SourceType: SourceTypeURL, URL: "https://example.com", Title: "Example"}) {
|
||||
return
|
||||
}
|
||||
yield(StreamPart{
|
||||
Type: StreamPartTypeFinish,
|
||||
Usage: Usage{InputTokens: 5, OutputTokens: 2, TotalTokens: 7},
|
||||
FinishReason: FinishReasonStop,
|
||||
})
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
// Create agent
|
||||
agent := NewAgent(mockModel)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create streaming call with all callbacks
|
||||
streamCall := AgentStreamCall{
|
||||
Prompt: "Test all callbacks",
|
||||
OnAgentStart: func() {
|
||||
callbacks["OnAgentStart"] = true
|
||||
},
|
||||
OnAgentFinish: func(result *AgentResult) error {
|
||||
callbacks["OnAgentFinish"] = true
|
||||
return nil
|
||||
},
|
||||
OnStepStart: func(stepNumber int) error {
|
||||
callbacks["OnStepStart"] = true
|
||||
return nil
|
||||
},
|
||||
OnStepFinish: func(stepResult StepResult) error {
|
||||
callbacks["OnStepFinish"] = true
|
||||
return nil
|
||||
},
|
||||
OnFinish: func(result *AgentResult) {
|
||||
callbacks["OnFinish"] = true
|
||||
},
|
||||
OnError: func(err error) {
|
||||
callbacks["OnError"] = true
|
||||
},
|
||||
OnChunk: func(part StreamPart) error {
|
||||
callbacks["OnChunk"] = true
|
||||
return nil
|
||||
},
|
||||
OnWarnings: func(warnings []CallWarning) error {
|
||||
callbacks["OnWarnings"] = true
|
||||
return nil
|
||||
},
|
||||
OnTextStart: func(id string) error {
|
||||
callbacks["OnTextStart"] = true
|
||||
return nil
|
||||
},
|
||||
OnTextDelta: func(id, text string) error {
|
||||
callbacks["OnTextDelta"] = true
|
||||
return nil
|
||||
},
|
||||
OnTextEnd: func(id string) error {
|
||||
callbacks["OnTextEnd"] = true
|
||||
return nil
|
||||
},
|
||||
OnReasoningStart: func(id string, _ ReasoningContent) error {
|
||||
callbacks["OnReasoningStart"] = true
|
||||
return nil
|
||||
},
|
||||
OnReasoningDelta: func(id, text string) error {
|
||||
callbacks["OnReasoningDelta"] = true
|
||||
return nil
|
||||
},
|
||||
OnReasoningEnd: func(id string, content ReasoningContent) error {
|
||||
callbacks["OnReasoningEnd"] = true
|
||||
return nil
|
||||
},
|
||||
OnToolInputStart: func(id, toolName string) error {
|
||||
callbacks["OnToolInputStart"] = true
|
||||
return nil
|
||||
},
|
||||
OnToolInputDelta: func(id, delta string) error {
|
||||
callbacks["OnToolInputDelta"] = true
|
||||
return nil
|
||||
},
|
||||
OnToolInputEnd: func(id string) error {
|
||||
callbacks["OnToolInputEnd"] = true
|
||||
return nil
|
||||
},
|
||||
OnToolCall: func(toolCall ToolCallContent) error {
|
||||
callbacks["OnToolCall"] = true
|
||||
return nil
|
||||
},
|
||||
OnToolResult: func(result ToolResultContent) error {
|
||||
callbacks["OnToolResult"] = true
|
||||
return nil
|
||||
},
|
||||
OnSource: func(source SourceContent) error {
|
||||
callbacks["OnSource"] = true
|
||||
return nil
|
||||
},
|
||||
OnStreamFinish: func(usage Usage, finishReason FinishReason, providerMetadata ProviderMetadata) error {
|
||||
callbacks["OnStreamFinish"] = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// Execute streaming agent
|
||||
result, err := agent.Stream(ctx, streamCall)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Verify that expected callbacks were called
|
||||
expectedCallbacks := []string{
|
||||
"OnAgentStart",
|
||||
"OnAgentFinish",
|
||||
"OnStepStart",
|
||||
"OnStepFinish",
|
||||
"OnFinish",
|
||||
"OnChunk",
|
||||
"OnWarnings",
|
||||
"OnTextStart",
|
||||
"OnTextDelta",
|
||||
"OnTextEnd",
|
||||
"OnReasoningStart",
|
||||
"OnReasoningDelta",
|
||||
"OnReasoningEnd",
|
||||
"OnToolInputStart",
|
||||
"OnToolInputDelta",
|
||||
"OnToolInputEnd",
|
||||
"OnSource",
|
||||
"OnStreamFinish",
|
||||
}
|
||||
|
||||
for _, callback := range expectedCallbacks {
|
||||
require.True(t, callbacks[callback], "Expected callback %s to be called", callback)
|
||||
}
|
||||
|
||||
// Verify that error callbacks were not called
|
||||
require.False(t, callbacks["OnError"], "OnError should not be called in successful case")
|
||||
require.False(t, callbacks["OnToolCall"], "OnToolCall should not be called without actual tool calls")
|
||||
require.False(t, callbacks["OnToolResult"], "OnToolResult should not be called without actual tool results")
|
||||
}
|
||||
|
||||
// TestStreamingAgentWithTools tests streaming agent with tool calls (mirrors TS test patterns)
|
||||
func TestStreamingAgentWithTools(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stepCount := 0
|
||||
// Create a mock language model that makes a tool call then finishes
|
||||
mockModel := &mockLanguageModel{
|
||||
streamFunc: func(ctx context.Context, call Call) (StreamResponse, error) {
|
||||
stepCount++
|
||||
return func(yield func(StreamPart) bool) {
|
||||
if stepCount == 1 {
|
||||
// First step: make tool call
|
||||
if !yield(StreamPart{Type: StreamPartTypeToolInputStart, ID: "tool-1", ToolCallName: "echo"}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeToolInputDelta, ID: "tool-1", Delta: `{"message"`}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeToolInputDelta, ID: "tool-1", Delta: `: "test"}`}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeToolInputEnd, ID: "tool-1"}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{
|
||||
Type: StreamPartTypeToolCall,
|
||||
ID: "tool-1",
|
||||
ToolCallName: "echo",
|
||||
ToolCallInput: `{"message": "test"}`,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
yield(StreamPart{
|
||||
Type: StreamPartTypeFinish,
|
||||
Usage: Usage{InputTokens: 10, OutputTokens: 5, TotalTokens: 15},
|
||||
FinishReason: FinishReasonToolCalls,
|
||||
})
|
||||
} else {
|
||||
// Second step: finish after tool execution
|
||||
if !yield(StreamPart{Type: StreamPartTypeTextStart, ID: "text-1"}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeTextDelta, ID: "text-1", Delta: "Tool executed successfully"}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeTextEnd, ID: "text-1"}) {
|
||||
return
|
||||
}
|
||||
yield(StreamPart{
|
||||
Type: StreamPartTypeFinish,
|
||||
Usage: Usage{InputTokens: 5, OutputTokens: 3, TotalTokens: 8},
|
||||
FinishReason: FinishReasonStop,
|
||||
})
|
||||
}
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
// Create agent with echo tool
|
||||
agent := NewAgent(
|
||||
mockModel,
|
||||
WithSystemPrompt("You are a helpful assistant."),
|
||||
WithTools(&EchoTool{}),
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Track callback invocations
|
||||
var toolInputStartCalled bool
|
||||
var toolInputDeltaCalled bool
|
||||
var toolInputEndCalled bool
|
||||
var toolCallCalled bool
|
||||
var toolResultCalled bool
|
||||
|
||||
// Create streaming call with callbacks
|
||||
streamCall := AgentStreamCall{
|
||||
Prompt: "Echo 'test'",
|
||||
OnToolInputStart: func(id, toolName string) error {
|
||||
toolInputStartCalled = true
|
||||
require.Equal(t, "tool-1", id)
|
||||
require.Equal(t, "echo", toolName)
|
||||
return nil
|
||||
},
|
||||
OnToolInputDelta: func(id, delta string) error {
|
||||
toolInputDeltaCalled = true
|
||||
require.Equal(t, "tool-1", id)
|
||||
require.Contains(t, []string{`{"message"`, `: "test"}`}, delta)
|
||||
return nil
|
||||
},
|
||||
OnToolInputEnd: func(id string) error {
|
||||
toolInputEndCalled = true
|
||||
require.Equal(t, "tool-1", id)
|
||||
return nil
|
||||
},
|
||||
OnToolCall: func(toolCall ToolCallContent) error {
|
||||
toolCallCalled = true
|
||||
require.Equal(t, "echo", toolCall.ToolName)
|
||||
require.Equal(t, `{"message": "test"}`, toolCall.Input)
|
||||
return nil
|
||||
},
|
||||
OnToolResult: func(result ToolResultContent) error {
|
||||
toolResultCalled = true
|
||||
require.Equal(t, "echo", result.ToolName)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// Execute streaming agent
|
||||
result, err := agent.Stream(ctx, streamCall)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify results
|
||||
require.True(t, toolInputStartCalled, "OnToolInputStart should have been called")
|
||||
require.True(t, toolInputDeltaCalled, "OnToolInputDelta should have been called")
|
||||
require.True(t, toolInputEndCalled, "OnToolInputEnd should have been called")
|
||||
require.True(t, toolCallCalled, "OnToolCall should have been called")
|
||||
require.True(t, toolResultCalled, "OnToolResult should have been called")
|
||||
require.Equal(t, 2, len(result.Steps)) // Two steps: tool call + final response
|
||||
|
||||
// Check that tool was executed in first step
|
||||
firstStep := result.Steps[0]
|
||||
toolCalls := firstStep.Content.ToolCalls()
|
||||
require.Equal(t, 1, len(toolCalls))
|
||||
require.Equal(t, "echo", toolCalls[0].ToolName)
|
||||
|
||||
toolResults := firstStep.Content.ToolResults()
|
||||
require.Equal(t, 1, len(toolResults))
|
||||
require.Equal(t, "echo", toolResults[0].ToolName)
|
||||
}
|
||||
|
||||
// TestStreamingAgentTextDeltas tests text streaming (mirrors TS textStream tests)
|
||||
func TestStreamingAgentTextDeltas(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a mock language model that returns text deltas
|
||||
mockModel := &mockLanguageModel{
|
||||
streamFunc: func(ctx context.Context, call Call) (StreamResponse, error) {
|
||||
return func(yield func(StreamPart) bool) {
|
||||
if !yield(StreamPart{Type: StreamPartTypeTextStart, ID: "text-1"}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeTextDelta, ID: "text-1", Delta: "Hello"}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeTextDelta, ID: "text-1", Delta: ", "}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeTextDelta, ID: "text-1", Delta: "world!"}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeTextEnd, ID: "text-1"}) {
|
||||
return
|
||||
}
|
||||
yield(StreamPart{
|
||||
Type: StreamPartTypeFinish,
|
||||
Usage: Usage{InputTokens: 3, OutputTokens: 10, TotalTokens: 13},
|
||||
FinishReason: FinishReasonStop,
|
||||
})
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
agent := NewAgent(mockModel)
|
||||
ctx := context.Background()
|
||||
|
||||
// Track text deltas
|
||||
var textDeltas []string
|
||||
|
||||
streamCall := AgentStreamCall{
|
||||
Prompt: "Say hello",
|
||||
OnTextDelta: func(id, text string) error {
|
||||
if text != "" {
|
||||
textDeltas = append(textDeltas, text)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
result, err := agent.Stream(ctx, streamCall)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify text deltas match expected pattern
|
||||
require.Equal(t, []string{"Hello", ", ", "world!"}, textDeltas)
|
||||
require.Equal(t, "Hello, world!", result.Response.Content.Text())
|
||||
require.Equal(t, int64(13), result.TotalUsage.TotalTokens)
|
||||
}
|
||||
|
||||
// TestStreamingAgentReasoning tests reasoning content (mirrors TS reasoning tests)
|
||||
func TestStreamingAgentReasoning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mockModel := &mockLanguageModel{
|
||||
streamFunc: func(ctx context.Context, call Call) (StreamResponse, error) {
|
||||
return func(yield func(StreamPart) bool) {
|
||||
if !yield(StreamPart{Type: StreamPartTypeReasoningStart, ID: "reasoning-1"}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeReasoningDelta, ID: "reasoning-1", Delta: "I will open the conversation"}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeReasoningDelta, ID: "reasoning-1", Delta: " with witty banter."}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeReasoningEnd, ID: "reasoning-1"}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeTextStart, ID: "text-1"}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeTextDelta, ID: "text-1", Delta: "Hi there!"}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeTextEnd, ID: "text-1"}) {
|
||||
return
|
||||
}
|
||||
yield(StreamPart{
|
||||
Type: StreamPartTypeFinish,
|
||||
Usage: Usage{InputTokens: 5, OutputTokens: 15, TotalTokens: 20},
|
||||
FinishReason: FinishReasonStop,
|
||||
})
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
agent := NewAgent(mockModel)
|
||||
ctx := context.Background()
|
||||
|
||||
var reasoningDeltas []string
|
||||
var textDeltas []string
|
||||
|
||||
streamCall := AgentStreamCall{
|
||||
Prompt: "Think and respond",
|
||||
OnReasoningDelta: func(id, text string) error {
|
||||
reasoningDeltas = append(reasoningDeltas, text)
|
||||
return nil
|
||||
},
|
||||
OnTextDelta: func(id, text string) error {
|
||||
textDeltas = append(textDeltas, text)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
result, err := agent.Stream(ctx, streamCall)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify reasoning and text are separate
|
||||
require.Equal(t, []string{"I will open the conversation", " with witty banter."}, reasoningDeltas)
|
||||
require.Equal(t, []string{"Hi there!"}, textDeltas)
|
||||
require.Equal(t, "Hi there!", result.Response.Content.Text())
|
||||
require.Equal(t, "I will open the conversation with witty banter.", result.Response.Content.ReasoningText())
|
||||
}
|
||||
|
||||
// TestStreamingAgentError tests error handling (mirrors TS error tests)
|
||||
func TestStreamingAgentError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a mock language model that returns an error
|
||||
mockModel := &mockLanguageModel{
|
||||
streamFunc: func(ctx context.Context, call Call) (StreamResponse, error) {
|
||||
return func(yield func(StreamPart) bool) {
|
||||
yield(StreamPart{Type: StreamPartTypeError, Error: fmt.Errorf("mock stream error")})
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
agent := NewAgent(mockModel)
|
||||
ctx := context.Background()
|
||||
|
||||
// Track error callbacks
|
||||
var errorOccurred bool
|
||||
var errorMessage string
|
||||
|
||||
streamCall := AgentStreamCall{
|
||||
Prompt: "This will fail",
|
||||
|
||||
OnError: func(err error) {
|
||||
errorOccurred = true
|
||||
errorMessage = err.Error()
|
||||
},
|
||||
}
|
||||
|
||||
// Execute streaming agent
|
||||
result, err := agent.Stream(ctx, streamCall)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
require.True(t, errorOccurred, "OnError should have been called")
|
||||
require.Contains(t, errorMessage, "mock stream error")
|
||||
}
|
||||
|
||||
// TestStreamingAgentSources tests source handling (mirrors TS source tests)
|
||||
func TestStreamingAgentSources(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mockModel := &mockLanguageModel{
|
||||
streamFunc: func(ctx context.Context, call Call) (StreamResponse, error) {
|
||||
return func(yield func(StreamPart) bool) {
|
||||
if !yield(StreamPart{
|
||||
Type: StreamPartTypeSource,
|
||||
ID: "source-1",
|
||||
SourceType: SourceTypeURL,
|
||||
URL: "https://example.com",
|
||||
Title: "Example",
|
||||
}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeTextStart, ID: "text-1"}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeTextDelta, ID: "text-1", Delta: "Hello!"}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{Type: StreamPartTypeTextEnd, ID: "text-1"}) {
|
||||
return
|
||||
}
|
||||
if !yield(StreamPart{
|
||||
Type: StreamPartTypeSource,
|
||||
ID: "source-2",
|
||||
SourceType: SourceTypeDocument,
|
||||
Title: "Document Example",
|
||||
}) {
|
||||
return
|
||||
}
|
||||
yield(StreamPart{
|
||||
Type: StreamPartTypeFinish,
|
||||
Usage: Usage{InputTokens: 3, OutputTokens: 5, TotalTokens: 8},
|
||||
FinishReason: FinishReasonStop,
|
||||
})
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
agent := NewAgent(mockModel)
|
||||
ctx := context.Background()
|
||||
|
||||
var sources []SourceContent
|
||||
|
||||
streamCall := AgentStreamCall{
|
||||
Prompt: "Search and respond",
|
||||
OnSource: func(source SourceContent) error {
|
||||
sources = append(sources, source)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
result, err := agent.Stream(ctx, streamCall)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify sources were captured
|
||||
require.Equal(t, 2, len(sources))
|
||||
require.Equal(t, SourceTypeURL, sources[0].SourceType)
|
||||
require.Equal(t, "https://example.com", sources[0].URL)
|
||||
require.Equal(t, "Example", sources[0].Title)
|
||||
require.Equal(t, SourceTypeDocument, sources[1].SourceType)
|
||||
require.Equal(t, "Document Example", sources[1].Title)
|
||||
|
||||
// Verify sources are in final result
|
||||
resultSources := result.Response.Content.Sources()
|
||||
require.Equal(t, 2, len(resultSources))
|
||||
}
|
||||
1770
internal/fantasy/agent_test.go
Normal file
1770
internal/fantasy/agent_test.go
Normal file
File diff suppressed because it is too large
Load diff
560
internal/fantasy/content.go
Normal file
560
internal/fantasy/content.go
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
package fantasy
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// ProviderOptionsData is an interface for provider-specific options data.
|
||||
// All implementations MUST also implement encoding/json.Marshaler and
|
||||
// encoding/json.Unmarshaler interfaces to ensure proper JSON serialization
|
||||
// with the provider registry system.
|
||||
//
|
||||
// Recommended implementation pattern using generic helpers:
|
||||
//
|
||||
// // Define type constants at the top of your file
|
||||
// const TypeMyProviderOptions = "myprovider.options"
|
||||
//
|
||||
// type MyProviderOptions struct {
|
||||
// Field string `json:"field"`
|
||||
// }
|
||||
//
|
||||
// // Register the type in init() - place at top of file after constants
|
||||
// func init() {
|
||||
// fantasy.RegisterProviderType(TypeMyProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
|
||||
// var opts MyProviderOptions
|
||||
// if err := json.Unmarshal(data, &opts); err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// return &opts, nil
|
||||
// })
|
||||
// }
|
||||
//
|
||||
// // Implement ProviderOptionsData interface
|
||||
// func (*MyProviderOptions) Options() {}
|
||||
//
|
||||
// // Implement json.Marshaler using the generic helper
|
||||
// func (m MyProviderOptions) MarshalJSON() ([]byte, error) {
|
||||
// type plain MyProviderOptions
|
||||
// return fantasy.MarshalProviderType(TypeMyProviderOptions, plain(m))
|
||||
// }
|
||||
//
|
||||
// // Implement json.Unmarshaler using the generic helper
|
||||
// // Note: Receives inner data after type routing by the registry.
|
||||
// func (m *MyProviderOptions) UnmarshalJSON(data []byte) error {
|
||||
// type plain MyProviderOptions
|
||||
// var p plain
|
||||
// if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
|
||||
// return err
|
||||
// }
|
||||
// *m = MyProviderOptions(p)
|
||||
// return nil
|
||||
// }
|
||||
type ProviderOptionsData interface {
|
||||
// Options is a marker method that identifies types implementing this interface.
|
||||
Options()
|
||||
json.Marshaler
|
||||
json.Unmarshaler
|
||||
}
|
||||
|
||||
// ProviderMetadata represents additional provider-specific metadata.
|
||||
// They are passed through from the provider to the AI SDK and enable
|
||||
// provider-specific results that can be fully encapsulated in the provider.
|
||||
//
|
||||
// The outer map is keyed by the provider name, and the inner
|
||||
// map is keyed by the provider-specific metadata key.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// {
|
||||
// "anthropic": {
|
||||
// "signature": "sig....."
|
||||
// }
|
||||
// }
|
||||
type ProviderMetadata map[string]ProviderOptionsData
|
||||
|
||||
// ProviderOptions represents additional provider-specific options.
|
||||
// Options are additional input to the provider. They are passed through
|
||||
// to the provider from the AI SDK and enable provider-specific functionality
|
||||
// that can be fully encapsulated in the provider.
|
||||
//
|
||||
// This enables us to quickly ship provider-specific functionality
|
||||
// without affecting the core AI SDK.
|
||||
//
|
||||
// The outer map is keyed by the provider name, and the inner
|
||||
// map is keyed by the provider-specific option key.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// {
|
||||
// "anthropic": {
|
||||
// "cacheControl": { "type": "ephemeral" }
|
||||
// }
|
||||
// }
|
||||
type ProviderOptions map[string]ProviderOptionsData
|
||||
|
||||
// FinishReason represents why a language model finished generating a response.
|
||||
//
|
||||
// Can be one of the following:
|
||||
// - `stop`: model generated stop sequence
|
||||
// - `length`: model generated maximum number of tokens
|
||||
// - `content-filter`: content filter violation stopped the model
|
||||
// - `tool-calls`: model triggered tool calls
|
||||
// - `error`: model stopped because of an error
|
||||
// - `other`: model stopped for other reasons
|
||||
// - `unknown`: the model has not transmitted a finish reason.
|
||||
type FinishReason string
|
||||
|
||||
const (
|
||||
// FinishReasonStop indicates the model generated a stop sequence.
|
||||
FinishReasonStop FinishReason = "stop" // model generated stop sequence
|
||||
// FinishReasonLength indicates the model generated maximum number of tokens.
|
||||
FinishReasonLength FinishReason = "length" // model generated maximum number of tokens
|
||||
// FinishReasonContentFilter indicates content filter violation stopped the model.
|
||||
FinishReasonContentFilter FinishReason = "content-filter" // content filter violation stopped the model
|
||||
// FinishReasonToolCalls indicates the model triggered tool calls.
|
||||
FinishReasonToolCalls FinishReason = "tool-calls" // model triggered tool calls
|
||||
// FinishReasonError indicates the model stopped because of an error.
|
||||
FinishReasonError FinishReason = "error" // model stopped because of an error
|
||||
// FinishReasonOther indicates the model stopped for other reasons.
|
||||
FinishReasonOther FinishReason = "other" // model stopped for other reasons
|
||||
// FinishReasonUnknown indicates the model has not transmitted a finish reason.
|
||||
FinishReasonUnknown FinishReason = "unknown" // the model has not transmitted a finish reason
|
||||
)
|
||||
|
||||
// Prompt represents a list of messages for the language model.
|
||||
type Prompt []Message
|
||||
|
||||
// MessageRole represents the role of a message.
|
||||
type MessageRole string
|
||||
|
||||
const (
|
||||
// MessageRoleSystem represents a system message.
|
||||
MessageRoleSystem MessageRole = "system"
|
||||
// MessageRoleUser represents a user message.
|
||||
MessageRoleUser MessageRole = "user"
|
||||
// MessageRoleAssistant represents an assistant message.
|
||||
MessageRoleAssistant MessageRole = "assistant"
|
||||
// MessageRoleTool represents a tool message.
|
||||
MessageRoleTool MessageRole = "tool"
|
||||
)
|
||||
|
||||
// Message represents a message in a prompt.
|
||||
type Message struct {
|
||||
Role MessageRole `json:"role"`
|
||||
Content []MessagePart `json:"content"`
|
||||
ProviderOptions ProviderOptions `json:"provider_options"`
|
||||
}
|
||||
|
||||
// AsContentType converts a Content interface to a specific content type.
|
||||
func AsContentType[T Content](content Content) (T, bool) {
|
||||
var zero T
|
||||
if content == nil {
|
||||
return zero, false
|
||||
}
|
||||
switch v := any(content).(type) {
|
||||
case T:
|
||||
return v, true
|
||||
case *T:
|
||||
return *v, true
|
||||
default:
|
||||
return zero, false
|
||||
}
|
||||
}
|
||||
|
||||
// AsMessagePart converts a MessagePart interface to a specific message part type.
|
||||
func AsMessagePart[T MessagePart](content MessagePart) (T, bool) {
|
||||
var zero T
|
||||
if content == nil {
|
||||
return zero, false
|
||||
}
|
||||
switch v := any(content).(type) {
|
||||
case T:
|
||||
return v, true
|
||||
case *T:
|
||||
return *v, true
|
||||
default:
|
||||
return zero, false
|
||||
}
|
||||
}
|
||||
|
||||
// MessagePart represents a part of a message content.
|
||||
type MessagePart interface {
|
||||
GetType() ContentType
|
||||
Options() ProviderOptions
|
||||
}
|
||||
|
||||
// TextPart represents text content in a message.
|
||||
type TextPart struct {
|
||||
Text string `json:"text"`
|
||||
ProviderOptions ProviderOptions `json:"provider_options"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the text part.
|
||||
func (t TextPart) GetType() ContentType {
|
||||
return ContentTypeText
|
||||
}
|
||||
|
||||
// Options returns the provider options for the text part.
|
||||
func (t TextPart) Options() ProviderOptions {
|
||||
return t.ProviderOptions
|
||||
}
|
||||
|
||||
// ReasoningPart represents reasoning content in a message.
|
||||
type ReasoningPart struct {
|
||||
Text string `json:"text"`
|
||||
ProviderOptions ProviderOptions `json:"provider_options"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the reasoning part.
|
||||
func (r ReasoningPart) GetType() ContentType {
|
||||
return ContentTypeReasoning
|
||||
}
|
||||
|
||||
// Options returns the provider options for the reasoning part.
|
||||
func (r ReasoningPart) Options() ProviderOptions {
|
||||
return r.ProviderOptions
|
||||
}
|
||||
|
||||
// FilePart represents file content in a message.
|
||||
type FilePart struct {
|
||||
Filename string `json:"filename"`
|
||||
Data []byte `json:"data"`
|
||||
MediaType string `json:"media_type"`
|
||||
ProviderOptions ProviderOptions `json:"provider_options"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the file part.
|
||||
func (f FilePart) GetType() ContentType {
|
||||
return ContentTypeFile
|
||||
}
|
||||
|
||||
// Options returns the provider options for the file part.
|
||||
func (f FilePart) Options() ProviderOptions {
|
||||
return f.ProviderOptions
|
||||
}
|
||||
|
||||
// ToolCallPart represents a tool call in a message.
|
||||
type ToolCallPart struct {
|
||||
ToolCallID string `json:"tool_call_id"`
|
||||
ToolName string `json:"tool_name"`
|
||||
Input string `json:"input"` // the json string
|
||||
ProviderExecuted bool `json:"provider_executed"`
|
||||
ProviderOptions ProviderOptions `json:"provider_options"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the tool call part.
|
||||
func (t ToolCallPart) GetType() ContentType {
|
||||
return ContentTypeToolCall
|
||||
}
|
||||
|
||||
// Options returns the provider options for the tool call part.
|
||||
func (t ToolCallPart) Options() ProviderOptions {
|
||||
return t.ProviderOptions
|
||||
}
|
||||
|
||||
// ToolResultPart represents a tool result in a message.
|
||||
type ToolResultPart struct {
|
||||
ToolCallID string `json:"tool_call_id"`
|
||||
Output ToolResultOutputContent `json:"output"`
|
||||
ProviderOptions ProviderOptions `json:"provider_options"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the tool result part.
|
||||
func (t ToolResultPart) GetType() ContentType {
|
||||
return ContentTypeToolResult
|
||||
}
|
||||
|
||||
// Options returns the provider options for the tool result part.
|
||||
func (t ToolResultPart) Options() ProviderOptions {
|
||||
return t.ProviderOptions
|
||||
}
|
||||
|
||||
// ToolResultContentType represents the type of tool result output.
|
||||
type ToolResultContentType string
|
||||
|
||||
const (
|
||||
// ToolResultContentTypeText represents text output.
|
||||
ToolResultContentTypeText ToolResultContentType = "text"
|
||||
// ToolResultContentTypeError represents error text output.
|
||||
ToolResultContentTypeError ToolResultContentType = "error"
|
||||
// ToolResultContentTypeMedia represents content output.
|
||||
ToolResultContentTypeMedia ToolResultContentType = "media"
|
||||
)
|
||||
|
||||
// ToolResultOutputContent represents the output content of a tool result.
|
||||
type ToolResultOutputContent interface {
|
||||
GetType() ToolResultContentType
|
||||
}
|
||||
|
||||
// ToolResultOutputContentText represents text output content of a tool result.
|
||||
type ToolResultOutputContentText struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the tool result output content text.
|
||||
func (t ToolResultOutputContentText) GetType() ToolResultContentType {
|
||||
return ToolResultContentTypeText
|
||||
}
|
||||
|
||||
// ToolResultOutputContentError represents error output content of a tool result.
|
||||
type ToolResultOutputContentError struct {
|
||||
Error error `json:"error"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the tool result output content error.
|
||||
func (t ToolResultOutputContentError) GetType() ToolResultContentType {
|
||||
return ToolResultContentTypeError
|
||||
}
|
||||
|
||||
// ToolResultOutputContentMedia represents media output content of a tool result.
|
||||
type ToolResultOutputContentMedia struct {
|
||||
Data string `json:"data"` // for media type (base64)
|
||||
MediaType string `json:"media_type"` // for media type
|
||||
Text string `json:"text,omitempty"` // optional text content accompanying the media
|
||||
}
|
||||
|
||||
// GetType returns the type of the tool result output content media.
|
||||
func (t ToolResultOutputContentMedia) GetType() ToolResultContentType {
|
||||
return ToolResultContentTypeMedia
|
||||
}
|
||||
|
||||
// AsToolResultOutputType converts a ToolResultOutputContent interface to a specific type.
|
||||
func AsToolResultOutputType[T ToolResultOutputContent](content ToolResultOutputContent) (T, bool) {
|
||||
var zero T
|
||||
if content == nil {
|
||||
return zero, false
|
||||
}
|
||||
switch v := any(content).(type) {
|
||||
case T:
|
||||
return v, true
|
||||
case *T:
|
||||
return *v, true
|
||||
default:
|
||||
return zero, false
|
||||
}
|
||||
}
|
||||
|
||||
// ContentType represents the type of content.
|
||||
type ContentType string
|
||||
|
||||
const (
|
||||
// ContentTypeText represents text content.
|
||||
ContentTypeText ContentType = "text"
|
||||
// ContentTypeReasoning represents reasoning content.
|
||||
ContentTypeReasoning ContentType = "reasoning"
|
||||
// ContentTypeFile represents file content.
|
||||
ContentTypeFile ContentType = "file"
|
||||
// ContentTypeSource represents source content.
|
||||
ContentTypeSource ContentType = "source"
|
||||
// ContentTypeToolCall represents a tool call.
|
||||
ContentTypeToolCall ContentType = "tool-call"
|
||||
// ContentTypeToolResult represents a tool result.
|
||||
ContentTypeToolResult ContentType = "tool-result"
|
||||
)
|
||||
|
||||
// Content represents generated content from the model.
|
||||
type Content interface {
|
||||
GetType() ContentType
|
||||
}
|
||||
|
||||
// TextContent represents text that the model has generated.
|
||||
type TextContent struct {
|
||||
// The text content.
|
||||
Text string `json:"text"`
|
||||
ProviderMetadata ProviderMetadata `json:"provider_metadata"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the text content.
|
||||
func (t TextContent) GetType() ContentType {
|
||||
return ContentTypeText
|
||||
}
|
||||
|
||||
// ReasoningContent represents reasoning that the model has generated.
|
||||
type ReasoningContent struct {
|
||||
Text string `json:"text"`
|
||||
ProviderMetadata ProviderMetadata `json:"provider_metadata"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the reasoning content.
|
||||
func (r ReasoningContent) GetType() ContentType {
|
||||
return ContentTypeReasoning
|
||||
}
|
||||
|
||||
// FileContent represents a file that has been generated by the model.
|
||||
// Generated files as base64 encoded strings or binary data.
|
||||
// The files should be returned without any unnecessary conversion.
|
||||
type FileContent struct {
|
||||
// The IANA media type of the file, e.g. `image/png` or `audio/mp3`.
|
||||
// @see https://www.iana.org/assignments/media-types/media-types.xhtml
|
||||
MediaType string `json:"media_type"`
|
||||
// Generated file data as binary data.
|
||||
Data []byte `json:"data"`
|
||||
ProviderMetadata ProviderMetadata `json:"provider_metadata"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the file content.
|
||||
func (f FileContent) GetType() ContentType {
|
||||
return ContentTypeFile
|
||||
}
|
||||
|
||||
// SourceType represents the type of source.
|
||||
type SourceType string
|
||||
|
||||
const (
|
||||
// SourceTypeURL represents a URL source.
|
||||
SourceTypeURL SourceType = "url"
|
||||
// SourceTypeDocument represents a document source.
|
||||
SourceTypeDocument SourceType = "document"
|
||||
)
|
||||
|
||||
// SourceContent represents a source that has been used as input to generate the response.
|
||||
type SourceContent struct {
|
||||
SourceType SourceType `json:"source_type"` // "url" or "document"
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"` // for URL sources
|
||||
Title string `json:"title"`
|
||||
MediaType string `json:"media_type"` // for document sources (IANA media type)
|
||||
Filename string `json:"filename"` // for document sources
|
||||
ProviderMetadata ProviderMetadata `json:"provider_metadata"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the source content.
|
||||
func (s SourceContent) GetType() ContentType {
|
||||
return ContentTypeSource
|
||||
}
|
||||
|
||||
// ToolCallContent represents tool calls that the model has generated.
|
||||
type ToolCallContent struct {
|
||||
ToolCallID string `json:"tool_call_id"`
|
||||
ToolName string `json:"tool_name"`
|
||||
// Stringified JSON object with the tool call arguments.
|
||||
// Must match the parameters schema of the tool.
|
||||
Input string `json:"input"`
|
||||
// Whether the tool call will be executed by the provider.
|
||||
// If this flag is not set or is false, the tool call will be executed by the client.
|
||||
ProviderExecuted bool `json:"provider_executed"`
|
||||
// Additional provider-specific metadata for the tool call.
|
||||
ProviderMetadata ProviderMetadata `json:"provider_metadata"`
|
||||
// Whether this tool call is invalid (failed validation/parsing)
|
||||
Invalid bool `json:"invalid,omitempty"`
|
||||
// Error that occurred during validation/parsing (only set if Invalid is true)
|
||||
ValidationError error `json:"validation_error,omitempty"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the tool call content.
|
||||
func (t ToolCallContent) GetType() ContentType {
|
||||
return ContentTypeToolCall
|
||||
}
|
||||
|
||||
// ToolResultContent represents result of a tool call that has been executed by the provider.
|
||||
type ToolResultContent struct {
|
||||
// The ID of the tool call that this result is associated with.
|
||||
ToolCallID string `json:"tool_call_id"`
|
||||
// Name of the tool that generated this result.
|
||||
ToolName string `json:"tool_name"`
|
||||
// Result of the tool call. This is a JSON-serializable object.
|
||||
Result ToolResultOutputContent `json:"result"`
|
||||
ClientMetadata string `json:"client_metadata"` // Metadata from the client that executed the tool
|
||||
// Whether the tool result was generated by the provider.
|
||||
// If this flag is set to true, the tool result was generated by the provider.
|
||||
// If this flag is not set or is false, the tool result was generated by the client.
|
||||
ProviderExecuted bool `json:"provider_executed"`
|
||||
// Additional provider-specific metadata for the tool result.
|
||||
ProviderMetadata ProviderMetadata `json:"provider_metadata"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the tool result content.
|
||||
func (t ToolResultContent) GetType() ContentType {
|
||||
return ContentTypeToolResult
|
||||
}
|
||||
|
||||
// ToolType represents the type of tool.
|
||||
type ToolType string
|
||||
|
||||
const (
|
||||
// ToolTypeFunction represents a function tool.
|
||||
ToolTypeFunction ToolType = "function"
|
||||
// ToolTypeProviderDefined represents a provider-defined tool.
|
||||
ToolTypeProviderDefined ToolType = "provider-defined"
|
||||
)
|
||||
|
||||
// Tool represents a tool that can be used by the model.
|
||||
//
|
||||
// Note: this is **not** the user-facing tool definition. The AI SDK methods will
|
||||
// map the user-facing tool definitions to this format.
|
||||
type Tool interface {
|
||||
GetType() ToolType
|
||||
GetName() string
|
||||
}
|
||||
|
||||
// FunctionTool represents a function tool.
|
||||
//
|
||||
// A tool has a name, a description, and a set of parameters.
|
||||
type FunctionTool struct {
|
||||
// Name of the tool. Unique within this model call.
|
||||
Name string `json:"name"`
|
||||
// Description of the tool. The language model uses this to understand the
|
||||
// tool's purpose and to provide better completion suggestions.
|
||||
Description string `json:"description"`
|
||||
// InputSchema - the parameters that the tool expects. The language model uses this to
|
||||
// understand the tool's input requirements and to provide matching suggestions.
|
||||
InputSchema map[string]any `json:"input_schema"` // JSON Schema
|
||||
// ProviderOptions are provider-specific options for the tool.
|
||||
ProviderOptions ProviderOptions `json:"provider_options"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the function tool.
|
||||
func (f FunctionTool) GetType() ToolType {
|
||||
return ToolTypeFunction
|
||||
}
|
||||
|
||||
// GetName returns the name of the function tool.
|
||||
func (f FunctionTool) GetName() string {
|
||||
return f.Name
|
||||
}
|
||||
|
||||
// ProviderDefinedTool represents the configuration of a tool that is defined by the provider.
|
||||
type ProviderDefinedTool struct {
|
||||
// ID of the tool. Should follow the format `<provider-name>.<unique-tool-name>`.
|
||||
ID string `json:"id"`
|
||||
// Name of the tool that the user must use in the tool set.
|
||||
Name string `json:"name"`
|
||||
// Args for configuring the tool. Must match the expected arguments defined by the provider for this tool.
|
||||
Args map[string]any `json:"args"`
|
||||
}
|
||||
|
||||
// GetType returns the type of the provider-defined tool.
|
||||
func (p ProviderDefinedTool) GetType() ToolType {
|
||||
return ToolTypeProviderDefined
|
||||
}
|
||||
|
||||
// GetName returns the name of the provider-defined tool.
|
||||
func (p ProviderDefinedTool) GetName() string {
|
||||
return p.Name
|
||||
}
|
||||
|
||||
// NewUserMessage creates a new user message with the given prompt and optional files.
|
||||
func NewUserMessage(prompt string, files ...FilePart) Message {
|
||||
content := make([]MessagePart, 0, len(files)+1)
|
||||
content = append(content, TextPart{Text: prompt})
|
||||
|
||||
for _, f := range files {
|
||||
content = append(content, f)
|
||||
}
|
||||
|
||||
return Message{
|
||||
Role: MessageRoleUser,
|
||||
Content: content,
|
||||
}
|
||||
}
|
||||
|
||||
// NewSystemMessage creates a new system message with the given prompts.
|
||||
func NewSystemMessage(prompt ...string) Message {
|
||||
content := make([]MessagePart, 0, len(prompt))
|
||||
for _, p := range prompt {
|
||||
content = append(content, TextPart{Text: p})
|
||||
}
|
||||
|
||||
return Message{
|
||||
Role: MessageRoleSystem,
|
||||
Content: content,
|
||||
}
|
||||
}
|
||||
1038
internal/fantasy/content_json.go
Normal file
1038
internal/fantasy/content_json.go
Normal file
File diff suppressed because it is too large
Load diff
6
internal/fantasy/crush.json
Normal file
6
internal/fantasy/crush.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"$schema": "https://charm.land/crush.json",
|
||||
"lsp": {
|
||||
"gopls": {}
|
||||
}
|
||||
}
|
||||
1
internal/fantasy/cspell.json
Normal file
1
internal/fantasy/cspell.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"flagWords":[],"words":["mapstructure","mapstructure","charmbracelet","providertests","joho","godotenv","stretchr","Quantizations","Logit","Probs","openrouter","openaisdk","Logprobs","Includable"],"version":"0.2","language":"en"}
|
||||
2
internal/fantasy/doc.go
Normal file
2
internal/fantasy/doc.go
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Package fantasy provides a unified interface for interacting with various AI language models.
|
||||
package fantasy
|
||||
104
internal/fantasy/errors.go
Normal file
104
internal/fantasy/errors.go
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
package fantasy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/x/exp/slice"
|
||||
)
|
||||
|
||||
// Error is a custom error type for the fantasy package.
|
||||
type Error struct {
|
||||
Message string
|
||||
Title string
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (err *Error) Error() string {
|
||||
if err.Title == "" {
|
||||
return err.Message
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", err.Title, err.Message)
|
||||
}
|
||||
|
||||
func (err Error) Unwrap() error {
|
||||
return err.Cause
|
||||
}
|
||||
|
||||
// ProviderError represents an error returned by an external provider.
|
||||
type ProviderError struct {
|
||||
Message string
|
||||
Title string
|
||||
Cause error
|
||||
|
||||
URL string
|
||||
StatusCode int
|
||||
RequestBody []byte
|
||||
ResponseHeaders map[string]string
|
||||
ResponseBody []byte
|
||||
}
|
||||
|
||||
func (m *ProviderError) Error() string {
|
||||
if m.Title == "" {
|
||||
return m.Message
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", m.Title, m.Message)
|
||||
}
|
||||
|
||||
// IsRetryable checks if the error is retryable based on the status code.
|
||||
func (m *ProviderError) IsRetryable() bool {
|
||||
return m.StatusCode == http.StatusRequestTimeout || m.StatusCode == http.StatusConflict || m.StatusCode == http.StatusTooManyRequests
|
||||
}
|
||||
|
||||
// RetryError represents an error that occurred during retry operations.
|
||||
type RetryError struct {
|
||||
Errors []error
|
||||
}
|
||||
|
||||
func (e *RetryError) Error() string {
|
||||
if err, ok := slice.Last(e.Errors); ok {
|
||||
return fmt.Sprintf("retry error: %v", err)
|
||||
}
|
||||
return "retry error: no underlying errors"
|
||||
}
|
||||
|
||||
func (e RetryError) Unwrap() error {
|
||||
if err, ok := slice.Last(e.Errors); ok {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrorTitleForStatusCode returns a human-readable title for a given HTTP status code.
|
||||
func ErrorTitleForStatusCode(statusCode int) string {
|
||||
return strings.ToLower(http.StatusText(statusCode))
|
||||
}
|
||||
|
||||
// NoObjectGeneratedError is returned when object generation fails
|
||||
// due to parsing errors, validation errors, or model failures.
|
||||
type NoObjectGeneratedError struct {
|
||||
RawText string
|
||||
ParseError error
|
||||
ValidationError error
|
||||
Usage Usage
|
||||
FinishReason FinishReason
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (e *NoObjectGeneratedError) Error() string {
|
||||
if e.ValidationError != nil {
|
||||
return fmt.Sprintf("object validation failed: %v", e.ValidationError)
|
||||
}
|
||||
if e.ParseError != nil {
|
||||
return fmt.Sprintf("failed to parse object: %v", e.ParseError)
|
||||
}
|
||||
return "failed to generate object"
|
||||
}
|
||||
|
||||
// IsNoObjectGeneratedError checks if an error is of type NoObjectGeneratedError.
|
||||
func IsNoObjectGeneratedError(err error) bool {
|
||||
var target *NoObjectGeneratedError
|
||||
return errors.As(err, &target)
|
||||
}
|
||||
81
internal/fantasy/go.mod
Normal file
81
internal/fantasy/go.mod
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
module charm.land/fantasy
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
charm.land/x/vcr v0.1.1
|
||||
cloud.google.com/go/auth v0.18.1
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.7
|
||||
github.com/aws/smithy-go v1.24.0
|
||||
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5
|
||||
github.com/charmbracelet/x/json v0.2.0
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/kaptinlin/jsonschema v0.6.10
|
||||
github.com/openai/openai-go/v2 v2.7.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
google.golang.org/genai v1.45.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.116.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/kaptinlin/go-i18n v0.2.3 // indirect
|
||||
github.com/kaptinlin/jsonpointer v0.4.9 // indirect
|
||||
github.com/kaptinlin/messageformat-go v0.4.9 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/api v0.239.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect
|
||||
google.golang.org/grpc v1.76.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
185
internal/fantasy/go.sum
Normal file
185
internal/fantasy/go.sum
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
charm.land/x/vcr v0.1.1 h1:PXCFMUG0rPtyk35rhfzYCJEduOzWXCIbrXTFq4OF/9Q=
|
||||
charm.land/x/vcr v0.1.1/go.mod h1:eByq2gqzWvcct/8XE2XO5KznoWEBiXH56+y2gphbltM=
|
||||
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
|
||||
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
|
||||
cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs=
|
||||
cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
|
||||
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
||||
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
|
||||
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 h1:DTSZxdV9qQagD4iGcAt9RgaRBZtJl01bfKgdLzUzUPI=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
|
||||
github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
|
||||
github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
|
||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
|
||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/kaptinlin/go-i18n v0.2.3 h1:jyN/YOXXLcnGRBLdU+a8+6782B97fWE5aQqAHtvvk8Q=
|
||||
github.com/kaptinlin/go-i18n v0.2.3/go.mod h1:O+Ax4HkMO0Jt4OaP4E4WCx0PAADeWkwk8Jgt9bjAU1w=
|
||||
github.com/kaptinlin/jsonpointer v0.4.9 h1:o//bYf4PCvnMJIIX8bIg77KB6DO3wBPAabRyPRKh680=
|
||||
github.com/kaptinlin/jsonpointer v0.4.9/go.mod h1:9y0LgXavlmVE5FSHShY5LRlURJJVhbyVJSRWkilrTqA=
|
||||
github.com/kaptinlin/jsonschema v0.6.10 h1:CYded7nrwVu7pU1GaIjtd9dSzgqZjh7+LTKFaWqS08I=
|
||||
github.com/kaptinlin/jsonschema v0.6.10/go.mod h1:ZXZ4K5KrRmCCF1i6dgvBsQifl+WTb8XShKj0NpQNrz8=
|
||||
github.com/kaptinlin/messageformat-go v0.4.9 h1:FR5j5n4aL4nG0afKn9vvANrKxLu7HjmbhJnw5ogIwAQ=
|
||||
github.com/kaptinlin/messageformat-go v0.4.9/go.mod h1:qZzrGrlvWDz2KyyvN3dOWcK9PVSRV1BnfnNU+zB/RWc=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/openai/openai-go/v2 v2.7.1 h1:/tfvTJhfv7hTSL8mWwc5VL4WLLSDL5yn9VqVykdu9r8=
|
||||
github.com/openai/openai-go/v2 v2.7.1/go.mod h1:jrJs23apqJKKbT+pqtFgNKpRju/KP9zpUTZhz3GElQE=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.239.0 h1:2hZKUnFZEy81eugPs4e2XzIJ5SOwQg0G82bpXD65Puo=
|
||||
google.golang.org/api v0.239.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
|
||||
google.golang.org/genai v1.45.0 h1:s80ZpS42XW0zu/ogiOtenCio17nJ7reEFJjoCftukpA=
|
||||
google.golang.org/genai v1.45.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 h1:g3ah7zaWmw41EtOgBNXpx8zk4HYuH3OMwB+qh1Dt834=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290/go.mod h1:sbq5oMEcM4PXngbcNbHhzfCP9OdZodLhrbRYoyg09HY=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
752
internal/fantasy/json_test.go
Normal file
752
internal/fantasy/json_test.go
Normal file
|
|
@ -0,0 +1,752 @@
|
|||
package fantasy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMessageJSONSerialization(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
message Message
|
||||
}{
|
||||
{
|
||||
name: "simple text message",
|
||||
message: Message{
|
||||
Role: MessageRoleUser,
|
||||
Content: []MessagePart{
|
||||
TextPart{Text: "Hello, world!"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "message with multiple text parts",
|
||||
message: Message{
|
||||
Role: MessageRoleAssistant,
|
||||
Content: []MessagePart{
|
||||
TextPart{Text: "First part"},
|
||||
TextPart{Text: "Second part"},
|
||||
TextPart{Text: "Third part"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "message with reasoning part",
|
||||
message: Message{
|
||||
Role: MessageRoleAssistant,
|
||||
Content: []MessagePart{
|
||||
ReasoningPart{Text: "Let me think about this..."},
|
||||
TextPart{Text: "Here's my answer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "message with file part",
|
||||
message: Message{
|
||||
Role: MessageRoleUser,
|
||||
Content: []MessagePart{
|
||||
TextPart{Text: "Here's an image:"},
|
||||
FilePart{
|
||||
Filename: "test.png",
|
||||
Data: []byte{0x89, 0x50, 0x4E, 0x47}, // PNG header
|
||||
MediaType: "image/png",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "message with tool call",
|
||||
message: Message{
|
||||
Role: MessageRoleAssistant,
|
||||
Content: []MessagePart{
|
||||
ToolCallPart{
|
||||
ToolCallID: "call_123",
|
||||
ToolName: "get_weather",
|
||||
Input: `{"location": "San Francisco"}`,
|
||||
ProviderExecuted: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "message with tool result - text output",
|
||||
message: Message{
|
||||
Role: MessageRoleTool,
|
||||
Content: []MessagePart{
|
||||
ToolResultPart{
|
||||
ToolCallID: "call_123",
|
||||
Output: ToolResultOutputContentText{
|
||||
Text: "The weather is sunny, 72°F",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "message with tool result - error output",
|
||||
message: Message{
|
||||
Role: MessageRoleTool,
|
||||
Content: []MessagePart{
|
||||
ToolResultPart{
|
||||
ToolCallID: "call_456",
|
||||
Output: ToolResultOutputContentError{
|
||||
Error: errors.New("API rate limit exceeded"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "message with tool result - media output",
|
||||
message: Message{
|
||||
Role: MessageRoleTool,
|
||||
Content: []MessagePart{
|
||||
ToolResultPart{
|
||||
ToolCallID: "call_789",
|
||||
Output: ToolResultOutputContentMedia{
|
||||
Data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
MediaType: "image/png",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "complex message with mixed content",
|
||||
message: Message{
|
||||
Role: MessageRoleAssistant,
|
||||
Content: []MessagePart{
|
||||
TextPart{Text: "I'll analyze this image and call some tools."},
|
||||
ReasoningPart{Text: "First, I need to identify the objects..."},
|
||||
ToolCallPart{
|
||||
ToolCallID: "call_001",
|
||||
ToolName: "analyze_image",
|
||||
Input: `{"image_id": "img_123"}`,
|
||||
ProviderExecuted: false,
|
||||
},
|
||||
ToolCallPart{
|
||||
ToolCallID: "call_002",
|
||||
ToolName: "get_context",
|
||||
Input: `{"query": "similar images"}`,
|
||||
ProviderExecuted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "system message",
|
||||
message: Message{
|
||||
Role: MessageRoleSystem,
|
||||
Content: []MessagePart{
|
||||
TextPart{Text: "You are a helpful assistant."},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty content",
|
||||
message: Message{
|
||||
Role: MessageRoleUser,
|
||||
Content: []MessagePart{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Marshal the message
|
||||
data, err := json.Marshal(tt.message)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal message: %v", err)
|
||||
}
|
||||
|
||||
// Unmarshal back
|
||||
var decoded Message
|
||||
err = json.Unmarshal(data, &decoded)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to unmarshal message: %v", err)
|
||||
}
|
||||
|
||||
// Compare roles
|
||||
if decoded.Role != tt.message.Role {
|
||||
t.Errorf("role mismatch: got %v, want %v", decoded.Role, tt.message.Role)
|
||||
}
|
||||
|
||||
// Compare content length
|
||||
if len(decoded.Content) != len(tt.message.Content) {
|
||||
t.Fatalf("content length mismatch: got %d, want %d", len(decoded.Content), len(tt.message.Content))
|
||||
}
|
||||
|
||||
// Compare each content part
|
||||
for i := range tt.message.Content {
|
||||
original := tt.message.Content[i]
|
||||
decodedPart := decoded.Content[i]
|
||||
|
||||
if original.GetType() != decodedPart.GetType() {
|
||||
t.Errorf("content[%d] type mismatch: got %v, want %v", i, decodedPart.GetType(), original.GetType())
|
||||
continue
|
||||
}
|
||||
|
||||
compareMessagePart(t, i, original, decodedPart)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func compareMessagePart(t *testing.T, index int, original, decoded MessagePart) {
|
||||
switch original.GetType() {
|
||||
case ContentTypeText:
|
||||
orig := original.(TextPart)
|
||||
dec := decoded.(TextPart)
|
||||
if orig.Text != dec.Text {
|
||||
t.Errorf("content[%d] text mismatch: got %q, want %q", index, dec.Text, orig.Text)
|
||||
}
|
||||
|
||||
case ContentTypeReasoning:
|
||||
orig := original.(ReasoningPart)
|
||||
dec := decoded.(ReasoningPart)
|
||||
if orig.Text != dec.Text {
|
||||
t.Errorf("content[%d] reasoning text mismatch: got %q, want %q", index, dec.Text, orig.Text)
|
||||
}
|
||||
|
||||
case ContentTypeFile:
|
||||
orig := original.(FilePart)
|
||||
dec := decoded.(FilePart)
|
||||
if orig.Filename != dec.Filename {
|
||||
t.Errorf("content[%d] filename mismatch: got %q, want %q", index, dec.Filename, orig.Filename)
|
||||
}
|
||||
if orig.MediaType != dec.MediaType {
|
||||
t.Errorf("content[%d] media type mismatch: got %q, want %q", index, dec.MediaType, orig.MediaType)
|
||||
}
|
||||
if !reflect.DeepEqual(orig.Data, dec.Data) {
|
||||
t.Errorf("content[%d] file data mismatch", index)
|
||||
}
|
||||
|
||||
case ContentTypeToolCall:
|
||||
orig := original.(ToolCallPart)
|
||||
dec := decoded.(ToolCallPart)
|
||||
if orig.ToolCallID != dec.ToolCallID {
|
||||
t.Errorf("content[%d] tool call id mismatch: got %q, want %q", index, dec.ToolCallID, orig.ToolCallID)
|
||||
}
|
||||
if orig.ToolName != dec.ToolName {
|
||||
t.Errorf("content[%d] tool name mismatch: got %q, want %q", index, dec.ToolName, orig.ToolName)
|
||||
}
|
||||
if orig.Input != dec.Input {
|
||||
t.Errorf("content[%d] tool input mismatch: got %q, want %q", index, dec.Input, orig.Input)
|
||||
}
|
||||
if orig.ProviderExecuted != dec.ProviderExecuted {
|
||||
t.Errorf("content[%d] provider executed mismatch: got %v, want %v", index, dec.ProviderExecuted, orig.ProviderExecuted)
|
||||
}
|
||||
|
||||
case ContentTypeToolResult:
|
||||
orig := original.(ToolResultPart)
|
||||
dec := decoded.(ToolResultPart)
|
||||
if orig.ToolCallID != dec.ToolCallID {
|
||||
t.Errorf("content[%d] tool result call id mismatch: got %q, want %q", index, dec.ToolCallID, orig.ToolCallID)
|
||||
}
|
||||
compareToolResultOutput(t, index, orig.Output, dec.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func compareToolResultOutput(t *testing.T, index int, original, decoded ToolResultOutputContent) {
|
||||
if original.GetType() != decoded.GetType() {
|
||||
t.Errorf("content[%d] tool result output type mismatch: got %v, want %v", index, decoded.GetType(), original.GetType())
|
||||
return
|
||||
}
|
||||
|
||||
switch original.GetType() {
|
||||
case ToolResultContentTypeText:
|
||||
orig := original.(ToolResultOutputContentText)
|
||||
dec := decoded.(ToolResultOutputContentText)
|
||||
if orig.Text != dec.Text {
|
||||
t.Errorf("content[%d] tool result text mismatch: got %q, want %q", index, dec.Text, orig.Text)
|
||||
}
|
||||
|
||||
case ToolResultContentTypeError:
|
||||
orig := original.(ToolResultOutputContentError)
|
||||
dec := decoded.(ToolResultOutputContentError)
|
||||
if orig.Error.Error() != dec.Error.Error() {
|
||||
t.Errorf("content[%d] tool result error mismatch: got %q, want %q", index, dec.Error.Error(), orig.Error.Error())
|
||||
}
|
||||
|
||||
case ToolResultContentTypeMedia:
|
||||
orig := original.(ToolResultOutputContentMedia)
|
||||
dec := decoded.(ToolResultOutputContentMedia)
|
||||
if orig.Data != dec.Data {
|
||||
t.Errorf("content[%d] tool result media data mismatch", index)
|
||||
}
|
||||
if orig.MediaType != dec.MediaType {
|
||||
t.Errorf("content[%d] tool result media type mismatch: got %q, want %q", index, dec.MediaType, orig.MediaType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelperFunctions(t *testing.T) {
|
||||
t.Run("NewUserMessage - text only", func(t *testing.T) {
|
||||
msg := NewUserMessage("Hello")
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
var decoded Message
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if decoded.Role != MessageRoleUser {
|
||||
t.Errorf("role mismatch: got %v, want %v", decoded.Role, MessageRoleUser)
|
||||
}
|
||||
|
||||
if len(decoded.Content) != 1 {
|
||||
t.Fatalf("expected 1 content part, got %d", len(decoded.Content))
|
||||
}
|
||||
|
||||
textPart := decoded.Content[0].(TextPart)
|
||||
if textPart.Text != "Hello" {
|
||||
t.Errorf("text mismatch: got %q, want %q", textPart.Text, "Hello")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NewUserMessage - with files", func(t *testing.T) {
|
||||
msg := NewUserMessage("Check this image",
|
||||
FilePart{
|
||||
Filename: "image1.jpg",
|
||||
Data: []byte{0xFF, 0xD8, 0xFF},
|
||||
MediaType: "image/jpeg",
|
||||
},
|
||||
FilePart{
|
||||
Filename: "image2.png",
|
||||
Data: []byte{0x89, 0x50, 0x4E, 0x47},
|
||||
MediaType: "image/png",
|
||||
},
|
||||
)
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
var decoded Message
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if len(decoded.Content) != 3 {
|
||||
t.Fatalf("expected 3 content parts, got %d", len(decoded.Content))
|
||||
}
|
||||
|
||||
// Check text part
|
||||
textPart := decoded.Content[0].(TextPart)
|
||||
if textPart.Text != "Check this image" {
|
||||
t.Errorf("text mismatch: got %q, want %q", textPart.Text, "Check this image")
|
||||
}
|
||||
|
||||
// Check first file
|
||||
file1 := decoded.Content[1].(FilePart)
|
||||
if file1.Filename != "image1.jpg" {
|
||||
t.Errorf("file1 name mismatch: got %q, want %q", file1.Filename, "image1.jpg")
|
||||
}
|
||||
|
||||
// Check second file
|
||||
file2 := decoded.Content[2].(FilePart)
|
||||
if file2.Filename != "image2.png" {
|
||||
t.Errorf("file2 name mismatch: got %q, want %q", file2.Filename, "image2.png")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NewSystemMessage - single prompt", func(t *testing.T) {
|
||||
msg := NewSystemMessage("You are a helpful assistant.")
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
var decoded Message
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if decoded.Role != MessageRoleSystem {
|
||||
t.Errorf("role mismatch: got %v, want %v", decoded.Role, MessageRoleSystem)
|
||||
}
|
||||
|
||||
if len(decoded.Content) != 1 {
|
||||
t.Fatalf("expected 1 content part, got %d", len(decoded.Content))
|
||||
}
|
||||
|
||||
textPart := decoded.Content[0].(TextPart)
|
||||
if textPart.Text != "You are a helpful assistant." {
|
||||
t.Errorf("text mismatch: got %q, want %q", textPart.Text, "You are a helpful assistant.")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NewSystemMessage - multiple prompts", func(t *testing.T) {
|
||||
msg := NewSystemMessage("First instruction", "Second instruction", "Third instruction")
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
var decoded Message
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if len(decoded.Content) != 3 {
|
||||
t.Fatalf("expected 3 content parts, got %d", len(decoded.Content))
|
||||
}
|
||||
|
||||
expected := []string{"First instruction", "Second instruction", "Third instruction"}
|
||||
for i, exp := range expected {
|
||||
textPart := decoded.Content[i].(TextPart)
|
||||
if textPart.Text != exp {
|
||||
t.Errorf("content[%d] text mismatch: got %q, want %q", i, textPart.Text, exp)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestEdgeCases(t *testing.T) {
|
||||
t.Run("empty text part", func(t *testing.T) {
|
||||
msg := Message{
|
||||
Role: MessageRoleUser,
|
||||
Content: []MessagePart{
|
||||
TextPart{Text: ""},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
var decoded Message
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
textPart := decoded.Content[0].(TextPart)
|
||||
if textPart.Text != "" {
|
||||
t.Errorf("expected empty text, got %q", textPart.Text)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil error in tool result", func(t *testing.T) {
|
||||
msg := Message{
|
||||
Role: MessageRoleTool,
|
||||
Content: []MessagePart{
|
||||
ToolResultPart{
|
||||
ToolCallID: "call_123",
|
||||
Output: ToolResultOutputContentError{
|
||||
Error: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
var decoded Message
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
toolResult := decoded.Content[0].(ToolResultPart)
|
||||
errorOutput := toolResult.Output.(ToolResultOutputContentError)
|
||||
if errorOutput.Error != nil {
|
||||
t.Errorf("expected nil error, got %v", errorOutput.Error)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty file data", func(t *testing.T) {
|
||||
msg := Message{
|
||||
Role: MessageRoleUser,
|
||||
Content: []MessagePart{
|
||||
FilePart{
|
||||
Filename: "empty.txt",
|
||||
Data: []byte{},
|
||||
MediaType: "text/plain",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
var decoded Message
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
filePart := decoded.Content[0].(FilePart)
|
||||
if len(filePart.Data) != 0 {
|
||||
t.Errorf("expected empty data, got %d bytes", len(filePart.Data))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unicode in text", func(t *testing.T) {
|
||||
msg := Message{
|
||||
Role: MessageRoleUser,
|
||||
Content: []MessagePart{
|
||||
TextPart{Text: "Hello 世界! 🌍 Привет"},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
var decoded Message
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
textPart := decoded.Content[0].(TextPart)
|
||||
if textPart.Text != "Hello 世界! 🌍 Привет" {
|
||||
t.Errorf("unicode text mismatch: got %q, want %q", textPart.Text, "Hello 世界! 🌍 Привет")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestInvalidJSONHandling(t *testing.T) {
|
||||
t.Run("unknown message part type", func(t *testing.T) {
|
||||
invalidJSON := `{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "unknown-type",
|
||||
"data": {}
|
||||
}
|
||||
],
|
||||
"provider_options": null
|
||||
}`
|
||||
|
||||
var msg Message
|
||||
err := json.Unmarshal([]byte(invalidJSON), &msg)
|
||||
if err == nil {
|
||||
t.Error("expected error for unknown message part type, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown tool result output type", func(t *testing.T) {
|
||||
invalidJSON := `{
|
||||
"role": "tool",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool-result",
|
||||
"data": {
|
||||
"tool_call_id": "call_123",
|
||||
"output": {
|
||||
"type": "unknown-output-type",
|
||||
"data": {}
|
||||
},
|
||||
"provider_options": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"provider_options": null
|
||||
}`
|
||||
|
||||
var msg Message
|
||||
err := json.Unmarshal([]byte(invalidJSON), &msg)
|
||||
if err == nil {
|
||||
t.Error("expected error for unknown tool result output type, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed JSON", func(t *testing.T) {
|
||||
invalidJSON := `{"role": "user", "content": [`
|
||||
|
||||
var msg Message
|
||||
err := json.Unmarshal([]byte(invalidJSON), &msg)
|
||||
if err == nil {
|
||||
t.Error("expected error for malformed JSON, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Mock provider data for testing provider options
|
||||
type mockProviderData struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
func (m mockProviderData) Options() {}
|
||||
func (m mockProviderData) Type() string { return "mock" }
|
||||
func (m mockProviderData) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
mockProviderData
|
||||
}{
|
||||
Type: "mock",
|
||||
mockProviderData: m,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *mockProviderData) UnmarshalJSON(data []byte) error {
|
||||
var aux struct {
|
||||
Type string `json:"type"`
|
||||
mockProviderData
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
*m = aux.mockProviderData
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestPromptSerialization(t *testing.T) {
|
||||
t.Run("serialize prompt (message slice)", func(t *testing.T) {
|
||||
prompt := Prompt{
|
||||
NewSystemMessage("You are helpful"),
|
||||
NewUserMessage("Hello"),
|
||||
Message{
|
||||
Role: MessageRoleAssistant,
|
||||
Content: []MessagePart{
|
||||
TextPart{Text: "Hi there!"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(prompt)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal prompt: %v", err)
|
||||
}
|
||||
|
||||
var decoded Prompt
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("failed to unmarshal prompt: %v", err)
|
||||
}
|
||||
|
||||
if len(decoded) != 3 {
|
||||
t.Fatalf("expected 3 messages, got %d", len(decoded))
|
||||
}
|
||||
|
||||
if decoded[0].Role != MessageRoleSystem {
|
||||
t.Errorf("message 0 role mismatch: got %v, want %v", decoded[0].Role, MessageRoleSystem)
|
||||
}
|
||||
|
||||
if decoded[1].Role != MessageRoleUser {
|
||||
t.Errorf("message 1 role mismatch: got %v, want %v", decoded[1].Role, MessageRoleUser)
|
||||
}
|
||||
|
||||
if decoded[2].Role != MessageRoleAssistant {
|
||||
t.Errorf("message 2 role mismatch: got %v, want %v", decoded[2].Role, MessageRoleAssistant)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStreamPartErrorSerialization(t *testing.T) {
|
||||
t.Run("stream part with ProviderError containing OpenAI API error", func(t *testing.T) {
|
||||
// Create a mock OpenAI API error
|
||||
openaiErr := errors.New("invalid_api_key: Incorrect API key provided")
|
||||
|
||||
// Wrap in ProviderError
|
||||
providerErr := &ProviderError{
|
||||
Title: "unauthorized",
|
||||
Message: "Incorrect API key provided",
|
||||
Cause: openaiErr,
|
||||
URL: "https://api.openai.com/v1/chat/completions",
|
||||
StatusCode: 401,
|
||||
RequestBody: []byte(`{"model":"gpt-4","messages":[]}`),
|
||||
ResponseHeaders: map[string]string{
|
||||
"content-type": "application/json",
|
||||
},
|
||||
ResponseBody: []byte(`{"error":{"message":"Incorrect API key provided","type":"invalid_request_error"}}`),
|
||||
}
|
||||
|
||||
// Create StreamPart with error
|
||||
streamPart := StreamPart{
|
||||
Type: StreamPartTypeError,
|
||||
Error: providerErr,
|
||||
}
|
||||
|
||||
// Marshal the stream part
|
||||
data, err := json.Marshal(streamPart)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal stream part: %v", err)
|
||||
}
|
||||
|
||||
// Unmarshal back
|
||||
var decoded StreamPart
|
||||
err = json.Unmarshal(data, &decoded)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to unmarshal stream part: %v", err)
|
||||
}
|
||||
|
||||
// Verify the stream part type
|
||||
if decoded.Type != StreamPartTypeError {
|
||||
t.Errorf("type mismatch: got %v, want %v", decoded.Type, StreamPartTypeError)
|
||||
}
|
||||
|
||||
// Verify error exists
|
||||
if decoded.Error == nil {
|
||||
t.Fatal("expected error to be present, got nil")
|
||||
}
|
||||
|
||||
// Verify error message
|
||||
expectedMsg := "unauthorized: Incorrect API key provided"
|
||||
if decoded.Error.Error() != expectedMsg {
|
||||
t.Errorf("error message mismatch: got %q, want %q", decoded.Error.Error(), expectedMsg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unmarshal stream part with error from JSON", func(t *testing.T) {
|
||||
// JSON representing a StreamPart with an error
|
||||
jsonData := `{
|
||||
"type": "error",
|
||||
"error": "unauthorized: Incorrect API key provided",
|
||||
"id": "",
|
||||
"tool_call_name": "",
|
||||
"tool_call_input": "",
|
||||
"delta": "",
|
||||
"provider_executed": false,
|
||||
"usage": {
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
"cache_creation_tokens": 0,
|
||||
"cache_read_tokens": 0
|
||||
},
|
||||
"finish_reason": "",
|
||||
"warnings": null,
|
||||
"source_type": "",
|
||||
"url": "",
|
||||
"title": "",
|
||||
"provider_metadata": null
|
||||
}`
|
||||
|
||||
var streamPart StreamPart
|
||||
err := json.Unmarshal([]byte(jsonData), &streamPart)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to unmarshal stream part: %v", err)
|
||||
}
|
||||
|
||||
// Verify the stream part type
|
||||
if streamPart.Type != StreamPartTypeError {
|
||||
t.Errorf("type mismatch: got %v, want %v", streamPart.Type, StreamPartTypeError)
|
||||
}
|
||||
|
||||
// Verify error exists
|
||||
if streamPart.Error == nil {
|
||||
t.Fatal("expected error to be present, got nil")
|
||||
}
|
||||
|
||||
// Verify error message
|
||||
expectedMsg := "unauthorized: Incorrect API key provided"
|
||||
if streamPart.Error.Error() != expectedMsg {
|
||||
t.Errorf("error message mismatch: got %q, want %q", streamPart.Error.Error(), expectedMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
1648
internal/fantasy/jsonrepair/jsonrepair.go
Normal file
1648
internal/fantasy/jsonrepair/jsonrepair.go
Normal file
File diff suppressed because it is too large
Load diff
1615
internal/fantasy/jsonrepair/jsonrepair_test.go
Normal file
1615
internal/fantasy/jsonrepair/jsonrepair_test.go
Normal file
File diff suppressed because it is too large
Load diff
258
internal/fantasy/model.go
Normal file
258
internal/fantasy/model.go
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
package fantasy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"iter"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Usage represents token usage statistics for a model call.
|
||||
type Usage struct {
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
ReasoningTokens int64 `json:"reasoning_tokens"`
|
||||
CacheCreationTokens int64 `json:"cache_creation_tokens"`
|
||||
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||||
}
|
||||
|
||||
func (u Usage) String() string {
|
||||
return fmt.Sprintf("Usage{Input: %d, Output: %d, Total: %d, Reasoning: %d, CacheCreation: %d, CacheRead: %d}",
|
||||
u.InputTokens,
|
||||
u.OutputTokens,
|
||||
u.TotalTokens,
|
||||
u.ReasoningTokens,
|
||||
u.CacheCreationTokens,
|
||||
u.CacheReadTokens,
|
||||
)
|
||||
}
|
||||
|
||||
// ResponseContent represents the content of a model response.
|
||||
type ResponseContent []Content
|
||||
|
||||
// Text returns the text content of the response.
|
||||
func (r ResponseContent) Text() string {
|
||||
for _, c := range r {
|
||||
if c.GetType() == ContentTypeText {
|
||||
if textContent, ok := AsContentType[TextContent](c); ok {
|
||||
return textContent.Text
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Reasoning returns all reasoning content parts.
|
||||
func (r ResponseContent) Reasoning() []ReasoningContent {
|
||||
var reasoning []ReasoningContent
|
||||
for _, c := range r {
|
||||
if c.GetType() == ContentTypeReasoning {
|
||||
if reasoningContent, ok := AsContentType[ReasoningContent](c); ok {
|
||||
reasoning = append(reasoning, reasoningContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
return reasoning
|
||||
}
|
||||
|
||||
// ReasoningText returns all reasoning content as a concatenated string.
|
||||
func (r ResponseContent) ReasoningText() string {
|
||||
var builder strings.Builder
|
||||
for _, reasoning := range r.Reasoning() {
|
||||
builder.WriteString(reasoning.Text)
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// Files returns all file content parts.
|
||||
func (r ResponseContent) Files() []FileContent {
|
||||
var files []FileContent
|
||||
for _, c := range r {
|
||||
if c.GetType() == ContentTypeFile {
|
||||
if fileContent, ok := AsContentType[FileContent](c); ok {
|
||||
files = append(files, fileContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// Sources returns all source content parts.
|
||||
func (r ResponseContent) Sources() []SourceContent {
|
||||
var sources []SourceContent
|
||||
for _, c := range r {
|
||||
if c.GetType() == ContentTypeSource {
|
||||
if sourceContent, ok := AsContentType[SourceContent](c); ok {
|
||||
sources = append(sources, sourceContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
// ToolCalls returns all tool call content parts.
|
||||
func (r ResponseContent) ToolCalls() []ToolCallContent {
|
||||
var toolCalls []ToolCallContent
|
||||
for _, c := range r {
|
||||
if c.GetType() == ContentTypeToolCall {
|
||||
if toolCallContent, ok := AsContentType[ToolCallContent](c); ok {
|
||||
toolCalls = append(toolCalls, toolCallContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
return toolCalls
|
||||
}
|
||||
|
||||
// ToolResults returns all tool result content parts.
|
||||
func (r ResponseContent) ToolResults() []ToolResultContent {
|
||||
var toolResults []ToolResultContent
|
||||
for _, c := range r {
|
||||
if c.GetType() == ContentTypeToolResult {
|
||||
if toolResultContent, ok := AsContentType[ToolResultContent](c); ok {
|
||||
toolResults = append(toolResults, toolResultContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
return toolResults
|
||||
}
|
||||
|
||||
// Response represents a response from a language model.
|
||||
type Response struct {
|
||||
Content ResponseContent `json:"content"`
|
||||
FinishReason FinishReason `json:"finish_reason"`
|
||||
Usage Usage `json:"usage"`
|
||||
Warnings []CallWarning `json:"warnings"`
|
||||
|
||||
// for provider specific response metadata, the key is the provider id
|
||||
ProviderMetadata ProviderMetadata `json:"provider_metadata"`
|
||||
}
|
||||
|
||||
// StreamPartType represents the type of a stream part.
|
||||
type StreamPartType string
|
||||
|
||||
const (
|
||||
// StreamPartTypeWarnings represents warnings stream part type.
|
||||
StreamPartTypeWarnings StreamPartType = "warnings"
|
||||
// StreamPartTypeTextStart represents text start stream part type.
|
||||
StreamPartTypeTextStart StreamPartType = "text_start"
|
||||
// StreamPartTypeTextDelta represents text delta stream part type.
|
||||
StreamPartTypeTextDelta StreamPartType = "text_delta"
|
||||
// StreamPartTypeTextEnd represents text end stream part type.
|
||||
StreamPartTypeTextEnd StreamPartType = "text_end"
|
||||
|
||||
// StreamPartTypeReasoningStart represents reasoning start stream part type.
|
||||
StreamPartTypeReasoningStart StreamPartType = "reasoning_start"
|
||||
// StreamPartTypeReasoningDelta represents reasoning delta stream part type.
|
||||
StreamPartTypeReasoningDelta StreamPartType = "reasoning_delta"
|
||||
// StreamPartTypeReasoningEnd represents reasoning end stream part type.
|
||||
StreamPartTypeReasoningEnd StreamPartType = "reasoning_end"
|
||||
// StreamPartTypeToolInputStart represents tool input start stream part type.
|
||||
StreamPartTypeToolInputStart StreamPartType = "tool_input_start"
|
||||
// StreamPartTypeToolInputDelta represents tool input delta stream part type.
|
||||
StreamPartTypeToolInputDelta StreamPartType = "tool_input_delta"
|
||||
// StreamPartTypeToolInputEnd represents tool input end stream part type.
|
||||
StreamPartTypeToolInputEnd StreamPartType = "tool_input_end"
|
||||
// StreamPartTypeToolCall represents tool call stream part type.
|
||||
StreamPartTypeToolCall StreamPartType = "tool_call"
|
||||
// StreamPartTypeToolResult represents tool result stream part type.
|
||||
StreamPartTypeToolResult StreamPartType = "tool_result"
|
||||
// StreamPartTypeSource represents source stream part type.
|
||||
StreamPartTypeSource StreamPartType = "source"
|
||||
// StreamPartTypeFinish represents finish stream part type.
|
||||
StreamPartTypeFinish StreamPartType = "finish"
|
||||
// StreamPartTypeError represents error stream part type.
|
||||
StreamPartTypeError StreamPartType = "error"
|
||||
)
|
||||
|
||||
// StreamPart represents a part of a streaming response.
|
||||
type StreamPart struct {
|
||||
Type StreamPartType `json:"type"`
|
||||
ID string `json:"id"`
|
||||
ToolCallName string `json:"tool_call_name"`
|
||||
ToolCallInput string `json:"tool_call_input"`
|
||||
Delta string `json:"delta"`
|
||||
ProviderExecuted bool `json:"provider_executed"`
|
||||
Usage Usage `json:"usage"`
|
||||
FinishReason FinishReason `json:"finish_reason"`
|
||||
Error error `json:"error"`
|
||||
Warnings []CallWarning `json:"warnings"`
|
||||
|
||||
// Source-related fields
|
||||
SourceType SourceType `json:"source_type"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
|
||||
ProviderMetadata ProviderMetadata `json:"provider_metadata"`
|
||||
}
|
||||
|
||||
// StreamResponse represents a streaming response sequence.
|
||||
type StreamResponse = iter.Seq[StreamPart]
|
||||
|
||||
// ToolChoice represents the tool choice preference for a model call.
|
||||
type ToolChoice string
|
||||
|
||||
const (
|
||||
// ToolChoiceNone indicates no tools should be used.
|
||||
ToolChoiceNone ToolChoice = "none"
|
||||
// ToolChoiceAuto indicates tools should be used automatically.
|
||||
ToolChoiceAuto ToolChoice = "auto"
|
||||
// ToolChoiceRequired indicates tools are required.
|
||||
ToolChoiceRequired ToolChoice = "required"
|
||||
)
|
||||
|
||||
// SpecificToolChoice creates a tool choice for a specific tool name.
|
||||
func SpecificToolChoice(name string) ToolChoice {
|
||||
return ToolChoice(name)
|
||||
}
|
||||
|
||||
// Call represents a call to a language model.
|
||||
type Call struct {
|
||||
Prompt Prompt `json:"prompt"`
|
||||
MaxOutputTokens *int64 `json:"max_output_tokens"`
|
||||
Temperature *float64 `json:"temperature"`
|
||||
TopP *float64 `json:"top_p"`
|
||||
TopK *int64 `json:"top_k"`
|
||||
PresencePenalty *float64 `json:"presence_penalty"`
|
||||
FrequencyPenalty *float64 `json:"frequency_penalty"`
|
||||
Tools []Tool `json:"tools"`
|
||||
ToolChoice *ToolChoice `json:"tool_choice"`
|
||||
|
||||
// for provider specific options, the key is the provider id
|
||||
ProviderOptions ProviderOptions `json:"provider_options"`
|
||||
}
|
||||
|
||||
// CallWarningType represents the type of call warning.
|
||||
type CallWarningType string
|
||||
|
||||
const (
|
||||
// CallWarningTypeUnsupportedSetting indicates an unsupported setting.
|
||||
CallWarningTypeUnsupportedSetting CallWarningType = "unsupported-setting"
|
||||
// CallWarningTypeUnsupportedTool indicates an unsupported tool.
|
||||
CallWarningTypeUnsupportedTool CallWarningType = "unsupported-tool"
|
||||
// CallWarningTypeOther indicates other warnings.
|
||||
CallWarningTypeOther CallWarningType = "other"
|
||||
)
|
||||
|
||||
// CallWarning represents a warning from the model provider for this call.
|
||||
// The call will proceed, but e.g. some settings might not be supported,
|
||||
// which can lead to suboptimal results.
|
||||
type CallWarning struct {
|
||||
Type CallWarningType `json:"type"`
|
||||
Setting string `json:"setting"`
|
||||
Tool Tool `json:"tool"`
|
||||
Details string `json:"details"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// LanguageModel represents a language model that can generate responses and stream responses.
|
||||
type LanguageModel interface {
|
||||
Generate(context.Context, Call) (*Response, error)
|
||||
Stream(context.Context, Call) (StreamResponse, error)
|
||||
|
||||
GenerateObject(context.Context, ObjectCall) (*ObjectResponse, error)
|
||||
StreamObject(context.Context, ObjectCall) (ObjectStreamResponse, error)
|
||||
|
||||
Provider() string
|
||||
Model() string
|
||||
}
|
||||
156
internal/fantasy/model_json.go
Normal file
156
internal/fantasy/model_json.go
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
package fantasy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler for Call.
|
||||
func (c *Call) UnmarshalJSON(data []byte) error {
|
||||
var aux struct {
|
||||
Prompt Prompt `json:"prompt"`
|
||||
MaxOutputTokens *int64 `json:"max_output_tokens"`
|
||||
Temperature *float64 `json:"temperature"`
|
||||
TopP *float64 `json:"top_p"`
|
||||
TopK *int64 `json:"top_k"`
|
||||
PresencePenalty *float64 `json:"presence_penalty"`
|
||||
FrequencyPenalty *float64 `json:"frequency_penalty"`
|
||||
Tools []json.RawMessage `json:"tools"`
|
||||
ToolChoice *ToolChoice `json:"tool_choice"`
|
||||
ProviderOptions map[string]json.RawMessage `json:"provider_options"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.Prompt = aux.Prompt
|
||||
c.MaxOutputTokens = aux.MaxOutputTokens
|
||||
c.Temperature = aux.Temperature
|
||||
c.TopP = aux.TopP
|
||||
c.TopK = aux.TopK
|
||||
c.PresencePenalty = aux.PresencePenalty
|
||||
c.FrequencyPenalty = aux.FrequencyPenalty
|
||||
c.ToolChoice = aux.ToolChoice
|
||||
|
||||
// Unmarshal Tools slice
|
||||
c.Tools = make([]Tool, len(aux.Tools))
|
||||
for i, rawTool := range aux.Tools {
|
||||
tool, err := UnmarshalTool(rawTool)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal tool at index %d: %w", i, err)
|
||||
}
|
||||
c.Tools[i] = tool
|
||||
}
|
||||
|
||||
// Unmarshal ProviderOptions
|
||||
if len(aux.ProviderOptions) > 0 {
|
||||
options, err := UnmarshalProviderOptions(aux.ProviderOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.ProviderOptions = options
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler for Response.
|
||||
func (r *Response) UnmarshalJSON(data []byte) error {
|
||||
var aux struct {
|
||||
Content json.RawMessage `json:"content"`
|
||||
FinishReason FinishReason `json:"finish_reason"`
|
||||
Usage Usage `json:"usage"`
|
||||
Warnings []CallWarning `json:"warnings"`
|
||||
ProviderMetadata map[string]json.RawMessage `json:"provider_metadata"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.FinishReason = aux.FinishReason
|
||||
r.Usage = aux.Usage
|
||||
r.Warnings = aux.Warnings
|
||||
|
||||
// Unmarshal ResponseContent (need to know the type definition)
|
||||
// If ResponseContent is []Content:
|
||||
var rawContent []json.RawMessage
|
||||
if err := json.Unmarshal(aux.Content, &rawContent); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
content := make([]Content, len(rawContent))
|
||||
for i, rawItem := range rawContent {
|
||||
item, err := UnmarshalContent(rawItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal content at index %d: %w", i, err)
|
||||
}
|
||||
content[i] = item
|
||||
}
|
||||
r.Content = content
|
||||
|
||||
// Unmarshal ProviderMetadata
|
||||
if len(aux.ProviderMetadata) > 0 {
|
||||
metadata, err := UnmarshalProviderMetadata(aux.ProviderMetadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.ProviderMetadata = metadata
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler for StreamPart.
|
||||
func (s StreamPart) MarshalJSON() ([]byte, error) {
|
||||
type alias StreamPart
|
||||
aux := struct {
|
||||
alias
|
||||
Error string `json:"error,omitempty"`
|
||||
}{
|
||||
alias: (alias)(s),
|
||||
}
|
||||
|
||||
// Marshal error to string
|
||||
if s.Error != nil {
|
||||
aux.Error = s.Error.Error()
|
||||
}
|
||||
|
||||
// Clear the original Error field to avoid duplicate marshaling
|
||||
aux.alias.Error = nil
|
||||
|
||||
return json.Marshal(aux)
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler for StreamPart.
|
||||
func (s *StreamPart) UnmarshalJSON(data []byte) error {
|
||||
type alias StreamPart
|
||||
aux := struct {
|
||||
*alias
|
||||
Error string `json:"error"`
|
||||
ProviderMetadata map[string]json.RawMessage `json:"provider_metadata"`
|
||||
}{
|
||||
alias: (*alias)(s),
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Unmarshal error string back to error type
|
||||
if aux.Error != "" {
|
||||
s.Error = fmt.Errorf("%s", aux.Error)
|
||||
}
|
||||
|
||||
// Unmarshal ProviderMetadata
|
||||
if len(aux.ProviderMetadata) > 0 {
|
||||
metadata, err := UnmarshalProviderMetadata(aux.ProviderMetadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.ProviderMetadata = metadata
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
233
internal/fantasy/object.go
Normal file
233
internal/fantasy/object.go
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
package fantasy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"iter"
|
||||
"reflect"
|
||||
|
||||
"charm.land/fantasy/schema"
|
||||
)
|
||||
|
||||
// ObjectMode specifies how structured output should be generated.
|
||||
type ObjectMode string
|
||||
|
||||
const (
|
||||
// ObjectModeAuto lets the provider choose the best approach.
|
||||
ObjectModeAuto ObjectMode = "auto"
|
||||
|
||||
// ObjectModeJSON forces the use of native JSON mode (if supported).
|
||||
ObjectModeJSON ObjectMode = "json"
|
||||
|
||||
// ObjectModeTool forces the use of tool-based approach.
|
||||
ObjectModeTool ObjectMode = "tool"
|
||||
|
||||
// ObjectModeText uses text generation with schema in prompt (fallback for models without tool/JSON support).
|
||||
ObjectModeText ObjectMode = "text"
|
||||
)
|
||||
|
||||
// ObjectCall represents a request to generate a structured object.
|
||||
type ObjectCall struct {
|
||||
Prompt Prompt
|
||||
Schema Schema
|
||||
SchemaName string
|
||||
SchemaDescription string
|
||||
|
||||
MaxOutputTokens *int64
|
||||
Temperature *float64
|
||||
TopP *float64
|
||||
TopK *int64
|
||||
PresencePenalty *float64
|
||||
FrequencyPenalty *float64
|
||||
|
||||
ProviderOptions ProviderOptions
|
||||
|
||||
RepairText schema.ObjectRepairFunc
|
||||
}
|
||||
|
||||
// ObjectResponse represents the response from a structured object generation.
|
||||
type ObjectResponse struct {
|
||||
Object any
|
||||
RawText string
|
||||
Usage Usage
|
||||
FinishReason FinishReason
|
||||
Warnings []CallWarning
|
||||
ProviderMetadata ProviderMetadata
|
||||
}
|
||||
|
||||
// ObjectStreamPartType indicates the type of stream part.
|
||||
type ObjectStreamPartType string
|
||||
|
||||
const (
|
||||
// ObjectStreamPartTypeObject is emitted when a new partial object is available.
|
||||
ObjectStreamPartTypeObject ObjectStreamPartType = "object"
|
||||
|
||||
// ObjectStreamPartTypeTextDelta is emitted for text deltas (if model generates text).
|
||||
ObjectStreamPartTypeTextDelta ObjectStreamPartType = "text-delta"
|
||||
|
||||
// ObjectStreamPartTypeError is emitted when an error occurs.
|
||||
ObjectStreamPartTypeError ObjectStreamPartType = "error"
|
||||
|
||||
// ObjectStreamPartTypeFinish is emitted when streaming completes.
|
||||
ObjectStreamPartTypeFinish ObjectStreamPartType = "finish"
|
||||
)
|
||||
|
||||
// ObjectStreamPart represents a single chunk in the object stream.
|
||||
type ObjectStreamPart struct {
|
||||
Type ObjectStreamPartType
|
||||
Object any
|
||||
Delta string
|
||||
Error error
|
||||
Usage Usage
|
||||
FinishReason FinishReason
|
||||
Warnings []CallWarning
|
||||
ProviderMetadata ProviderMetadata
|
||||
}
|
||||
|
||||
// ObjectStreamResponse is an iterator over ObjectStreamPart.
|
||||
type ObjectStreamResponse = iter.Seq[ObjectStreamPart]
|
||||
|
||||
// ObjectResult is a typed result wrapper returned by GenerateObject[T].
|
||||
type ObjectResult[T any] struct {
|
||||
Object T
|
||||
RawText string
|
||||
Usage Usage
|
||||
FinishReason FinishReason
|
||||
Warnings []CallWarning
|
||||
ProviderMetadata ProviderMetadata
|
||||
}
|
||||
|
||||
// StreamObjectResult provides typed access to a streaming object generation result.
|
||||
type StreamObjectResult[T any] struct {
|
||||
stream ObjectStreamResponse
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// NewStreamObjectResult creates a typed stream result from an untyped stream.
|
||||
func NewStreamObjectResult[T any](ctx context.Context, stream ObjectStreamResponse) *StreamObjectResult[T] {
|
||||
return &StreamObjectResult[T]{
|
||||
stream: stream,
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
// PartialObjectStream returns an iterator that yields progressively more complete objects.
|
||||
// Only emits when the object actually changes (deduplication).
|
||||
func (s *StreamObjectResult[T]) PartialObjectStream() iter.Seq[T] {
|
||||
return func(yield func(T) bool) {
|
||||
var lastObject T
|
||||
var hasEmitted bool
|
||||
|
||||
for part := range s.stream {
|
||||
if part.Type == ObjectStreamPartTypeObject && part.Object != nil {
|
||||
var current T
|
||||
if err := unmarshalObject(part.Object, ¤t); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !hasEmitted || !reflect.DeepEqual(current, lastObject) {
|
||||
if !yield(current) {
|
||||
return
|
||||
}
|
||||
lastObject = current
|
||||
hasEmitted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TextStream returns an iterator that yields text deltas.
|
||||
// Useful if the model generates explanatory text alongside the object.
|
||||
func (s *StreamObjectResult[T]) TextStream() iter.Seq[string] {
|
||||
return func(yield func(string) bool) {
|
||||
for part := range s.stream {
|
||||
if part.Type == ObjectStreamPartTypeTextDelta && part.Delta != "" {
|
||||
if !yield(part.Delta) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FullStream returns an iterator that yields all stream parts including errors and metadata.
|
||||
func (s *StreamObjectResult[T]) FullStream() iter.Seq[ObjectStreamPart] {
|
||||
return s.stream
|
||||
}
|
||||
|
||||
// Object waits for the stream to complete and returns the final object.
|
||||
// Returns an error if streaming fails or no valid object was generated.
|
||||
func (s *StreamObjectResult[T]) Object() (*ObjectResult[T], error) {
|
||||
var finalObject T
|
||||
var usage Usage
|
||||
var finishReason FinishReason
|
||||
var warnings []CallWarning
|
||||
var providerMetadata ProviderMetadata
|
||||
var rawText string
|
||||
var lastError error
|
||||
hasObject := false
|
||||
|
||||
for part := range s.stream {
|
||||
switch part.Type {
|
||||
case ObjectStreamPartTypeObject:
|
||||
if part.Object != nil {
|
||||
if err := unmarshalObject(part.Object, &finalObject); err == nil {
|
||||
hasObject = true
|
||||
if jsonBytes, err := json.Marshal(part.Object); err == nil {
|
||||
rawText = string(jsonBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case ObjectStreamPartTypeError:
|
||||
lastError = part.Error
|
||||
|
||||
case ObjectStreamPartTypeFinish:
|
||||
usage = part.Usage
|
||||
finishReason = part.FinishReason
|
||||
if len(part.Warnings) > 0 {
|
||||
warnings = part.Warnings
|
||||
}
|
||||
if len(part.ProviderMetadata) > 0 {
|
||||
providerMetadata = part.ProviderMetadata
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lastError != nil {
|
||||
return nil, lastError
|
||||
}
|
||||
|
||||
if !hasObject {
|
||||
return nil, &NoObjectGeneratedError{
|
||||
RawText: rawText,
|
||||
ParseError: fmt.Errorf("no valid object generated in stream"),
|
||||
Usage: usage,
|
||||
FinishReason: finishReason,
|
||||
}
|
||||
}
|
||||
|
||||
return &ObjectResult[T]{
|
||||
Object: finalObject,
|
||||
RawText: rawText,
|
||||
Usage: usage,
|
||||
FinishReason: finishReason,
|
||||
Warnings: warnings,
|
||||
ProviderMetadata: providerMetadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func unmarshalObject(obj any, target any) error {
|
||||
jsonBytes, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal object: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(jsonBytes, target); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal into target type: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
616
internal/fantasy/object/object.go
Normal file
616
internal/fantasy/object/object.go
Normal file
|
|
@ -0,0 +1,616 @@
|
|||
// Package object provides utilities for generating structured objects with automatic schema generation.
|
||||
// It simplifies working with typed structured outputs by handling schema reflection and unmarshaling.
|
||||
package object
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"charm.land/fantasy/schema"
|
||||
)
|
||||
|
||||
// Generate generates a structured object that matches the given type T.
|
||||
// The schema is automatically generated from T using reflection.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// type Recipe struct {
|
||||
// Name string `json:"name"`
|
||||
// Ingredients []string `json:"ingredients"`
|
||||
// }
|
||||
//
|
||||
// result, err := object.Generate[Recipe](ctx, model, fantasy.ObjectCall{
|
||||
// Prompt: fantasy.Prompt{fantasy.NewUserMessage("Generate a lasagna recipe")},
|
||||
// })
|
||||
func Generate[T any](
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
opts fantasy.ObjectCall,
|
||||
) (*fantasy.ObjectResult[T], error) {
|
||||
var zero T
|
||||
s := schema.Generate(reflect.TypeOf(zero))
|
||||
opts.Schema = s
|
||||
|
||||
resp, err := model.GenerateObject(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result T
|
||||
if err := unmarshal(resp.Object, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal to %T: %w", result, err)
|
||||
}
|
||||
|
||||
return &fantasy.ObjectResult[T]{
|
||||
Object: result,
|
||||
RawText: resp.RawText,
|
||||
Usage: resp.Usage,
|
||||
FinishReason: resp.FinishReason,
|
||||
Warnings: resp.Warnings,
|
||||
ProviderMetadata: resp.ProviderMetadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Stream streams a structured object that matches the given type T.
|
||||
// Returns a StreamObjectResult[T] with progressive updates and deduplication.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// stream, err := object.Stream[Recipe](ctx, model, fantasy.ObjectCall{
|
||||
// Prompt: fantasy.Prompt{fantasy.NewUserMessage("Generate a lasagna recipe")},
|
||||
// })
|
||||
//
|
||||
// for partial := range stream.PartialObjectStream() {
|
||||
// fmt.Printf("Progress: %s\n", partial.Name)
|
||||
// }
|
||||
//
|
||||
// result, err := stream.Object() // Wait for final result
|
||||
func Stream[T any](
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
opts fantasy.ObjectCall,
|
||||
) (*fantasy.StreamObjectResult[T], error) {
|
||||
var zero T
|
||||
s := schema.Generate(reflect.TypeOf(zero))
|
||||
opts.Schema = s
|
||||
|
||||
stream, err := model.StreamObject(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return fantasy.NewStreamObjectResult[T](ctx, stream), nil
|
||||
}
|
||||
|
||||
// GenerateWithTool is a helper for providers without native JSON mode.
|
||||
// It converts the schema to a tool definition, forces the model to call it,
|
||||
// and extracts the tool's input as the structured output.
|
||||
func GenerateWithTool(
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
call fantasy.ObjectCall,
|
||||
) (*fantasy.ObjectResponse, error) {
|
||||
toolName := call.SchemaName
|
||||
if toolName == "" {
|
||||
toolName = "generate_object"
|
||||
}
|
||||
|
||||
toolDescription := call.SchemaDescription
|
||||
if toolDescription == "" {
|
||||
toolDescription = "Generate a structured object matching the schema"
|
||||
}
|
||||
|
||||
tool := fantasy.FunctionTool{
|
||||
Name: toolName,
|
||||
Description: toolDescription,
|
||||
InputSchema: schema.ToMap(call.Schema),
|
||||
}
|
||||
|
||||
toolChoice := fantasy.SpecificToolChoice(tool.Name)
|
||||
resp, err := model.Generate(ctx, fantasy.Call{
|
||||
Prompt: call.Prompt,
|
||||
Tools: []fantasy.Tool{tool},
|
||||
ToolChoice: &toolChoice,
|
||||
MaxOutputTokens: call.MaxOutputTokens,
|
||||
Temperature: call.Temperature,
|
||||
TopP: call.TopP,
|
||||
TopK: call.TopK,
|
||||
PresencePenalty: call.PresencePenalty,
|
||||
FrequencyPenalty: call.FrequencyPenalty,
|
||||
ProviderOptions: call.ProviderOptions,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tool-based generation failed: %w", err)
|
||||
}
|
||||
|
||||
toolCalls := resp.Content.ToolCalls()
|
||||
if len(toolCalls) == 0 {
|
||||
return nil, &fantasy.NoObjectGeneratedError{
|
||||
RawText: resp.Content.Text(),
|
||||
ParseError: fmt.Errorf("no tool call generated"),
|
||||
Usage: resp.Usage,
|
||||
FinishReason: resp.FinishReason,
|
||||
}
|
||||
}
|
||||
|
||||
toolCall := toolCalls[0]
|
||||
|
||||
var obj any
|
||||
if call.RepairText != nil {
|
||||
obj, err = schema.ParseAndValidateWithRepair(ctx, toolCall.Input, call.Schema, call.RepairText)
|
||||
} else {
|
||||
obj, err = schema.ParseAndValidate(toolCall.Input, call.Schema)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if nogErr, ok := err.(*fantasy.NoObjectGeneratedError); ok {
|
||||
nogErr.Usage = resp.Usage
|
||||
nogErr.FinishReason = resp.FinishReason
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &fantasy.ObjectResponse{
|
||||
Object: obj,
|
||||
RawText: toolCall.Input,
|
||||
Usage: resp.Usage,
|
||||
FinishReason: resp.FinishReason,
|
||||
Warnings: resp.Warnings,
|
||||
ProviderMetadata: resp.ProviderMetadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateWithText is a helper for providers without tool or JSON mode support.
|
||||
// It adds the schema to the system prompt and parses the text response as JSON.
|
||||
// This is a fallback for older models or simple providers.
|
||||
func GenerateWithText(
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
call fantasy.ObjectCall,
|
||||
) (*fantasy.ObjectResponse, error) {
|
||||
jsonSchemaBytes, err := json.Marshal(call.Schema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal schema: %w", err)
|
||||
}
|
||||
|
||||
schemaInstruction := fmt.Sprintf(
|
||||
"You must respond with valid JSON that matches this schema: %s\n"+
|
||||
"Respond ONLY with the JSON object, no additional text or explanation.",
|
||||
string(jsonSchemaBytes),
|
||||
)
|
||||
|
||||
enhancedPrompt := make(fantasy.Prompt, 0, len(call.Prompt)+1)
|
||||
|
||||
hasSystem := false
|
||||
for _, msg := range call.Prompt {
|
||||
if msg.Role == fantasy.MessageRoleSystem {
|
||||
hasSystem = true
|
||||
existingText := ""
|
||||
if len(msg.Content) > 0 {
|
||||
if textPart, ok := msg.Content[0].(fantasy.TextPart); ok {
|
||||
existingText = textPart.Text
|
||||
}
|
||||
}
|
||||
enhancedPrompt = append(enhancedPrompt, fantasy.NewSystemMessage(existingText+"\n\n"+schemaInstruction))
|
||||
} else {
|
||||
enhancedPrompt = append(enhancedPrompt, msg)
|
||||
}
|
||||
}
|
||||
|
||||
if !hasSystem {
|
||||
enhancedPrompt = append(fantasy.Prompt{fantasy.NewSystemMessage(schemaInstruction)}, call.Prompt...)
|
||||
}
|
||||
|
||||
resp, err := model.Generate(ctx, fantasy.Call{
|
||||
Prompt: enhancedPrompt,
|
||||
MaxOutputTokens: call.MaxOutputTokens,
|
||||
Temperature: call.Temperature,
|
||||
TopP: call.TopP,
|
||||
TopK: call.TopK,
|
||||
PresencePenalty: call.PresencePenalty,
|
||||
FrequencyPenalty: call.FrequencyPenalty,
|
||||
ProviderOptions: call.ProviderOptions,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("text-based generation failed: %w", err)
|
||||
}
|
||||
|
||||
textContent := resp.Content.Text()
|
||||
if textContent == "" {
|
||||
return nil, &fantasy.NoObjectGeneratedError{
|
||||
RawText: "",
|
||||
ParseError: fmt.Errorf("no text content in response"),
|
||||
Usage: resp.Usage,
|
||||
FinishReason: resp.FinishReason,
|
||||
}
|
||||
}
|
||||
|
||||
var obj any
|
||||
if call.RepairText != nil {
|
||||
obj, err = schema.ParseAndValidateWithRepair(ctx, textContent, call.Schema, call.RepairText)
|
||||
} else {
|
||||
obj, err = schema.ParseAndValidate(textContent, call.Schema)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if nogErr, ok := err.(*schema.ParseError); ok {
|
||||
return nil, &fantasy.NoObjectGeneratedError{
|
||||
RawText: nogErr.RawText,
|
||||
ParseError: nogErr.ParseError,
|
||||
ValidationError: nogErr.ValidationError,
|
||||
Usage: resp.Usage,
|
||||
FinishReason: resp.FinishReason,
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &fantasy.ObjectResponse{
|
||||
Object: obj,
|
||||
RawText: textContent,
|
||||
Usage: resp.Usage,
|
||||
FinishReason: resp.FinishReason,
|
||||
Warnings: resp.Warnings,
|
||||
ProviderMetadata: resp.ProviderMetadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StreamWithTool is a helper for providers without native JSON streaming.
|
||||
// It uses streaming tool calls to extract and parse the structured output progressively.
|
||||
func StreamWithTool(
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
call fantasy.ObjectCall,
|
||||
) (fantasy.ObjectStreamResponse, error) {
|
||||
// Create a tool from the schema
|
||||
toolName := call.SchemaName
|
||||
if toolName == "" {
|
||||
toolName = "generate_object"
|
||||
}
|
||||
|
||||
toolDescription := call.SchemaDescription
|
||||
if toolDescription == "" {
|
||||
toolDescription = "Generate a structured object matching the schema"
|
||||
}
|
||||
|
||||
tool := fantasy.FunctionTool{
|
||||
Name: toolName,
|
||||
Description: toolDescription,
|
||||
InputSchema: schema.ToMap(call.Schema),
|
||||
}
|
||||
|
||||
// Make a streaming Generate call with forced tool choice
|
||||
toolChoice := fantasy.SpecificToolChoice(tool.Name)
|
||||
stream, err := model.Stream(ctx, fantasy.Call{
|
||||
Prompt: call.Prompt,
|
||||
Tools: []fantasy.Tool{tool},
|
||||
ToolChoice: &toolChoice,
|
||||
MaxOutputTokens: call.MaxOutputTokens,
|
||||
Temperature: call.Temperature,
|
||||
TopP: call.TopP,
|
||||
TopK: call.TopK,
|
||||
PresencePenalty: call.PresencePenalty,
|
||||
FrequencyPenalty: call.FrequencyPenalty,
|
||||
ProviderOptions: call.ProviderOptions,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tool-based streaming failed: %w", err)
|
||||
}
|
||||
|
||||
// Convert the text stream to object stream parts
|
||||
return func(yield func(fantasy.ObjectStreamPart) bool) {
|
||||
var accumulated string
|
||||
var lastParsedObject any
|
||||
var usage fantasy.Usage
|
||||
var finishReason fantasy.FinishReason
|
||||
var warnings []fantasy.CallWarning
|
||||
var providerMetadata fantasy.ProviderMetadata
|
||||
var streamErr error
|
||||
|
||||
for part := range stream {
|
||||
switch part.Type {
|
||||
case fantasy.StreamPartTypeTextDelta:
|
||||
accumulated += part.Delta
|
||||
|
||||
obj, state, parseErr := schema.ParsePartialJSON(accumulated)
|
||||
|
||||
if state == schema.ParseStateSuccessful || state == schema.ParseStateRepaired {
|
||||
if err := schema.ValidateAgainstSchema(obj, call.Schema); err == nil {
|
||||
if !reflect.DeepEqual(obj, lastParsedObject) {
|
||||
if !yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeObject,
|
||||
Object: obj,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
lastParsedObject = obj
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if state == schema.ParseStateFailed && call.RepairText != nil {
|
||||
repairedText, repairErr := call.RepairText(ctx, accumulated, parseErr)
|
||||
if repairErr == nil {
|
||||
obj2, state2, _ := schema.ParsePartialJSON(repairedText)
|
||||
if (state2 == schema.ParseStateSuccessful || state2 == schema.ParseStateRepaired) &&
|
||||
schema.ValidateAgainstSchema(obj2, call.Schema) == nil {
|
||||
if !reflect.DeepEqual(obj2, lastParsedObject) {
|
||||
if !yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeObject,
|
||||
Object: obj2,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
lastParsedObject = obj2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case fantasy.StreamPartTypeToolInputDelta:
|
||||
accumulated += part.Delta
|
||||
|
||||
obj, state, parseErr := schema.ParsePartialJSON(accumulated)
|
||||
if state == schema.ParseStateSuccessful || state == schema.ParseStateRepaired {
|
||||
if err := schema.ValidateAgainstSchema(obj, call.Schema); err == nil {
|
||||
if !reflect.DeepEqual(obj, lastParsedObject) {
|
||||
if !yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeObject,
|
||||
Object: obj,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
lastParsedObject = obj
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if state == schema.ParseStateFailed && call.RepairText != nil {
|
||||
repairedText, repairErr := call.RepairText(ctx, accumulated, parseErr)
|
||||
if repairErr == nil {
|
||||
obj2, state2, _ := schema.ParsePartialJSON(repairedText)
|
||||
if (state2 == schema.ParseStateSuccessful || state2 == schema.ParseStateRepaired) &&
|
||||
schema.ValidateAgainstSchema(obj2, call.Schema) == nil {
|
||||
if !reflect.DeepEqual(obj2, lastParsedObject) {
|
||||
if !yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeObject,
|
||||
Object: obj2,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
lastParsedObject = obj2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case fantasy.StreamPartTypeToolCall:
|
||||
toolInput := part.ToolCallInput
|
||||
|
||||
var obj any
|
||||
var err error
|
||||
if call.RepairText != nil {
|
||||
obj, err = schema.ParseAndValidateWithRepair(ctx, toolInput, call.Schema, call.RepairText)
|
||||
} else {
|
||||
obj, err = schema.ParseAndValidate(toolInput, call.Schema)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
if !reflect.DeepEqual(obj, lastParsedObject) {
|
||||
if !yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeObject,
|
||||
Object: obj,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
lastParsedObject = obj
|
||||
}
|
||||
}
|
||||
|
||||
case fantasy.StreamPartTypeError:
|
||||
streamErr = part.Error
|
||||
if !yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeError,
|
||||
Error: part.Error,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
|
||||
case fantasy.StreamPartTypeFinish:
|
||||
usage = part.Usage
|
||||
finishReason = part.FinishReason
|
||||
|
||||
case fantasy.StreamPartTypeWarnings:
|
||||
warnings = part.Warnings
|
||||
}
|
||||
|
||||
if len(part.ProviderMetadata) > 0 {
|
||||
providerMetadata = part.ProviderMetadata
|
||||
}
|
||||
}
|
||||
|
||||
if streamErr == nil && lastParsedObject != nil {
|
||||
yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeFinish,
|
||||
Usage: usage,
|
||||
FinishReason: finishReason,
|
||||
Warnings: warnings,
|
||||
ProviderMetadata: providerMetadata,
|
||||
})
|
||||
} else if streamErr == nil && lastParsedObject == nil {
|
||||
yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeError,
|
||||
Error: &fantasy.NoObjectGeneratedError{
|
||||
RawText: accumulated,
|
||||
ParseError: fmt.Errorf("no valid object generated in stream"),
|
||||
Usage: usage,
|
||||
FinishReason: finishReason,
|
||||
},
|
||||
})
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StreamWithText is a helper for providers without tool or JSON streaming support.
|
||||
// It adds the schema to the system prompt and parses the streamed text as JSON progressively.
|
||||
func StreamWithText(
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
call fantasy.ObjectCall,
|
||||
) (fantasy.ObjectStreamResponse, error) {
|
||||
jsonSchemaMap := schema.ToMap(call.Schema)
|
||||
jsonSchemaBytes, err := json.Marshal(jsonSchemaMap)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal schema: %w", err)
|
||||
}
|
||||
|
||||
schemaInstruction := fmt.Sprintf(
|
||||
"You must respond with valid JSON that matches this schema: %s\n"+
|
||||
"Respond ONLY with the JSON object, no additional text or explanation.",
|
||||
string(jsonSchemaBytes),
|
||||
)
|
||||
|
||||
enhancedPrompt := make(fantasy.Prompt, 0, len(call.Prompt)+1)
|
||||
|
||||
hasSystem := false
|
||||
for _, msg := range call.Prompt {
|
||||
if msg.Role == fantasy.MessageRoleSystem {
|
||||
hasSystem = true
|
||||
existingText := ""
|
||||
if len(msg.Content) > 0 {
|
||||
if textPart, ok := msg.Content[0].(fantasy.TextPart); ok {
|
||||
existingText = textPart.Text
|
||||
}
|
||||
}
|
||||
enhancedPrompt = append(enhancedPrompt, fantasy.NewSystemMessage(existingText+"\n\n"+schemaInstruction))
|
||||
} else {
|
||||
enhancedPrompt = append(enhancedPrompt, msg)
|
||||
}
|
||||
}
|
||||
|
||||
if !hasSystem {
|
||||
enhancedPrompt = append(fantasy.Prompt{fantasy.NewSystemMessage(schemaInstruction)}, call.Prompt...)
|
||||
}
|
||||
|
||||
stream, err := model.Stream(ctx, fantasy.Call{
|
||||
Prompt: enhancedPrompt,
|
||||
MaxOutputTokens: call.MaxOutputTokens,
|
||||
Temperature: call.Temperature,
|
||||
TopP: call.TopP,
|
||||
TopK: call.TopK,
|
||||
PresencePenalty: call.PresencePenalty,
|
||||
FrequencyPenalty: call.FrequencyPenalty,
|
||||
ProviderOptions: call.ProviderOptions,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("text-based streaming failed: %w", err)
|
||||
}
|
||||
|
||||
return func(yield func(fantasy.ObjectStreamPart) bool) {
|
||||
var accumulated string
|
||||
var lastParsedObject any
|
||||
var usage fantasy.Usage
|
||||
var finishReason fantasy.FinishReason
|
||||
var warnings []fantasy.CallWarning
|
||||
var providerMetadata fantasy.ProviderMetadata
|
||||
var streamErr error
|
||||
|
||||
for part := range stream {
|
||||
switch part.Type {
|
||||
case fantasy.StreamPartTypeTextDelta:
|
||||
accumulated += part.Delta
|
||||
|
||||
obj, state, parseErr := schema.ParsePartialJSON(accumulated)
|
||||
|
||||
if state == schema.ParseStateSuccessful || state == schema.ParseStateRepaired {
|
||||
if err := schema.ValidateAgainstSchema(obj, call.Schema); err == nil {
|
||||
if !reflect.DeepEqual(obj, lastParsedObject) {
|
||||
if !yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeObject,
|
||||
Object: obj,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
lastParsedObject = obj
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if state == schema.ParseStateFailed && call.RepairText != nil {
|
||||
repairedText, repairErr := call.RepairText(ctx, accumulated, parseErr)
|
||||
if repairErr == nil {
|
||||
obj2, state2, _ := schema.ParsePartialJSON(repairedText)
|
||||
if (state2 == schema.ParseStateSuccessful || state2 == schema.ParseStateRepaired) &&
|
||||
schema.ValidateAgainstSchema(obj2, call.Schema) == nil {
|
||||
if !reflect.DeepEqual(obj2, lastParsedObject) {
|
||||
if !yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeObject,
|
||||
Object: obj2,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
lastParsedObject = obj2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case fantasy.StreamPartTypeError:
|
||||
streamErr = part.Error
|
||||
if !yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeError,
|
||||
Error: part.Error,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
|
||||
case fantasy.StreamPartTypeFinish:
|
||||
usage = part.Usage
|
||||
finishReason = part.FinishReason
|
||||
|
||||
case fantasy.StreamPartTypeWarnings:
|
||||
warnings = part.Warnings
|
||||
}
|
||||
|
||||
if len(part.ProviderMetadata) > 0 {
|
||||
providerMetadata = part.ProviderMetadata
|
||||
}
|
||||
}
|
||||
|
||||
if streamErr == nil && lastParsedObject != nil {
|
||||
yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeFinish,
|
||||
Usage: usage,
|
||||
FinishReason: finishReason,
|
||||
Warnings: warnings,
|
||||
ProviderMetadata: providerMetadata,
|
||||
})
|
||||
} else if streamErr == nil && lastParsedObject == nil {
|
||||
yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeError,
|
||||
Error: &fantasy.NoObjectGeneratedError{
|
||||
RawText: accumulated,
|
||||
ParseError: fmt.Errorf("no valid object generated in stream"),
|
||||
Usage: usage,
|
||||
FinishReason: finishReason,
|
||||
},
|
||||
})
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func unmarshal(obj any, target any) error {
|
||||
jsonBytes, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal object: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(jsonBytes, target); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal into target type: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
11
internal/fantasy/provider.go
Normal file
11
internal/fantasy/provider.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package fantasy
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Provider represents a provider of language models.
|
||||
type Provider interface {
|
||||
Name() string
|
||||
LanguageModel(ctx context.Context, modelID string) (LanguageModel, error)
|
||||
}
|
||||
106
internal/fantasy/provider_registry.go
Normal file
106
internal/fantasy/provider_registry.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package fantasy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// providerDataJSON is the serialized wrapper used by the registry.
|
||||
type providerDataJSON struct {
|
||||
Type string `json:"type"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// UnmarshalFunc converts raw JSON into a ProviderOptionsData implementation.
|
||||
type UnmarshalFunc func([]byte) (ProviderOptionsData, error)
|
||||
|
||||
// providerRegistry uses sync.Map for lock-free reads after initialization.
|
||||
// All registrations happen in init() functions before concurrent access.
|
||||
var providerRegistry sync.Map
|
||||
|
||||
// RegisterProviderType registers a provider type ID with its unmarshal function.
|
||||
// Type IDs must be globally unique (e.g. "openai.options").
|
||||
// This should only be called during package initialization (init functions).
|
||||
func RegisterProviderType(typeID string, unmarshalFn UnmarshalFunc) {
|
||||
providerRegistry.Store(typeID, unmarshalFn)
|
||||
}
|
||||
|
||||
// unmarshalProviderData routes a typed payload to the correct constructor.
|
||||
func unmarshalProviderData(data []byte) (ProviderOptionsData, error) {
|
||||
var pj providerDataJSON
|
||||
if err := json.Unmarshal(data, &pj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
val, exists := providerRegistry.Load(pj.Type)
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("unknown provider data type: %s", pj.Type)
|
||||
}
|
||||
|
||||
unmarshalFn := val.(UnmarshalFunc) //nolint:forcetypeassert // type enforced by RegisterProviderType
|
||||
return unmarshalFn(pj.Data)
|
||||
}
|
||||
|
||||
// unmarshalProviderDataMap is a helper for unmarshaling maps of provider data.
|
||||
func unmarshalProviderDataMap(data map[string]json.RawMessage) (map[string]ProviderOptionsData, error) {
|
||||
result := make(map[string]ProviderOptionsData)
|
||||
for provider, rawData := range data {
|
||||
providerData, err := unmarshalProviderData(rawData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal provider data for %s: %w", provider, err)
|
||||
}
|
||||
result[provider] = providerData
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UnmarshalProviderOptions unmarshals a map of provider options by type.
|
||||
func UnmarshalProviderOptions(data map[string]json.RawMessage) (ProviderOptions, error) {
|
||||
return unmarshalProviderDataMap(data)
|
||||
}
|
||||
|
||||
// UnmarshalProviderMetadata unmarshals a map of provider metadata by type.
|
||||
func UnmarshalProviderMetadata(data map[string]json.RawMessage) (ProviderMetadata, error) {
|
||||
return unmarshalProviderDataMap(data)
|
||||
}
|
||||
|
||||
// MarshalProviderType marshals provider data with a type wrapper using generics.
|
||||
// To avoid infinite recursion, use the "type plain T" pattern before calling this.
|
||||
//
|
||||
// Usage in provider types:
|
||||
//
|
||||
// func (o ProviderOptions) MarshalJSON() ([]byte, error) {
|
||||
// type plain ProviderOptions
|
||||
// return fantasy.MarshalProviderType(TypeProviderOptions, plain(o))
|
||||
// }
|
||||
func MarshalProviderType[T any](typeID string, data T) ([]byte, error) {
|
||||
rawData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return json.Marshal(providerDataJSON{
|
||||
Type: typeID,
|
||||
Data: json.RawMessage(rawData),
|
||||
})
|
||||
}
|
||||
|
||||
// UnmarshalProviderType unmarshals provider data without type wrapper using generics.
|
||||
// To avoid infinite recursion, unmarshal to a plain type first.
|
||||
// Note: This receives the inner 'data' field after type routing by the registry.
|
||||
//
|
||||
// Usage in provider types:
|
||||
//
|
||||
// func (o *ProviderOptions) UnmarshalJSON(data []byte) error {
|
||||
// type plain ProviderOptions
|
||||
// var p plain
|
||||
// if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
|
||||
// return err
|
||||
// }
|
||||
// *o = ProviderOptions(p)
|
||||
// return nil
|
||||
// }
|
||||
func UnmarshalProviderType[T any](data []byte, target *T) error {
|
||||
return json.Unmarshal(data, target)
|
||||
}
|
||||
1032
internal/fantasy/providers/anthropic/anthropic.go
Normal file
1032
internal/fantasy/providers/anthropic/anthropic.go
Normal file
File diff suppressed because it is too large
Load diff
343
internal/fantasy/providers/anthropic/anthropic_test.go
Normal file
343
internal/fantasy/providers/anthropic/anthropic_test.go
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
package anthropic
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestToPrompt_DropsEmptyMessages(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("should drop assistant messages with only reasoning content", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "Hello"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: fantasy.MessageRoleAssistant,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.ReasoningPart{
|
||||
Text: "Let me think about this...",
|
||||
ProviderOptions: fantasy.ProviderOptions{
|
||||
Name: &ReasoningOptionMetadata{
|
||||
Signature: "abc123",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
systemBlocks, messages, warnings := toPrompt(prompt, true)
|
||||
|
||||
require.Empty(t, systemBlocks)
|
||||
require.Len(t, messages, 1, "should only have user message, assistant message should be dropped")
|
||||
require.Len(t, warnings, 1)
|
||||
require.Equal(t, fantasy.CallWarningTypeOther, warnings[0].Type)
|
||||
require.Contains(t, warnings[0].Message, "dropping empty assistant message")
|
||||
require.Contains(t, warnings[0].Message, "neither user-facing content nor tool calls")
|
||||
})
|
||||
|
||||
t.Run("should drop assistant reasoning when sendReasoning disabled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "Hello"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: fantasy.MessageRoleAssistant,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.ReasoningPart{
|
||||
Text: "Let me think about this...",
|
||||
ProviderOptions: fantasy.ProviderOptions{
|
||||
Name: &ReasoningOptionMetadata{
|
||||
Signature: "def456",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
systemBlocks, messages, warnings := toPrompt(prompt, false)
|
||||
|
||||
require.Empty(t, systemBlocks)
|
||||
require.Len(t, messages, 1, "should only have user message, assistant message should be dropped")
|
||||
require.Len(t, warnings, 2)
|
||||
require.Equal(t, fantasy.CallWarningTypeOther, warnings[0].Type)
|
||||
require.Contains(t, warnings[0].Message, "sending reasoning content is disabled")
|
||||
require.Equal(t, fantasy.CallWarningTypeOther, warnings[1].Type)
|
||||
require.Contains(t, warnings[1].Message, "dropping empty assistant message")
|
||||
})
|
||||
|
||||
t.Run("should drop truly empty assistant messages", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "Hello"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: fantasy.MessageRoleAssistant,
|
||||
Content: []fantasy.MessagePart{},
|
||||
},
|
||||
}
|
||||
|
||||
systemBlocks, messages, warnings := toPrompt(prompt, true)
|
||||
|
||||
require.Empty(t, systemBlocks)
|
||||
require.Len(t, messages, 1, "should only have user message")
|
||||
require.Len(t, warnings, 1)
|
||||
require.Equal(t, fantasy.CallWarningTypeOther, warnings[0].Type)
|
||||
require.Contains(t, warnings[0].Message, "dropping empty assistant message")
|
||||
})
|
||||
|
||||
t.Run("should keep assistant messages with text content", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "Hello"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: fantasy.MessageRoleAssistant,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "Hi there!"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
systemBlocks, messages, warnings := toPrompt(prompt, true)
|
||||
|
||||
require.Empty(t, systemBlocks)
|
||||
require.Len(t, messages, 2, "should have both user and assistant messages")
|
||||
require.Empty(t, warnings)
|
||||
})
|
||||
|
||||
t.Run("should keep assistant messages with tool calls", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "What's the weather?"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: fantasy.MessageRoleAssistant,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.ToolCallPart{
|
||||
ToolCallID: "call_123",
|
||||
ToolName: "get_weather",
|
||||
Input: `{"location":"NYC"}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
systemBlocks, messages, warnings := toPrompt(prompt, true)
|
||||
|
||||
require.Empty(t, systemBlocks)
|
||||
require.Len(t, messages, 2, "should have both user and assistant messages")
|
||||
require.Empty(t, warnings)
|
||||
})
|
||||
|
||||
t.Run("should drop assistant messages with invalid tool input", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "Hi"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: fantasy.MessageRoleAssistant,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.ToolCallPart{
|
||||
ToolCallID: "call_123",
|
||||
ToolName: "get_weather",
|
||||
Input: "{not-json",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
systemBlocks, messages, warnings := toPrompt(prompt, true)
|
||||
|
||||
require.Empty(t, systemBlocks)
|
||||
require.Len(t, messages, 1, "should only have user message")
|
||||
require.Len(t, warnings, 1)
|
||||
require.Equal(t, fantasy.CallWarningTypeOther, warnings[0].Type)
|
||||
require.Contains(t, warnings[0].Message, "dropping empty assistant message")
|
||||
})
|
||||
|
||||
t.Run("should keep assistant messages with reasoning and text", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "Hello"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: fantasy.MessageRoleAssistant,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.ReasoningPart{
|
||||
Text: "Let me think...",
|
||||
ProviderOptions: fantasy.ProviderOptions{
|
||||
Name: &ReasoningOptionMetadata{
|
||||
Signature: "abc123",
|
||||
},
|
||||
},
|
||||
},
|
||||
fantasy.TextPart{Text: "Hi there!"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
systemBlocks, messages, warnings := toPrompt(prompt, true)
|
||||
|
||||
require.Empty(t, systemBlocks)
|
||||
require.Len(t, messages, 2, "should have both user and assistant messages")
|
||||
require.Empty(t, warnings)
|
||||
})
|
||||
|
||||
t.Run("should keep user messages with image content", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.FilePart{
|
||||
Data: []byte{0x01, 0x02, 0x03},
|
||||
MediaType: "image/png",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
systemBlocks, messages, warnings := toPrompt(prompt, true)
|
||||
|
||||
require.Empty(t, systemBlocks)
|
||||
require.Len(t, messages, 1)
|
||||
require.Empty(t, warnings)
|
||||
})
|
||||
|
||||
t.Run("should drop user messages without visible content", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.FilePart{
|
||||
Data: []byte("not supported"),
|
||||
MediaType: "application/pdf",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
systemBlocks, messages, warnings := toPrompt(prompt, true)
|
||||
|
||||
require.Empty(t, systemBlocks)
|
||||
require.Empty(t, messages)
|
||||
require.Len(t, warnings, 1)
|
||||
require.Equal(t, fantasy.CallWarningTypeOther, warnings[0].Type)
|
||||
require.Contains(t, warnings[0].Message, "dropping empty user message")
|
||||
require.Contains(t, warnings[0].Message, "neither user-facing content nor tool results")
|
||||
})
|
||||
|
||||
t.Run("should keep user messages with tool results", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleTool,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.ToolResultPart{
|
||||
ToolCallID: "call_123",
|
||||
Output: fantasy.ToolResultOutputContentText{Text: "done"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
systemBlocks, messages, warnings := toPrompt(prompt, true)
|
||||
|
||||
require.Empty(t, systemBlocks)
|
||||
require.Len(t, messages, 1)
|
||||
require.Empty(t, warnings)
|
||||
})
|
||||
|
||||
t.Run("should keep user messages with tool error results", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleTool,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.ToolResultPart{
|
||||
ToolCallID: "call_456",
|
||||
Output: fantasy.ToolResultOutputContentError{Error: errors.New("boom")},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
systemBlocks, messages, warnings := toPrompt(prompt, true)
|
||||
|
||||
require.Empty(t, systemBlocks)
|
||||
require.Len(t, messages, 1)
|
||||
require.Empty(t, warnings)
|
||||
})
|
||||
|
||||
t.Run("should keep user messages with tool media results", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleTool,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.ToolResultPart{
|
||||
ToolCallID: "call_789",
|
||||
Output: fantasy.ToolResultOutputContentMedia{
|
||||
Data: "AQID",
|
||||
MediaType: "image/png",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
systemBlocks, messages, warnings := toPrompt(prompt, true)
|
||||
|
||||
require.Empty(t, systemBlocks)
|
||||
require.Len(t, messages, 1)
|
||||
require.Empty(t, warnings)
|
||||
})
|
||||
}
|
||||
29
internal/fantasy/providers/anthropic/bedrock.go
Normal file
29
internal/fantasy/providers/anthropic/bedrock.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package anthropic
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/smithy-go/auth/bearer"
|
||||
)
|
||||
|
||||
func bedrockBasicAuthConfig(apiKey string) aws.Config {
|
||||
return aws.Config{
|
||||
Region: cmp.Or(os.Getenv("AWS_REGION"), "us-east-1"),
|
||||
BearerAuthTokenProvider: bearer.StaticTokenProvider{Token: bearer.Token{Value: apiKey}},
|
||||
}
|
||||
}
|
||||
|
||||
func bedrockPrefixModelWithRegion(modelID string) string {
|
||||
region := os.Getenv("AWS_REGION")
|
||||
if len(region) < 2 {
|
||||
region = "us-east-1"
|
||||
}
|
||||
prefix := region[:2] + "."
|
||||
if strings.HasPrefix(modelID, prefix) {
|
||||
return modelID
|
||||
}
|
||||
return prefix + modelID
|
||||
}
|
||||
39
internal/fantasy/providers/anthropic/error.go
Normal file
39
internal/fantasy/providers/anthropic/error.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package anthropic
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/charmbracelet/anthropic-sdk-go"
|
||||
)
|
||||
|
||||
func toProviderErr(err error) error {
|
||||
var apiErr *anthropic.Error
|
||||
if errors.As(err, &apiErr) {
|
||||
return &fantasy.ProviderError{
|
||||
Title: cmp.Or(fantasy.ErrorTitleForStatusCode(apiErr.StatusCode), "provider request failed"),
|
||||
Message: apiErr.Error(),
|
||||
Cause: apiErr,
|
||||
URL: apiErr.Request.URL.String(),
|
||||
StatusCode: apiErr.StatusCode,
|
||||
RequestBody: apiErr.DumpRequest(true),
|
||||
ResponseHeaders: toHeaderMap(apiErr.Response.Header),
|
||||
ResponseBody: apiErr.DumpResponse(true),
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func toHeaderMap(in http.Header) (out map[string]string) {
|
||||
out = make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
if l := len(v); l > 0 {
|
||||
out[k] = v[l-1]
|
||||
in[strings.ToLower(k)] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
11
internal/fantasy/providers/anthropic/google.go
Normal file
11
internal/fantasy/providers/anthropic/google.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package anthropic
|
||||
|
||||
import (
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type googleDummyTokenSource struct{}
|
||||
|
||||
func (googleDummyTokenSource) Token() (*oauth2.Token, error) {
|
||||
return &oauth2.Token{AccessToken: "dummy-token"}, nil
|
||||
}
|
||||
151
internal/fantasy/providers/anthropic/provider_options.go
Normal file
151
internal/fantasy/providers/anthropic/provider_options.go
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
// Package anthropic provides an implementation of the fantasy AI SDK for Anthropic's language models.
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
|
||||
// Global type identifiers for Anthropic-specific provider data.
|
||||
const (
|
||||
TypeProviderOptions = Name + ".options"
|
||||
TypeReasoningOptionMetadata = Name + ".reasoning_metadata"
|
||||
TypeProviderCacheControl = Name + ".cache_control_options"
|
||||
)
|
||||
|
||||
// Register Anthropic provider-specific types with the global registry.
|
||||
func init() {
|
||||
fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
|
||||
var v ProviderOptions
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
})
|
||||
fantasy.RegisterProviderType(TypeReasoningOptionMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) {
|
||||
var v ReasoningOptionMetadata
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
})
|
||||
fantasy.RegisterProviderType(TypeProviderCacheControl, func(data []byte) (fantasy.ProviderOptionsData, error) {
|
||||
var v ProviderCacheControlOptions
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ProviderOptions represents additional options for the Anthropic provider.
|
||||
type ProviderOptions struct {
|
||||
SendReasoning *bool `json:"send_reasoning"`
|
||||
Thinking *ThinkingProviderOption `json:"thinking"`
|
||||
DisableParallelToolUse *bool `json:"disable_parallel_tool_use"`
|
||||
}
|
||||
|
||||
// Options implements the ProviderOptions interface.
|
||||
func (o *ProviderOptions) Options() {}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling with type info for ProviderOptions.
|
||||
func (o ProviderOptions) MarshalJSON() ([]byte, error) {
|
||||
type plain ProviderOptions
|
||||
return fantasy.MarshalProviderType(TypeProviderOptions, plain(o))
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom JSON unmarshaling with type info for ProviderOptions.
|
||||
func (o *ProviderOptions) UnmarshalJSON(data []byte) error {
|
||||
type plain ProviderOptions
|
||||
var p plain
|
||||
if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
|
||||
return err
|
||||
}
|
||||
*o = ProviderOptions(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ThinkingProviderOption represents thinking options for the Anthropic provider.
|
||||
type ThinkingProviderOption struct {
|
||||
BudgetTokens int64 `json:"budget_tokens"`
|
||||
}
|
||||
|
||||
// ReasoningOptionMetadata represents reasoning metadata for the Anthropic provider.
|
||||
type ReasoningOptionMetadata struct {
|
||||
Signature string `json:"signature"`
|
||||
RedactedData string `json:"redacted_data"`
|
||||
}
|
||||
|
||||
// Options implements the ProviderOptions interface.
|
||||
func (*ReasoningOptionMetadata) Options() {}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling with type info for ReasoningOptionMetadata.
|
||||
func (m ReasoningOptionMetadata) MarshalJSON() ([]byte, error) {
|
||||
type plain ReasoningOptionMetadata
|
||||
return fantasy.MarshalProviderType(TypeReasoningOptionMetadata, plain(m))
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom JSON unmarshaling with type info for ReasoningOptionMetadata.
|
||||
func (m *ReasoningOptionMetadata) UnmarshalJSON(data []byte) error {
|
||||
type plain ReasoningOptionMetadata
|
||||
var p plain
|
||||
if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
|
||||
return err
|
||||
}
|
||||
*m = ReasoningOptionMetadata(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProviderCacheControlOptions represents cache control options for the Anthropic provider.
|
||||
type ProviderCacheControlOptions struct {
|
||||
CacheControl CacheControl `json:"cache_control"`
|
||||
}
|
||||
|
||||
// Options implements the ProviderOptions interface.
|
||||
func (*ProviderCacheControlOptions) Options() {}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling with type info for ProviderCacheControlOptions.
|
||||
func (o ProviderCacheControlOptions) MarshalJSON() ([]byte, error) {
|
||||
type plain ProviderCacheControlOptions
|
||||
return fantasy.MarshalProviderType(TypeProviderCacheControl, plain(o))
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom JSON unmarshaling with type info for ProviderCacheControlOptions.
|
||||
func (o *ProviderCacheControlOptions) UnmarshalJSON(data []byte) error {
|
||||
type plain ProviderCacheControlOptions
|
||||
var p plain
|
||||
if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
|
||||
return err
|
||||
}
|
||||
*o = ProviderCacheControlOptions(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CacheControl represents cache control settings for the Anthropic provider.
|
||||
type CacheControl struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// NewProviderOptions creates new provider options for the Anthropic provider.
|
||||
func NewProviderOptions(opts *ProviderOptions) fantasy.ProviderOptions {
|
||||
return fantasy.ProviderOptions{
|
||||
Name: opts,
|
||||
}
|
||||
}
|
||||
|
||||
// NewProviderCacheControlOptions creates new cache control options for the Anthropic provider.
|
||||
func NewProviderCacheControlOptions(opts *ProviderCacheControlOptions) fantasy.ProviderOptions {
|
||||
return fantasy.ProviderOptions{
|
||||
Name: opts,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseOptions parses provider options from a map for the Anthropic provider.
|
||||
func ParseOptions(data map[string]any) (*ProviderOptions, error) {
|
||||
var options ProviderOptions
|
||||
if err := fantasy.ParseOptions(data, &options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &options, nil
|
||||
}
|
||||
6
internal/fantasy/providers/azure/README.md
Normal file
6
internal/fantasy/providers/azure/README.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Azure
|
||||
|
||||
* Go to https://portal.azure.com/ and log in (or sign up if you haven't already)
|
||||
* Access https://ai.azure.com/
|
||||
* Create a project / resource (or use an existing one)
|
||||
* Copy and API key
|
||||
117
internal/fantasy/providers/azure/azure.go
Normal file
117
internal/fantasy/providers/azure/azure.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// Package azure provides an implementation of the fantasy AI SDK for Azure's language models.
|
||||
package azure
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"charm.land/fantasy/providers/openai"
|
||||
"github.com/openai/openai-go/v2/azure"
|
||||
"github.com/openai/openai-go/v2/option"
|
||||
)
|
||||
|
||||
type options struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
apiVersion string
|
||||
|
||||
openaiOptions []openai.Option
|
||||
}
|
||||
|
||||
const (
|
||||
// Name is the name of the Azure provider.
|
||||
Name = "azure"
|
||||
// defaultAPIVersion is the default API version for Azure.
|
||||
defaultAPIVersion = "2025-01-01-preview"
|
||||
)
|
||||
|
||||
// azureURLPattern matches Azure OpenAI endpoint URLs in various formats:
|
||||
// * https://resource-id.openai.azure.com;
|
||||
// * https://resource-id.openai.azure.com/;
|
||||
// * https://resource-id.cognitiveservices.azure.com;
|
||||
// * https://resource-id.services.ai.azure.com/api/projects/project-name;
|
||||
// * resource-id.openai.azure.com.
|
||||
var azureURLPattern = regexp.MustCompile(`^(?:https?://)?([a-zA-Z0-9-]+)\.(?:openai|cognitiveservices|services\.ai)\.azure\.com(?:/.*)?$`)
|
||||
|
||||
// Option defines a function that configures Azure provider options.
|
||||
type Option = func(*options)
|
||||
|
||||
// New creates a new Azure provider with the given options.
|
||||
func New(opts ...Option) (fantasy.Provider, error) {
|
||||
o := options{
|
||||
apiVersion: defaultAPIVersion,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(&o)
|
||||
}
|
||||
return openai.New(
|
||||
append(
|
||||
o.openaiOptions,
|
||||
openai.WithName(Name),
|
||||
openai.WithBaseURL(o.baseURL),
|
||||
openai.WithSDKOptions(
|
||||
azure.WithAPIKey(o.apiKey),
|
||||
),
|
||||
)...,
|
||||
)
|
||||
}
|
||||
|
||||
// WithBaseURL sets the base URL for the Azure provider.
|
||||
func WithBaseURL(baseURL string) Option {
|
||||
return func(o *options) {
|
||||
o.baseURL = parseAzureURL(baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
// parseAzureURL extracts the resource ID from various Azure URL formats
|
||||
// and returns the standardized OpenAI-compatible endpoint URL.
|
||||
// If the URL doesn't match known Azure patterns, it returns the original URL.
|
||||
func parseAzureURL(baseURL string) string {
|
||||
matches := azureURLPattern.FindStringSubmatch(baseURL)
|
||||
if len(matches) >= 2 {
|
||||
resourceID := matches[1]
|
||||
return fmt.Sprintf("https://%s.openai.azure.com/openai/v1", resourceID)
|
||||
}
|
||||
// fallback to use the provided url
|
||||
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
|
||||
return "https://" + baseURL
|
||||
}
|
||||
return baseURL
|
||||
}
|
||||
|
||||
// WithAPIKey sets the API key for the Azure provider.
|
||||
func WithAPIKey(apiKey string) Option {
|
||||
return func(o *options) {
|
||||
o.apiKey = apiKey
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaders sets the headers for the Azure provider.
|
||||
func WithHeaders(headers map[string]string) Option {
|
||||
return func(o *options) {
|
||||
o.openaiOptions = append(o.openaiOptions, openai.WithHeaders(headers))
|
||||
}
|
||||
}
|
||||
|
||||
// WithAPIVersion sets the API version for the Azure provider.
|
||||
func WithAPIVersion(version string) Option {
|
||||
return func(o *options) {
|
||||
o.apiVersion = version
|
||||
}
|
||||
}
|
||||
|
||||
// WithHTTPClient sets the HTTP client for the Azure provider.
|
||||
func WithHTTPClient(client option.HTTPClient) Option {
|
||||
return func(o *options) {
|
||||
o.openaiOptions = append(o.openaiOptions, openai.WithHTTPClient(client))
|
||||
}
|
||||
}
|
||||
|
||||
// WithUseResponsesAPI configures the provider to use the responses API for models that support it.
|
||||
func WithUseResponsesAPI() Option {
|
||||
return func(o *options) {
|
||||
o.openaiOptions = append(o.openaiOptions, openai.WithUseResponsesAPI())
|
||||
}
|
||||
}
|
||||
98
internal/fantasy/providers/azure/azure_test.go
Normal file
98
internal/fantasy/providers/azure/azure_test.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package azure
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseAzureURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "full https openai azure url",
|
||||
input: "https://my-resource.openai.azure.com",
|
||||
expected: "https://my-resource.openai.azure.com/openai/v1",
|
||||
},
|
||||
{
|
||||
name: "full https openai azure url with trailing slash",
|
||||
input: "https://my-resource.openai.azure.com/",
|
||||
expected: "https://my-resource.openai.azure.com/openai/v1",
|
||||
},
|
||||
{
|
||||
name: "full https cognitiveservices azure url",
|
||||
input: "https://my-resource.cognitiveservices.azure.com",
|
||||
expected: "https://my-resource.openai.azure.com/openai/v1",
|
||||
},
|
||||
{
|
||||
name: "full https services.ai azure url with path",
|
||||
input: "https://fantasy-playground-resource.services.ai.azure.com/api/projects/fantasy-playground",
|
||||
expected: "https://fantasy-playground-resource.openai.azure.com/openai/v1",
|
||||
},
|
||||
{
|
||||
name: "openai azure url without protocol",
|
||||
input: "my-resource.openai.azure.com",
|
||||
expected: "https://my-resource.openai.azure.com/openai/v1",
|
||||
},
|
||||
{
|
||||
name: "cognitiveservices azure url without protocol",
|
||||
input: "my-resource.cognitiveservices.azure.com",
|
||||
expected: "https://my-resource.openai.azure.com/openai/v1",
|
||||
},
|
||||
{
|
||||
name: "services.ai azure url without protocol",
|
||||
input: "fantasy-playground-resource.services.ai.azure.com/api/projects/fantasy-playground",
|
||||
expected: "https://fantasy-playground-resource.openai.azure.com/openai/v1",
|
||||
},
|
||||
{
|
||||
name: "resource with hyphens",
|
||||
input: "https://my-complex-resource-123.openai.azure.com",
|
||||
expected: "https://my-complex-resource-123.openai.azure.com/openai/v1",
|
||||
},
|
||||
{
|
||||
name: "openai azure url with trailing slash",
|
||||
input: "https://fantasy-playground-resource.openai.azure.com/",
|
||||
expected: "https://fantasy-playground-resource.openai.azure.com/openai/v1",
|
||||
},
|
||||
{
|
||||
name: "cognitiveservices azure url with trailing slash",
|
||||
input: "https://fantasy-playground-resource.cognitiveservices.azure.com/",
|
||||
expected: "https://fantasy-playground-resource.openai.azure.com/openai/v1",
|
||||
},
|
||||
{
|
||||
name: "malformed url - non azure domain",
|
||||
input: "https://non.sense.com",
|
||||
expected: "https://non.sense.com",
|
||||
},
|
||||
{
|
||||
name: "malformed url - simple domain",
|
||||
input: "example.com",
|
||||
expected: "https://example.com",
|
||||
},
|
||||
{
|
||||
name: "custom endpoint with protocol",
|
||||
input: "https://custom-endpoint.example.com",
|
||||
expected: "https://custom-endpoint.example.com",
|
||||
},
|
||||
{
|
||||
name: "custom endpoint without protocol",
|
||||
input: "custom-endpoint.example.com",
|
||||
expected: "https://custom-endpoint.example.com",
|
||||
},
|
||||
{
|
||||
name: "localhost",
|
||||
input: "http://localhost:8080",
|
||||
expected: "http://localhost:8080",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := parseAzureURL(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
10
internal/fantasy/providers/bedrock/README.md
Normal file
10
internal/fantasy/providers/bedrock/README.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Bedrock
|
||||
|
||||
- Install the AWS CLI
|
||||
- Log in in `aws configure`
|
||||
|
||||
To see available models, run:
|
||||
|
||||
```bash
|
||||
aws bedrock list-inference-profiles --region us-east-1
|
||||
```
|
||||
65
internal/fantasy/providers/bedrock/bedrock.go
Normal file
65
internal/fantasy/providers/bedrock/bedrock.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// Package bedrock provides an implementation of the fantasy AI SDK for AWS Bedrock's language models.
|
||||
package bedrock
|
||||
|
||||
import (
|
||||
"charm.land/fantasy"
|
||||
"charm.land/fantasy/providers/anthropic"
|
||||
"github.com/charmbracelet/anthropic-sdk-go/option"
|
||||
)
|
||||
|
||||
type options struct {
|
||||
skipAuth bool
|
||||
anthropicOptions []anthropic.Option
|
||||
}
|
||||
|
||||
const (
|
||||
// Name is the name of the Bedrock provider.
|
||||
Name = "bedrock"
|
||||
)
|
||||
|
||||
// Option defines a function that configures Bedrock provider options.
|
||||
type Option = func(*options)
|
||||
|
||||
// New creates a new Bedrock provider with the given options.
|
||||
func New(opts ...Option) (fantasy.Provider, error) {
|
||||
var o options
|
||||
for _, opt := range opts {
|
||||
opt(&o)
|
||||
}
|
||||
return anthropic.New(
|
||||
append(
|
||||
o.anthropicOptions,
|
||||
anthropic.WithName(Name),
|
||||
anthropic.WithBedrock(),
|
||||
anthropic.WithSkipAuth(o.skipAuth),
|
||||
)...,
|
||||
)
|
||||
}
|
||||
|
||||
// WithAPIKey sets the access token for the Bedrock provider.
|
||||
func WithAPIKey(apiKey string) Option {
|
||||
return func(o *options) {
|
||||
o.anthropicOptions = append(o.anthropicOptions, anthropic.WithAPIKey(apiKey))
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaders sets the headers for the Bedrock provider.
|
||||
func WithHeaders(headers map[string]string) Option {
|
||||
return func(o *options) {
|
||||
o.anthropicOptions = append(o.anthropicOptions, anthropic.WithHeaders(headers))
|
||||
}
|
||||
}
|
||||
|
||||
// WithHTTPClient sets the HTTP client for the Bedrock provider.
|
||||
func WithHTTPClient(client option.HTTPClient) Option {
|
||||
return func(o *options) {
|
||||
o.anthropicOptions = append(o.anthropicOptions, anthropic.WithHTTPClient(client))
|
||||
}
|
||||
}
|
||||
|
||||
// WithSkipAuth configures whether to skip authentication for the Bedrock provider.
|
||||
func WithSkipAuth(skipAuth bool) Option {
|
||||
return func(o *options) {
|
||||
o.skipAuth = skipAuth
|
||||
}
|
||||
}
|
||||
63
internal/fantasy/providers/google/README.md
Normal file
63
internal/fantasy/providers/google/README.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# Google Provider
|
||||
|
||||
This document describes how to get an API keys for Google Gemini and Vertex.
|
||||
|
||||
## Gemini
|
||||
|
||||
Simply navigate to [this page](https://aistudio.google.com/apikey) in the
|
||||
Google AI Studio and create a new API key.
|
||||
|
||||
## Vertex
|
||||
|
||||
### Install `gcloud`
|
||||
|
||||
Install the `gcloud` command line tool. Install via Homebrew, Nix, or download
|
||||
it from [here](https://cloud.google.com/sdk/docs/install).
|
||||
|
||||
```bash
|
||||
# Homebrew
|
||||
brew install --cask google-cloud-sdk
|
||||
|
||||
# Nix
|
||||
nix-env -iA nixpkgs.google-cloud-sdk
|
||||
```
|
||||
|
||||
### Authenticate
|
||||
|
||||
Then authenticate with your Google account:
|
||||
|
||||
```bash
|
||||
gcloud auth login
|
||||
```
|
||||
|
||||
### Create And Setup Project
|
||||
|
||||
Navigate here to create a new project if you haven't already:
|
||||
https://console.cloud.google.com/projectcreate
|
||||
|
||||
Alternatively, you can create a new project via the command line:
|
||||
|
||||
```bash
|
||||
gcloud projects create {YOUR_PROJECT_ID} --name="{YOUR_PROJECT_NAME}"
|
||||
```
|
||||
|
||||
Set the project on your machine:
|
||||
|
||||
```bash
|
||||
gcloud config set project {YOUR_PROJECT_ID}
|
||||
```
|
||||
|
||||
Enable the Vertex AI API:
|
||||
|
||||
```bash
|
||||
gcloud services enable aiplatform.googleapis.com
|
||||
```
|
||||
|
||||
### Setup Env
|
||||
|
||||
Finally, you need to run this command to ensure that libraries will be able to
|
||||
find your credentials.
|
||||
|
||||
```bash
|
||||
gcloud auth application-default login
|
||||
```
|
||||
15
internal/fantasy/providers/google/auth.go
Normal file
15
internal/fantasy/providers/google/auth.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// Package google provides an implementation of the fantasy AI SDK for Google's language models.
|
||||
package google
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"cloud.google.com/go/auth"
|
||||
)
|
||||
|
||||
type dummyTokenProvider struct{}
|
||||
|
||||
// Token implements the auth.TokenProvider interface.
|
||||
func (dummyTokenProvider) Token(_ context.Context) (*auth.Token, error) {
|
||||
return &auth.Token{Value: "dummy-token"}, nil
|
||||
}
|
||||
23
internal/fantasy/providers/google/error.go
Normal file
23
internal/fantasy/providers/google/error.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package google
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"google.golang.org/genai"
|
||||
)
|
||||
|
||||
func toProviderErr(err error) error {
|
||||
var apiErr genai.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
return err
|
||||
}
|
||||
return &fantasy.ProviderError{
|
||||
Message: apiErr.Message,
|
||||
Title: cmp.Or(fantasy.ErrorTitleForStatusCode(apiErr.Code), "provider request failed"),
|
||||
Cause: err,
|
||||
StatusCode: apiErr.Code,
|
||||
ResponseBody: []byte(apiErr.Message),
|
||||
}
|
||||
}
|
||||
1431
internal/fantasy/providers/google/google.go
Normal file
1431
internal/fantasy/providers/google/google.go
Normal file
File diff suppressed because it is too large
Load diff
132
internal/fantasy/providers/google/provider_options.go
Normal file
132
internal/fantasy/providers/google/provider_options.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
// Package google provides an implementation of the fantasy AI SDK for Google's language models.
|
||||
package google
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
|
||||
// Global type identifiers for Google-specific provider data.
|
||||
const (
|
||||
TypeProviderOptions = Name + ".options"
|
||||
TypeReasoningMetadata = Name + ".reasoning_metadata"
|
||||
)
|
||||
|
||||
// Register Google provider-specific types with the global registry.
|
||||
func init() {
|
||||
fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
|
||||
var v ProviderOptions
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
})
|
||||
fantasy.RegisterProviderType(TypeReasoningMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) {
|
||||
var v ReasoningMetadata
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ThinkingConfig represents thinking configuration for the Google provider.
|
||||
type ThinkingConfig struct {
|
||||
ThinkingBudget *int64 `json:"thinking_budget"`
|
||||
IncludeThoughts *bool `json:"include_thoughts"`
|
||||
}
|
||||
|
||||
// ReasoningMetadata represents reasoning metadata for the Google provider.
|
||||
type ReasoningMetadata struct {
|
||||
Signature string `json:"signature"`
|
||||
ToolID string `json:"tool_id"`
|
||||
}
|
||||
|
||||
// Options implements the ProviderOptionsData interface for ReasoningMetadata.
|
||||
func (m *ReasoningMetadata) Options() {}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling with type info for ReasoningMetadata.
|
||||
func (m ReasoningMetadata) MarshalJSON() ([]byte, error) {
|
||||
type plain ReasoningMetadata
|
||||
return fantasy.MarshalProviderType(TypeReasoningMetadata, plain(m))
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom JSON unmarshaling with type info for ReasoningMetadata.
|
||||
func (m *ReasoningMetadata) UnmarshalJSON(data []byte) error {
|
||||
type plain ReasoningMetadata
|
||||
var p plain
|
||||
if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
|
||||
return err
|
||||
}
|
||||
*m = ReasoningMetadata(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SafetySetting represents safety settings for the Google provider.
|
||||
type SafetySetting struct {
|
||||
// 'HARM_CATEGORY_UNSPECIFIED',
|
||||
// 'HARM_CATEGORY_HATE_SPEECH',
|
||||
// 'HARM_CATEGORY_DANGEROUS_CONTENT',
|
||||
// 'HARM_CATEGORY_HARASSMENT',
|
||||
// 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
|
||||
// 'HARM_CATEGORY_CIVIC_INTEGRITY',
|
||||
Category string `json:"category"`
|
||||
|
||||
// 'HARM_BLOCK_THRESHOLD_UNSPECIFIED',
|
||||
// 'BLOCK_LOW_AND_ABOVE',
|
||||
// 'BLOCK_MEDIUM_AND_ABOVE',
|
||||
// 'BLOCK_ONLY_HIGH',
|
||||
// 'BLOCK_NONE',
|
||||
// 'OFF',
|
||||
Threshold string `json:"threshold"`
|
||||
}
|
||||
|
||||
// ProviderOptions represents additional options for the Google provider.
|
||||
type ProviderOptions struct {
|
||||
ThinkingConfig *ThinkingConfig `json:"thinking_config"`
|
||||
|
||||
// Optional.
|
||||
// The name of the cached content used as context to serve the prediction.
|
||||
// Format: cachedContents/{cachedContent}
|
||||
CachedContent string `json:"cached_content"`
|
||||
|
||||
// Optional. A list of unique safety settings for blocking unsafe content.
|
||||
SafetySettings []SafetySetting `json:"safety_settings"`
|
||||
// 'HARM_BLOCK_THRESHOLD_UNSPECIFIED',
|
||||
// 'BLOCK_LOW_AND_ABOVE',
|
||||
// 'BLOCK_MEDIUM_AND_ABOVE',
|
||||
// 'BLOCK_ONLY_HIGH',
|
||||
// 'BLOCK_NONE',
|
||||
// 'OFF',
|
||||
Threshold string `json:"threshold"`
|
||||
}
|
||||
|
||||
// Options implements the ProviderOptionsData interface for ProviderOptions.
|
||||
func (o *ProviderOptions) Options() {}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling with type info for ProviderOptions.
|
||||
func (o ProviderOptions) MarshalJSON() ([]byte, error) {
|
||||
type plain ProviderOptions
|
||||
return fantasy.MarshalProviderType(TypeProviderOptions, plain(o))
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom JSON unmarshaling with type info for ProviderOptions.
|
||||
func (o *ProviderOptions) UnmarshalJSON(data []byte) error {
|
||||
type plain ProviderOptions
|
||||
var p plain
|
||||
if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
|
||||
return err
|
||||
}
|
||||
*o = ProviderOptions(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseOptions parses provider options from a map for the Google provider.
|
||||
func ParseOptions(data map[string]any) (*ProviderOptions, error) {
|
||||
var options ProviderOptions
|
||||
if err := fantasy.ParseOptions(data, &options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &options, nil
|
||||
}
|
||||
11
internal/fantasy/providers/google/slice.go
Normal file
11
internal/fantasy/providers/google/slice.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package google
|
||||
|
||||
func depointerSlice[T any](s []*T) []T {
|
||||
result := make([]T, 0, len(s))
|
||||
for _, v := range s {
|
||||
if v != nil {
|
||||
result = append(result, *v)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
52
internal/fantasy/providers/openai/error.go
Normal file
52
internal/fantasy/providers/openai/error.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package openai
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/openai/openai-go/v2"
|
||||
)
|
||||
|
||||
func toProviderErr(err error) error {
|
||||
var apiErr *openai.Error
|
||||
if errors.As(err, &apiErr) {
|
||||
return &fantasy.ProviderError{
|
||||
Title: cmp.Or(fantasy.ErrorTitleForStatusCode(apiErr.StatusCode), "provider request failed"),
|
||||
Message: toProviderErrMessage(apiErr),
|
||||
Cause: apiErr,
|
||||
URL: apiErr.Request.URL.String(),
|
||||
StatusCode: apiErr.StatusCode,
|
||||
RequestBody: apiErr.DumpRequest(true),
|
||||
ResponseHeaders: toHeaderMap(apiErr.Response.Header),
|
||||
ResponseBody: apiErr.DumpResponse(true),
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func toProviderErrMessage(apiErr *openai.Error) string {
|
||||
if apiErr.Message != "" {
|
||||
return apiErr.Message
|
||||
}
|
||||
|
||||
// For some OpenAI-compatible providers, the SDK is not always able to parse
|
||||
// the error message correctly.
|
||||
// Fallback to returning the raw response body in such cases.
|
||||
data, _ := io.ReadAll(apiErr.Response.Body)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func toHeaderMap(in http.Header) (out map[string]string) {
|
||||
out = make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
if l := len(v); l > 0 {
|
||||
out[k] = v[l-1]
|
||||
in[strings.ToLower(k)] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
948
internal/fantasy/providers/openai/language_model.go
Normal file
948
internal/fantasy/providers/openai/language_model.go
Normal file
|
|
@ -0,0 +1,948 @@
|
|||
package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"charm.land/fantasy/object"
|
||||
"charm.land/fantasy/schema"
|
||||
xjson "github.com/charmbracelet/x/json"
|
||||
"github.com/google/uuid"
|
||||
"github.com/openai/openai-go/v2"
|
||||
"github.com/openai/openai-go/v2/packages/param"
|
||||
"github.com/openai/openai-go/v2/shared"
|
||||
)
|
||||
|
||||
type languageModel struct {
|
||||
provider string
|
||||
modelID string
|
||||
client openai.Client
|
||||
objectMode fantasy.ObjectMode
|
||||
prepareCallFunc LanguageModelPrepareCallFunc
|
||||
mapFinishReasonFunc LanguageModelMapFinishReasonFunc
|
||||
extraContentFunc LanguageModelExtraContentFunc
|
||||
usageFunc LanguageModelUsageFunc
|
||||
streamUsageFunc LanguageModelStreamUsageFunc
|
||||
streamExtraFunc LanguageModelStreamExtraFunc
|
||||
streamProviderMetadataFunc LanguageModelStreamProviderMetadataFunc
|
||||
toPromptFunc LanguageModelToPromptFunc
|
||||
}
|
||||
|
||||
// LanguageModelOption is a function that configures a languageModel.
|
||||
type LanguageModelOption = func(*languageModel)
|
||||
|
||||
// WithLanguageModelPrepareCallFunc sets the prepare call function for the language model.
|
||||
func WithLanguageModelPrepareCallFunc(fn LanguageModelPrepareCallFunc) LanguageModelOption {
|
||||
return func(l *languageModel) {
|
||||
l.prepareCallFunc = fn
|
||||
}
|
||||
}
|
||||
|
||||
// WithLanguageModelMapFinishReasonFunc sets the map finish reason function for the language model.
|
||||
func WithLanguageModelMapFinishReasonFunc(fn LanguageModelMapFinishReasonFunc) LanguageModelOption {
|
||||
return func(l *languageModel) {
|
||||
l.mapFinishReasonFunc = fn
|
||||
}
|
||||
}
|
||||
|
||||
// WithLanguageModelExtraContentFunc sets the extra content function for the language model.
|
||||
func WithLanguageModelExtraContentFunc(fn LanguageModelExtraContentFunc) LanguageModelOption {
|
||||
return func(l *languageModel) {
|
||||
l.extraContentFunc = fn
|
||||
}
|
||||
}
|
||||
|
||||
// WithLanguageModelStreamExtraFunc sets the stream extra function for the language model.
|
||||
func WithLanguageModelStreamExtraFunc(fn LanguageModelStreamExtraFunc) LanguageModelOption {
|
||||
return func(l *languageModel) {
|
||||
l.streamExtraFunc = fn
|
||||
}
|
||||
}
|
||||
|
||||
// WithLanguageModelUsageFunc sets the usage function for the language model.
|
||||
func WithLanguageModelUsageFunc(fn LanguageModelUsageFunc) LanguageModelOption {
|
||||
return func(l *languageModel) {
|
||||
l.usageFunc = fn
|
||||
}
|
||||
}
|
||||
|
||||
// WithLanguageModelStreamUsageFunc sets the stream usage function for the language model.
|
||||
func WithLanguageModelStreamUsageFunc(fn LanguageModelStreamUsageFunc) LanguageModelOption {
|
||||
return func(l *languageModel) {
|
||||
l.streamUsageFunc = fn
|
||||
}
|
||||
}
|
||||
|
||||
// WithLanguageModelToPromptFunc sets the to prompt function for the language model.
|
||||
func WithLanguageModelToPromptFunc(fn LanguageModelToPromptFunc) LanguageModelOption {
|
||||
return func(l *languageModel) {
|
||||
l.toPromptFunc = fn
|
||||
}
|
||||
}
|
||||
|
||||
// WithLanguageModelObjectMode sets the object generation mode.
|
||||
func WithLanguageModelObjectMode(om fantasy.ObjectMode) LanguageModelOption {
|
||||
return func(l *languageModel) {
|
||||
// not supported
|
||||
if om == fantasy.ObjectModeJSON {
|
||||
om = fantasy.ObjectModeAuto
|
||||
}
|
||||
l.objectMode = om
|
||||
}
|
||||
}
|
||||
|
||||
func newLanguageModel(modelID string, provider string, client openai.Client, opts ...LanguageModelOption) languageModel {
|
||||
model := languageModel{
|
||||
modelID: modelID,
|
||||
provider: provider,
|
||||
client: client,
|
||||
objectMode: fantasy.ObjectModeAuto,
|
||||
prepareCallFunc: DefaultPrepareCallFunc,
|
||||
mapFinishReasonFunc: DefaultMapFinishReasonFunc,
|
||||
usageFunc: DefaultUsageFunc,
|
||||
streamUsageFunc: DefaultStreamUsageFunc,
|
||||
streamProviderMetadataFunc: DefaultStreamProviderMetadataFunc,
|
||||
toPromptFunc: DefaultToPrompt,
|
||||
}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&model)
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
type streamToolCall struct {
|
||||
id string
|
||||
name string
|
||||
arguments string
|
||||
hasFinished bool
|
||||
}
|
||||
|
||||
// Model implements fantasy.LanguageModel.
|
||||
func (o languageModel) Model() string {
|
||||
return o.modelID
|
||||
}
|
||||
|
||||
// Provider implements fantasy.LanguageModel.
|
||||
func (o languageModel) Provider() string {
|
||||
return o.provider
|
||||
}
|
||||
|
||||
func (o languageModel) prepareParams(call fantasy.Call) (*openai.ChatCompletionNewParams, []fantasy.CallWarning, error) {
|
||||
params := &openai.ChatCompletionNewParams{}
|
||||
messages, warnings := o.toPromptFunc(call.Prompt, o.provider, o.modelID)
|
||||
if call.TopK != nil {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeUnsupportedSetting,
|
||||
Setting: "top_k",
|
||||
})
|
||||
}
|
||||
|
||||
if call.MaxOutputTokens != nil {
|
||||
params.MaxTokens = param.NewOpt(*call.MaxOutputTokens)
|
||||
}
|
||||
if call.Temperature != nil {
|
||||
params.Temperature = param.NewOpt(*call.Temperature)
|
||||
}
|
||||
if call.TopP != nil {
|
||||
params.TopP = param.NewOpt(*call.TopP)
|
||||
}
|
||||
if call.FrequencyPenalty != nil {
|
||||
params.FrequencyPenalty = param.NewOpt(*call.FrequencyPenalty)
|
||||
}
|
||||
if call.PresencePenalty != nil {
|
||||
params.PresencePenalty = param.NewOpt(*call.PresencePenalty)
|
||||
}
|
||||
|
||||
if isReasoningModel(o.modelID) {
|
||||
// remove unsupported settings for reasoning models
|
||||
// see https://platform.openai.com/docs/guides/reasoning#limitations
|
||||
if call.Temperature != nil {
|
||||
params.Temperature = param.Opt[float64]{}
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeUnsupportedSetting,
|
||||
Setting: "temperature",
|
||||
Details: "temperature is not supported for reasoning models",
|
||||
})
|
||||
}
|
||||
if call.TopP != nil {
|
||||
params.TopP = param.Opt[float64]{}
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeUnsupportedSetting,
|
||||
Setting: "TopP",
|
||||
Details: "TopP is not supported for reasoning models",
|
||||
})
|
||||
}
|
||||
if call.FrequencyPenalty != nil {
|
||||
params.FrequencyPenalty = param.Opt[float64]{}
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeUnsupportedSetting,
|
||||
Setting: "FrequencyPenalty",
|
||||
Details: "FrequencyPenalty is not supported for reasoning models",
|
||||
})
|
||||
}
|
||||
if call.PresencePenalty != nil {
|
||||
params.PresencePenalty = param.Opt[float64]{}
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeUnsupportedSetting,
|
||||
Setting: "PresencePenalty",
|
||||
Details: "PresencePenalty is not supported for reasoning models",
|
||||
})
|
||||
}
|
||||
|
||||
// reasoning models use max_completion_tokens instead of max_tokens
|
||||
if call.MaxOutputTokens != nil {
|
||||
if !params.MaxCompletionTokens.Valid() {
|
||||
params.MaxCompletionTokens = param.NewOpt(*call.MaxOutputTokens)
|
||||
}
|
||||
params.MaxTokens = param.Opt[int64]{}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle search preview models
|
||||
if isSearchPreviewModel(o.modelID) {
|
||||
if call.Temperature != nil {
|
||||
params.Temperature = param.Opt[float64]{}
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeUnsupportedSetting,
|
||||
Setting: "temperature",
|
||||
Details: "temperature is not supported for the search preview models and has been removed.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
optionsWarnings, err := o.prepareCallFunc(o, params, call)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if len(optionsWarnings) > 0 {
|
||||
warnings = append(warnings, optionsWarnings...)
|
||||
}
|
||||
|
||||
params.Messages = messages
|
||||
params.Model = o.modelID
|
||||
|
||||
if len(call.Tools) > 0 {
|
||||
tools, toolChoice, toolWarnings := toOpenAiTools(call.Tools, call.ToolChoice)
|
||||
params.Tools = tools
|
||||
if toolChoice != nil {
|
||||
params.ToolChoice = *toolChoice
|
||||
}
|
||||
warnings = append(warnings, toolWarnings...)
|
||||
}
|
||||
return params, warnings, nil
|
||||
}
|
||||
|
||||
// Generate implements fantasy.LanguageModel.
|
||||
func (o languageModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) {
|
||||
params, warnings, err := o.prepareParams(call)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response, err := o.client.Chat.Completions.New(ctx, *params)
|
||||
if err != nil {
|
||||
return nil, toProviderErr(err)
|
||||
}
|
||||
|
||||
if len(response.Choices) == 0 {
|
||||
return nil, &fantasy.Error{Title: "no response", Message: "no response generated"}
|
||||
}
|
||||
choice := response.Choices[0]
|
||||
content := make([]fantasy.Content, 0, 1+len(choice.Message.ToolCalls)+len(choice.Message.Annotations))
|
||||
text := choice.Message.Content
|
||||
if text != "" {
|
||||
content = append(content, fantasy.TextContent{
|
||||
Text: text,
|
||||
})
|
||||
}
|
||||
if o.extraContentFunc != nil {
|
||||
extraContent := o.extraContentFunc(choice)
|
||||
content = append(content, extraContent...)
|
||||
}
|
||||
for _, tc := range choice.Message.ToolCalls {
|
||||
toolCallID := tc.ID
|
||||
content = append(content, fantasy.ToolCallContent{
|
||||
ProviderExecuted: false,
|
||||
ToolCallID: toolCallID,
|
||||
ToolName: tc.Function.Name,
|
||||
Input: tc.Function.Arguments,
|
||||
})
|
||||
}
|
||||
for _, annotation := range choice.Message.Annotations {
|
||||
if annotation.Type == "url_citation" {
|
||||
content = append(content, fantasy.SourceContent{
|
||||
SourceType: fantasy.SourceTypeURL,
|
||||
ID: uuid.NewString(),
|
||||
URL: annotation.URLCitation.URL,
|
||||
Title: annotation.URLCitation.Title,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
usage, providerMetadata := o.usageFunc(*response)
|
||||
|
||||
mappedFinishReason := o.mapFinishReasonFunc(choice.FinishReason)
|
||||
if len(choice.Message.ToolCalls) > 0 {
|
||||
mappedFinishReason = fantasy.FinishReasonToolCalls
|
||||
}
|
||||
return &fantasy.Response{
|
||||
Content: content,
|
||||
Usage: usage,
|
||||
FinishReason: mappedFinishReason,
|
||||
ProviderMetadata: fantasy.ProviderMetadata{
|
||||
Name: providerMetadata,
|
||||
},
|
||||
Warnings: warnings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Stream implements fantasy.LanguageModel.
|
||||
func (o languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
params, warnings, err := o.prepareParams(call)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params.StreamOptions = openai.ChatCompletionStreamOptionsParam{
|
||||
IncludeUsage: openai.Bool(true),
|
||||
}
|
||||
|
||||
stream := o.client.Chat.Completions.NewStreaming(ctx, *params)
|
||||
isActiveText := false
|
||||
toolCalls := make(map[int64]streamToolCall)
|
||||
|
||||
providerMetadata := fantasy.ProviderMetadata{
|
||||
Name: &ProviderMetadata{},
|
||||
}
|
||||
acc := openai.ChatCompletionAccumulator{}
|
||||
extraContext := make(map[string]any)
|
||||
var usage fantasy.Usage
|
||||
var finishReason string
|
||||
return func(yield func(fantasy.StreamPart) bool) {
|
||||
if len(warnings) > 0 {
|
||||
if !yield(fantasy.StreamPart{
|
||||
Type: fantasy.StreamPartTypeWarnings,
|
||||
Warnings: warnings,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
for stream.Next() {
|
||||
chunk := stream.Current()
|
||||
acc.AddChunk(chunk)
|
||||
usage, providerMetadata = o.streamUsageFunc(chunk, extraContext, providerMetadata)
|
||||
if len(chunk.Choices) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, choice := range chunk.Choices {
|
||||
if choice.FinishReason != "" {
|
||||
finishReason = choice.FinishReason
|
||||
}
|
||||
switch {
|
||||
case choice.Delta.Content != "":
|
||||
if !isActiveText {
|
||||
isActiveText = true
|
||||
if !yield(fantasy.StreamPart{
|
||||
Type: fantasy.StreamPartTypeTextStart,
|
||||
ID: "0",
|
||||
}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if !yield(fantasy.StreamPart{
|
||||
Type: fantasy.StreamPartTypeTextDelta,
|
||||
ID: "0",
|
||||
Delta: choice.Delta.Content,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
case len(choice.Delta.ToolCalls) > 0:
|
||||
if isActiveText {
|
||||
isActiveText = false
|
||||
if !yield(fantasy.StreamPart{
|
||||
Type: fantasy.StreamPartTypeTextEnd,
|
||||
ID: "0",
|
||||
}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for _, toolCallDelta := range choice.Delta.ToolCalls {
|
||||
if existingToolCall, ok := toolCalls[toolCallDelta.Index]; ok {
|
||||
if existingToolCall.hasFinished {
|
||||
continue
|
||||
}
|
||||
if toolCallDelta.Function.Arguments != "" {
|
||||
existingToolCall.arguments += toolCallDelta.Function.Arguments
|
||||
}
|
||||
if !yield(fantasy.StreamPart{
|
||||
Type: fantasy.StreamPartTypeToolInputDelta,
|
||||
ID: existingToolCall.id,
|
||||
Delta: toolCallDelta.Function.Arguments,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
toolCalls[toolCallDelta.Index] = existingToolCall
|
||||
if xjson.IsValid(existingToolCall.arguments) {
|
||||
if !yield(fantasy.StreamPart{
|
||||
Type: fantasy.StreamPartTypeToolInputEnd,
|
||||
ID: existingToolCall.id,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
|
||||
if !yield(fantasy.StreamPart{
|
||||
Type: fantasy.StreamPartTypeToolCall,
|
||||
ID: existingToolCall.id,
|
||||
ToolCallName: existingToolCall.name,
|
||||
ToolCallInput: existingToolCall.arguments,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
existingToolCall.hasFinished = true
|
||||
toolCalls[toolCallDelta.Index] = existingToolCall
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
if toolCallDelta.Type != "function" {
|
||||
err = &fantasy.Error{Title: "invalid provider response", Message: "expected 'function' type."}
|
||||
}
|
||||
if toolCallDelta.ID == "" {
|
||||
err = &fantasy.Error{Title: "invalid provider response", Message: "expected 'id' to be a string."}
|
||||
}
|
||||
if toolCallDelta.Function.Name == "" {
|
||||
err = &fantasy.Error{Title: "invalid provider response", Message: "expected 'function.name' to be a string."}
|
||||
}
|
||||
if err != nil {
|
||||
yield(fantasy.StreamPart{
|
||||
Type: fantasy.StreamPartTypeError,
|
||||
Error: toProviderErr(stream.Err()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !yield(fantasy.StreamPart{
|
||||
Type: fantasy.StreamPartTypeToolInputStart,
|
||||
ID: toolCallDelta.ID,
|
||||
ToolCallName: toolCallDelta.Function.Name,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
toolCalls[toolCallDelta.Index] = streamToolCall{
|
||||
id: toolCallDelta.ID,
|
||||
name: toolCallDelta.Function.Name,
|
||||
arguments: toolCallDelta.Function.Arguments,
|
||||
}
|
||||
|
||||
exTc := toolCalls[toolCallDelta.Index]
|
||||
if exTc.arguments != "" {
|
||||
if !yield(fantasy.StreamPart{
|
||||
Type: fantasy.StreamPartTypeToolInputDelta,
|
||||
ID: exTc.id,
|
||||
Delta: exTc.arguments,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
if xjson.IsValid(toolCalls[toolCallDelta.Index].arguments) {
|
||||
if !yield(fantasy.StreamPart{
|
||||
Type: fantasy.StreamPartTypeToolInputEnd,
|
||||
ID: toolCallDelta.ID,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
|
||||
if !yield(fantasy.StreamPart{
|
||||
Type: fantasy.StreamPartTypeToolCall,
|
||||
ID: exTc.id,
|
||||
ToolCallName: exTc.name,
|
||||
ToolCallInput: exTc.arguments,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
exTc.hasFinished = true
|
||||
toolCalls[toolCallDelta.Index] = exTc
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if o.streamExtraFunc != nil {
|
||||
updatedContext, shouldContinue := o.streamExtraFunc(chunk, yield, extraContext)
|
||||
if !shouldContinue {
|
||||
return
|
||||
}
|
||||
extraContext = updatedContext
|
||||
}
|
||||
}
|
||||
|
||||
for _, choice := range chunk.Choices {
|
||||
if annotations := parseAnnotationsFromDelta(choice.Delta); len(annotations) > 0 {
|
||||
for _, annotation := range annotations {
|
||||
if annotation.Type == "url_citation" {
|
||||
if !yield(fantasy.StreamPart{
|
||||
Type: fantasy.StreamPartTypeSource,
|
||||
ID: uuid.NewString(),
|
||||
SourceType: fantasy.SourceTypeURL,
|
||||
URL: annotation.URLCitation.URL,
|
||||
Title: annotation.URLCitation.Title,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
err := stream.Err()
|
||||
if err == nil || errors.Is(err, io.EOF) {
|
||||
if isActiveText {
|
||||
isActiveText = false
|
||||
if !yield(fantasy.StreamPart{
|
||||
Type: fantasy.StreamPartTypeTextEnd,
|
||||
ID: "0",
|
||||
}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if len(acc.Choices) > 0 {
|
||||
choice := acc.Choices[0]
|
||||
providerMetadata = o.streamProviderMetadataFunc(choice, providerMetadata)
|
||||
|
||||
for _, annotation := range choice.Message.Annotations {
|
||||
if annotation.Type == "url_citation" {
|
||||
if !yield(fantasy.StreamPart{
|
||||
Type: fantasy.StreamPartTypeSource,
|
||||
ID: acc.ID,
|
||||
SourceType: fantasy.SourceTypeURL,
|
||||
URL: annotation.URLCitation.URL,
|
||||
Title: annotation.URLCitation.Title,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
mappedFinishReason := o.mapFinishReasonFunc(finishReason)
|
||||
if len(acc.Choices) > 0 {
|
||||
choice := acc.Choices[0]
|
||||
if len(choice.Message.ToolCalls) > 0 {
|
||||
mappedFinishReason = fantasy.FinishReasonToolCalls
|
||||
}
|
||||
}
|
||||
yield(fantasy.StreamPart{
|
||||
Type: fantasy.StreamPartTypeFinish,
|
||||
Usage: usage,
|
||||
FinishReason: mappedFinishReason,
|
||||
ProviderMetadata: providerMetadata,
|
||||
})
|
||||
return
|
||||
} else { //nolint: revive
|
||||
yield(fantasy.StreamPart{
|
||||
Type: fantasy.StreamPartTypeError,
|
||||
Error: toProviderErr(err),
|
||||
})
|
||||
return
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isReasoningModel(modelID string) bool {
|
||||
return strings.HasPrefix(modelID, "o1") || strings.Contains(modelID, "-o1") ||
|
||||
strings.HasPrefix(modelID, "o3") || strings.Contains(modelID, "-o3") ||
|
||||
strings.HasPrefix(modelID, "o4") || strings.Contains(modelID, "-o4") ||
|
||||
strings.HasPrefix(modelID, "oss") || strings.Contains(modelID, "-oss") ||
|
||||
strings.Contains(modelID, "gpt-5") || strings.Contains(modelID, "gpt-5-chat")
|
||||
}
|
||||
|
||||
func isSearchPreviewModel(modelID string) bool {
|
||||
return strings.Contains(modelID, "search-preview")
|
||||
}
|
||||
|
||||
func supportsFlexProcessing(modelID string) bool {
|
||||
return strings.HasPrefix(modelID, "o3") || strings.Contains(modelID, "-o3") ||
|
||||
strings.Contains(modelID, "o4-mini") || strings.Contains(modelID, "gpt-5")
|
||||
}
|
||||
|
||||
func supportsPriorityProcessing(modelID string) bool {
|
||||
return strings.Contains(modelID, "gpt-4") || strings.Contains(modelID, "gpt-5") ||
|
||||
strings.Contains(modelID, "gpt-5-mini") || strings.HasPrefix(modelID, "o3") ||
|
||||
strings.Contains(modelID, "-o3") || strings.Contains(modelID, "o4-mini")
|
||||
}
|
||||
|
||||
func toOpenAiTools(tools []fantasy.Tool, toolChoice *fantasy.ToolChoice) (openAiTools []openai.ChatCompletionToolUnionParam, openAiToolChoice *openai.ChatCompletionToolChoiceOptionUnionParam, warnings []fantasy.CallWarning) {
|
||||
for _, tool := range tools {
|
||||
if tool.GetType() == fantasy.ToolTypeFunction {
|
||||
ft, ok := tool.(fantasy.FunctionTool)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
openAiTools = append(openAiTools, openai.ChatCompletionToolUnionParam{
|
||||
OfFunction: &openai.ChatCompletionFunctionToolParam{
|
||||
Function: shared.FunctionDefinitionParam{
|
||||
Name: ft.Name,
|
||||
Description: param.NewOpt(ft.Description),
|
||||
Parameters: openai.FunctionParameters(ft.InputSchema),
|
||||
Strict: param.NewOpt(false),
|
||||
},
|
||||
Type: "function",
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeUnsupportedTool,
|
||||
Tool: tool,
|
||||
Message: "tool is not supported",
|
||||
})
|
||||
}
|
||||
if toolChoice == nil {
|
||||
return openAiTools, openAiToolChoice, warnings
|
||||
}
|
||||
|
||||
switch *toolChoice {
|
||||
case fantasy.ToolChoiceAuto:
|
||||
openAiToolChoice = &openai.ChatCompletionToolChoiceOptionUnionParam{
|
||||
OfAuto: param.NewOpt("auto"),
|
||||
}
|
||||
case fantasy.ToolChoiceNone:
|
||||
openAiToolChoice = &openai.ChatCompletionToolChoiceOptionUnionParam{
|
||||
OfAuto: param.NewOpt("none"),
|
||||
}
|
||||
case fantasy.ToolChoiceRequired:
|
||||
openAiToolChoice = &openai.ChatCompletionToolChoiceOptionUnionParam{
|
||||
OfAuto: param.NewOpt("required"),
|
||||
}
|
||||
default:
|
||||
openAiToolChoice = &openai.ChatCompletionToolChoiceOptionUnionParam{
|
||||
OfFunctionToolChoice: &openai.ChatCompletionNamedToolChoiceParam{
|
||||
Type: "function",
|
||||
Function: openai.ChatCompletionNamedToolChoiceFunctionParam{
|
||||
Name: string(*toolChoice),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return openAiTools, openAiToolChoice, warnings
|
||||
}
|
||||
|
||||
// parseAnnotationsFromDelta parses annotations from the raw JSON of a delta.
|
||||
func parseAnnotationsFromDelta(delta openai.ChatCompletionChunkChoiceDelta) []openai.ChatCompletionMessageAnnotation {
|
||||
var annotations []openai.ChatCompletionMessageAnnotation
|
||||
|
||||
// Parse the raw JSON to extract annotations
|
||||
var deltaData map[string]any
|
||||
if err := json.Unmarshal([]byte(delta.RawJSON()), &deltaData); err != nil {
|
||||
return annotations
|
||||
}
|
||||
|
||||
// Check if annotations exist in the delta
|
||||
if annotationsData, ok := deltaData["annotations"].([]any); ok {
|
||||
for _, annotationData := range annotationsData {
|
||||
if annotationMap, ok := annotationData.(map[string]any); ok {
|
||||
if annotationType, ok := annotationMap["type"].(string); ok && annotationType == "url_citation" {
|
||||
if urlCitationData, ok := annotationMap["url_citation"].(map[string]any); ok {
|
||||
url, urlOk := urlCitationData["url"].(string)
|
||||
title, titleOk := urlCitationData["title"].(string)
|
||||
if urlOk && titleOk {
|
||||
annotation := openai.ChatCompletionMessageAnnotation{
|
||||
Type: "url_citation",
|
||||
URLCitation: openai.ChatCompletionMessageAnnotationURLCitation{
|
||||
URL: url,
|
||||
Title: title,
|
||||
},
|
||||
}
|
||||
annotations = append(annotations, annotation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return annotations
|
||||
}
|
||||
|
||||
// GenerateObject implements fantasy.LanguageModel.
|
||||
func (o languageModel) GenerateObject(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
|
||||
switch o.objectMode {
|
||||
case fantasy.ObjectModeText:
|
||||
return object.GenerateWithText(ctx, o, call)
|
||||
case fantasy.ObjectModeTool:
|
||||
return object.GenerateWithTool(ctx, o, call)
|
||||
default:
|
||||
return o.generateObjectWithJSONMode(ctx, call)
|
||||
}
|
||||
}
|
||||
|
||||
// StreamObject implements fantasy.LanguageModel.
|
||||
func (o languageModel) StreamObject(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
|
||||
switch o.objectMode {
|
||||
case fantasy.ObjectModeTool:
|
||||
return object.StreamWithTool(ctx, o, call)
|
||||
case fantasy.ObjectModeText:
|
||||
return object.StreamWithText(ctx, o, call)
|
||||
default:
|
||||
return o.streamObjectWithJSONMode(ctx, call)
|
||||
}
|
||||
}
|
||||
|
||||
func (o languageModel) generateObjectWithJSONMode(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
|
||||
jsonSchemaMap := schema.ToMap(call.Schema)
|
||||
|
||||
addAdditionalPropertiesFalse(jsonSchemaMap)
|
||||
|
||||
schemaName := call.SchemaName
|
||||
if schemaName == "" {
|
||||
schemaName = "response"
|
||||
}
|
||||
|
||||
fantasyCall := fantasy.Call{
|
||||
Prompt: call.Prompt,
|
||||
MaxOutputTokens: call.MaxOutputTokens,
|
||||
Temperature: call.Temperature,
|
||||
TopP: call.TopP,
|
||||
PresencePenalty: call.PresencePenalty,
|
||||
FrequencyPenalty: call.FrequencyPenalty,
|
||||
ProviderOptions: call.ProviderOptions,
|
||||
}
|
||||
|
||||
params, warnings, err := o.prepareParams(fantasyCall)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params.ResponseFormat = openai.ChatCompletionNewParamsResponseFormatUnion{
|
||||
OfJSONSchema: &shared.ResponseFormatJSONSchemaParam{
|
||||
JSONSchema: shared.ResponseFormatJSONSchemaJSONSchemaParam{
|
||||
Name: schemaName,
|
||||
Description: param.NewOpt(call.SchemaDescription),
|
||||
Schema: jsonSchemaMap,
|
||||
Strict: param.NewOpt(true),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
response, err := o.client.Chat.Completions.New(ctx, *params)
|
||||
if err != nil {
|
||||
return nil, toProviderErr(err)
|
||||
}
|
||||
|
||||
if len(response.Choices) == 0 {
|
||||
usage, _ := o.usageFunc(*response)
|
||||
return nil, &fantasy.NoObjectGeneratedError{
|
||||
RawText: "",
|
||||
ParseError: fmt.Errorf("no choices in response"),
|
||||
Usage: usage,
|
||||
FinishReason: fantasy.FinishReasonUnknown,
|
||||
}
|
||||
}
|
||||
|
||||
choice := response.Choices[0]
|
||||
jsonText := choice.Message.Content
|
||||
|
||||
var obj any
|
||||
if call.RepairText != nil {
|
||||
obj, err = schema.ParseAndValidateWithRepair(ctx, jsonText, call.Schema, call.RepairText)
|
||||
} else {
|
||||
obj, err = schema.ParseAndValidate(jsonText, call.Schema)
|
||||
}
|
||||
|
||||
usage, _ := o.usageFunc(*response)
|
||||
finishReason := o.mapFinishReasonFunc(choice.FinishReason)
|
||||
|
||||
if err != nil {
|
||||
if nogErr, ok := err.(*fantasy.NoObjectGeneratedError); ok {
|
||||
nogErr.Usage = usage
|
||||
nogErr.FinishReason = finishReason
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &fantasy.ObjectResponse{
|
||||
Object: obj,
|
||||
RawText: jsonText,
|
||||
Usage: usage,
|
||||
FinishReason: finishReason,
|
||||
Warnings: warnings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (o languageModel) streamObjectWithJSONMode(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
|
||||
jsonSchemaMap := schema.ToMap(call.Schema)
|
||||
|
||||
addAdditionalPropertiesFalse(jsonSchemaMap)
|
||||
|
||||
schemaName := call.SchemaName
|
||||
if schemaName == "" {
|
||||
schemaName = "response"
|
||||
}
|
||||
|
||||
fantasyCall := fantasy.Call{
|
||||
Prompt: call.Prompt,
|
||||
MaxOutputTokens: call.MaxOutputTokens,
|
||||
Temperature: call.Temperature,
|
||||
TopP: call.TopP,
|
||||
PresencePenalty: call.PresencePenalty,
|
||||
FrequencyPenalty: call.FrequencyPenalty,
|
||||
ProviderOptions: call.ProviderOptions,
|
||||
}
|
||||
|
||||
params, warnings, err := o.prepareParams(fantasyCall)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params.ResponseFormat = openai.ChatCompletionNewParamsResponseFormatUnion{
|
||||
OfJSONSchema: &shared.ResponseFormatJSONSchemaParam{
|
||||
JSONSchema: shared.ResponseFormatJSONSchemaJSONSchemaParam{
|
||||
Name: schemaName,
|
||||
Description: param.NewOpt(call.SchemaDescription),
|
||||
Schema: jsonSchemaMap,
|
||||
Strict: param.NewOpt(true),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
params.StreamOptions = openai.ChatCompletionStreamOptionsParam{
|
||||
IncludeUsage: openai.Bool(true),
|
||||
}
|
||||
|
||||
stream := o.client.Chat.Completions.NewStreaming(ctx, *params)
|
||||
|
||||
return func(yield func(fantasy.ObjectStreamPart) bool) {
|
||||
if len(warnings) > 0 {
|
||||
if !yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeObject,
|
||||
Warnings: warnings,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var accumulated string
|
||||
var lastParsedObject any
|
||||
var usage fantasy.Usage
|
||||
var finishReason fantasy.FinishReason
|
||||
var providerMetadata fantasy.ProviderMetadata
|
||||
var streamErr error
|
||||
|
||||
for stream.Next() {
|
||||
chunk := stream.Current()
|
||||
|
||||
// Update usage
|
||||
usage, providerMetadata = o.streamUsageFunc(chunk, make(map[string]any), providerMetadata)
|
||||
|
||||
if len(chunk.Choices) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
choice := chunk.Choices[0]
|
||||
if choice.FinishReason != "" {
|
||||
finishReason = o.mapFinishReasonFunc(choice.FinishReason)
|
||||
}
|
||||
|
||||
if choice.Delta.Content != "" {
|
||||
accumulated += choice.Delta.Content
|
||||
|
||||
obj, state, parseErr := schema.ParsePartialJSON(accumulated)
|
||||
|
||||
if state == schema.ParseStateSuccessful || state == schema.ParseStateRepaired {
|
||||
if err := schema.ValidateAgainstSchema(obj, call.Schema); err == nil {
|
||||
if !reflect.DeepEqual(obj, lastParsedObject) {
|
||||
if !yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeObject,
|
||||
Object: obj,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
lastParsedObject = obj
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if state == schema.ParseStateFailed && call.RepairText != nil {
|
||||
repairedText, repairErr := call.RepairText(ctx, accumulated, parseErr)
|
||||
if repairErr == nil {
|
||||
obj2, state2, _ := schema.ParsePartialJSON(repairedText)
|
||||
if (state2 == schema.ParseStateSuccessful || state2 == schema.ParseStateRepaired) &&
|
||||
schema.ValidateAgainstSchema(obj2, call.Schema) == nil {
|
||||
if !reflect.DeepEqual(obj2, lastParsedObject) {
|
||||
if !yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeObject,
|
||||
Object: obj2,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
lastParsedObject = obj2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := stream.Err()
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
streamErr = toProviderErr(err)
|
||||
yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeError,
|
||||
Error: streamErr,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if lastParsedObject != nil {
|
||||
yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeFinish,
|
||||
Usage: usage,
|
||||
FinishReason: finishReason,
|
||||
ProviderMetadata: providerMetadata,
|
||||
})
|
||||
} else {
|
||||
yield(fantasy.ObjectStreamPart{
|
||||
Type: fantasy.ObjectStreamPartTypeError,
|
||||
Error: &fantasy.NoObjectGeneratedError{
|
||||
RawText: accumulated,
|
||||
ParseError: fmt.Errorf("no valid object generated in stream"),
|
||||
Usage: usage,
|
||||
FinishReason: finishReason,
|
||||
},
|
||||
})
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
// addAdditionalPropertiesFalse recursively adds "additionalProperties": false to all object schemas.
|
||||
// This is required by OpenAI's strict mode for structured outputs.
|
||||
func addAdditionalPropertiesFalse(schema map[string]any) {
|
||||
if schema["type"] == "object" {
|
||||
if _, hasAdditional := schema["additionalProperties"]; !hasAdditional {
|
||||
schema["additionalProperties"] = false
|
||||
}
|
||||
|
||||
// Recursively process nested properties
|
||||
if properties, ok := schema["properties"].(map[string]any); ok {
|
||||
for _, propValue := range properties {
|
||||
if propSchema, ok := propValue.(map[string]any); ok {
|
||||
addAdditionalPropertiesFalse(propSchema)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle array items
|
||||
if items, ok := schema["items"].(map[string]any); ok {
|
||||
addAdditionalPropertiesFalse(items)
|
||||
}
|
||||
}
|
||||
578
internal/fantasy/providers/openai/language_model_hooks.go
Normal file
578
internal/fantasy/providers/openai/language_model_hooks.go
Normal file
|
|
@ -0,0 +1,578 @@
|
|||
package openai
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/openai/openai-go/v2"
|
||||
"github.com/openai/openai-go/v2/packages/param"
|
||||
"github.com/openai/openai-go/v2/shared"
|
||||
)
|
||||
|
||||
// LanguageModelPrepareCallFunc is a function that prepares the call for the language model.
|
||||
type LanguageModelPrepareCallFunc = func(model fantasy.LanguageModel, params *openai.ChatCompletionNewParams, call fantasy.Call) ([]fantasy.CallWarning, error)
|
||||
|
||||
// LanguageModelMapFinishReasonFunc is a function that maps the finish reason for the language model.
|
||||
type LanguageModelMapFinishReasonFunc = func(finishReason string) fantasy.FinishReason
|
||||
|
||||
// LanguageModelUsageFunc is a function that calculates usage for the language model.
|
||||
type LanguageModelUsageFunc = func(choice openai.ChatCompletion) (fantasy.Usage, fantasy.ProviderOptionsData)
|
||||
|
||||
// LanguageModelExtraContentFunc is a function that adds extra content for the language model.
|
||||
type LanguageModelExtraContentFunc = func(choice openai.ChatCompletionChoice) []fantasy.Content
|
||||
|
||||
// LanguageModelStreamExtraFunc is a function that handles stream extra functionality for the language model.
|
||||
type LanguageModelStreamExtraFunc = func(chunk openai.ChatCompletionChunk, yield func(fantasy.StreamPart) bool, ctx map[string]any) (map[string]any, bool)
|
||||
|
||||
// LanguageModelStreamUsageFunc is a function that calculates stream usage for the language model.
|
||||
type LanguageModelStreamUsageFunc = func(chunk openai.ChatCompletionChunk, ctx map[string]any, metadata fantasy.ProviderMetadata) (fantasy.Usage, fantasy.ProviderMetadata)
|
||||
|
||||
// LanguageModelStreamProviderMetadataFunc is a function that handles stream provider metadata for the language model.
|
||||
type LanguageModelStreamProviderMetadataFunc = func(choice openai.ChatCompletionChoice, metadata fantasy.ProviderMetadata) fantasy.ProviderMetadata
|
||||
|
||||
// LanguageModelToPromptFunc is a function that handles converting fantasy prompts to openai sdk messages.
|
||||
type LanguageModelToPromptFunc = func(prompt fantasy.Prompt, provider, model string) ([]openai.ChatCompletionMessageParamUnion, []fantasy.CallWarning)
|
||||
|
||||
// DefaultPrepareCallFunc is the default implementation for preparing a call to the language model.
|
||||
func DefaultPrepareCallFunc(model fantasy.LanguageModel, params *openai.ChatCompletionNewParams, call fantasy.Call) ([]fantasy.CallWarning, error) {
|
||||
if call.ProviderOptions == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var warnings []fantasy.CallWarning
|
||||
providerOptions := &ProviderOptions{}
|
||||
if v, ok := call.ProviderOptions[Name]; ok {
|
||||
providerOptions, ok = v.(*ProviderOptions)
|
||||
if !ok {
|
||||
return nil, &fantasy.Error{Title: "invalid argument", Message: "openai provider options should be *openai.ProviderOptions"}
|
||||
}
|
||||
}
|
||||
|
||||
if providerOptions.LogitBias != nil {
|
||||
params.LogitBias = providerOptions.LogitBias
|
||||
}
|
||||
if providerOptions.LogProbs != nil && providerOptions.TopLogProbs != nil {
|
||||
providerOptions.LogProbs = nil
|
||||
}
|
||||
if providerOptions.LogProbs != nil {
|
||||
params.Logprobs = param.NewOpt(*providerOptions.LogProbs)
|
||||
}
|
||||
if providerOptions.TopLogProbs != nil {
|
||||
params.TopLogprobs = param.NewOpt(*providerOptions.TopLogProbs)
|
||||
}
|
||||
if providerOptions.User != nil {
|
||||
params.User = param.NewOpt(*providerOptions.User)
|
||||
}
|
||||
if providerOptions.ParallelToolCalls != nil {
|
||||
params.ParallelToolCalls = param.NewOpt(*providerOptions.ParallelToolCalls)
|
||||
}
|
||||
if providerOptions.MaxCompletionTokens != nil {
|
||||
params.MaxCompletionTokens = param.NewOpt(*providerOptions.MaxCompletionTokens)
|
||||
}
|
||||
|
||||
if providerOptions.TextVerbosity != nil {
|
||||
params.Verbosity = openai.ChatCompletionNewParamsVerbosity(*providerOptions.TextVerbosity)
|
||||
}
|
||||
if providerOptions.Prediction != nil {
|
||||
// Convert map[string]any to ChatCompletionPredictionContentParam
|
||||
if content, ok := providerOptions.Prediction["content"]; ok {
|
||||
if contentStr, ok := content.(string); ok {
|
||||
params.Prediction = openai.ChatCompletionPredictionContentParam{
|
||||
Content: openai.ChatCompletionPredictionContentContentUnionParam{
|
||||
OfString: param.NewOpt(contentStr),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if providerOptions.Store != nil {
|
||||
params.Store = param.NewOpt(*providerOptions.Store)
|
||||
}
|
||||
if providerOptions.Metadata != nil {
|
||||
// Convert map[string]any to map[string]string
|
||||
metadata := make(map[string]string)
|
||||
for k, v := range providerOptions.Metadata {
|
||||
if str, ok := v.(string); ok {
|
||||
metadata[k] = str
|
||||
}
|
||||
}
|
||||
params.Metadata = metadata
|
||||
}
|
||||
if providerOptions.PromptCacheKey != nil {
|
||||
params.PromptCacheKey = param.NewOpt(*providerOptions.PromptCacheKey)
|
||||
}
|
||||
if providerOptions.SafetyIdentifier != nil {
|
||||
params.SafetyIdentifier = param.NewOpt(*providerOptions.SafetyIdentifier)
|
||||
}
|
||||
if providerOptions.ServiceTier != nil {
|
||||
params.ServiceTier = openai.ChatCompletionNewParamsServiceTier(*providerOptions.ServiceTier)
|
||||
}
|
||||
|
||||
if providerOptions.ReasoningEffort != nil {
|
||||
switch *providerOptions.ReasoningEffort {
|
||||
case ReasoningEffortMinimal:
|
||||
params.ReasoningEffort = shared.ReasoningEffortMinimal
|
||||
case ReasoningEffortLow:
|
||||
params.ReasoningEffort = shared.ReasoningEffortLow
|
||||
case ReasoningEffortMedium:
|
||||
params.ReasoningEffort = shared.ReasoningEffortMedium
|
||||
case ReasoningEffortHigh:
|
||||
params.ReasoningEffort = shared.ReasoningEffortHigh
|
||||
default:
|
||||
return nil, fmt.Errorf("reasoning model `%s` not supported", *providerOptions.ReasoningEffort)
|
||||
}
|
||||
}
|
||||
|
||||
if isReasoningModel(model.Model()) {
|
||||
if providerOptions.LogitBias != nil {
|
||||
params.LogitBias = nil
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeUnsupportedSetting,
|
||||
Setting: "LogitBias",
|
||||
Message: "LogitBias is not supported for reasoning models",
|
||||
})
|
||||
}
|
||||
if providerOptions.LogProbs != nil {
|
||||
params.Logprobs = param.Opt[bool]{}
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeUnsupportedSetting,
|
||||
Setting: "Logprobs",
|
||||
Message: "Logprobs is not supported for reasoning models",
|
||||
})
|
||||
}
|
||||
if providerOptions.TopLogProbs != nil {
|
||||
params.TopLogprobs = param.Opt[int64]{}
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeUnsupportedSetting,
|
||||
Setting: "TopLogprobs",
|
||||
Message: "TopLogprobs is not supported for reasoning models",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Handle service tier validation
|
||||
if providerOptions.ServiceTier != nil {
|
||||
serviceTier := *providerOptions.ServiceTier
|
||||
if serviceTier == "flex" && !supportsFlexProcessing(model.Model()) {
|
||||
params.ServiceTier = ""
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeUnsupportedSetting,
|
||||
Setting: "ServiceTier",
|
||||
Details: "flex processing is only available for o3, o4-mini, and gpt-5 models",
|
||||
})
|
||||
} else if serviceTier == "priority" && !supportsPriorityProcessing(model.Model()) {
|
||||
params.ServiceTier = ""
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeUnsupportedSetting,
|
||||
Setting: "ServiceTier",
|
||||
Details: "priority processing is only available for supported models (gpt-4, gpt-5, gpt-5-mini, o3, o4-mini) and requires Enterprise access. gpt-5-nano is not supported",
|
||||
})
|
||||
}
|
||||
}
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
// DefaultMapFinishReasonFunc is the default implementation for mapping finish reasons.
|
||||
func DefaultMapFinishReasonFunc(finishReason string) fantasy.FinishReason {
|
||||
switch finishReason {
|
||||
case "stop":
|
||||
return fantasy.FinishReasonStop
|
||||
case "length":
|
||||
return fantasy.FinishReasonLength
|
||||
case "content_filter":
|
||||
return fantasy.FinishReasonContentFilter
|
||||
case "function_call", "tool_calls":
|
||||
return fantasy.FinishReasonToolCalls
|
||||
default:
|
||||
return fantasy.FinishReasonUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultUsageFunc is the default implementation for calculating usage.
|
||||
func DefaultUsageFunc(response openai.ChatCompletion) (fantasy.Usage, fantasy.ProviderOptionsData) {
|
||||
completionTokenDetails := response.Usage.CompletionTokensDetails
|
||||
promptTokenDetails := response.Usage.PromptTokensDetails
|
||||
|
||||
// Build provider metadata
|
||||
providerMetadata := &ProviderMetadata{}
|
||||
|
||||
// Add logprobs if available
|
||||
if len(response.Choices) > 0 && len(response.Choices[0].Logprobs.Content) > 0 {
|
||||
providerMetadata.Logprobs = response.Choices[0].Logprobs.Content
|
||||
}
|
||||
|
||||
// Add prediction tokens if available
|
||||
if completionTokenDetails.AcceptedPredictionTokens > 0 || completionTokenDetails.RejectedPredictionTokens > 0 {
|
||||
if completionTokenDetails.AcceptedPredictionTokens > 0 {
|
||||
providerMetadata.AcceptedPredictionTokens = completionTokenDetails.AcceptedPredictionTokens
|
||||
}
|
||||
if completionTokenDetails.RejectedPredictionTokens > 0 {
|
||||
providerMetadata.RejectedPredictionTokens = completionTokenDetails.RejectedPredictionTokens
|
||||
}
|
||||
}
|
||||
return fantasy.Usage{
|
||||
InputTokens: response.Usage.PromptTokens,
|
||||
OutputTokens: response.Usage.CompletionTokens,
|
||||
TotalTokens: response.Usage.TotalTokens,
|
||||
ReasoningTokens: completionTokenDetails.ReasoningTokens,
|
||||
CacheReadTokens: promptTokenDetails.CachedTokens,
|
||||
}, providerMetadata
|
||||
}
|
||||
|
||||
// DefaultStreamUsageFunc is the default implementation for calculating stream usage.
|
||||
func DefaultStreamUsageFunc(chunk openai.ChatCompletionChunk, _ map[string]any, metadata fantasy.ProviderMetadata) (fantasy.Usage, fantasy.ProviderMetadata) {
|
||||
if chunk.Usage.TotalTokens == 0 {
|
||||
return fantasy.Usage{}, nil
|
||||
}
|
||||
streamProviderMetadata := &ProviderMetadata{}
|
||||
if metadata != nil {
|
||||
if providerMetadata, ok := metadata[Name]; ok {
|
||||
converted, ok := providerMetadata.(*ProviderMetadata)
|
||||
if ok {
|
||||
streamProviderMetadata = converted
|
||||
}
|
||||
}
|
||||
}
|
||||
// we do this here because the acc does not add prompt details
|
||||
completionTokenDetails := chunk.Usage.CompletionTokensDetails
|
||||
promptTokenDetails := chunk.Usage.PromptTokensDetails
|
||||
usage := fantasy.Usage{
|
||||
InputTokens: chunk.Usage.PromptTokens,
|
||||
OutputTokens: chunk.Usage.CompletionTokens,
|
||||
TotalTokens: chunk.Usage.TotalTokens,
|
||||
ReasoningTokens: completionTokenDetails.ReasoningTokens,
|
||||
CacheReadTokens: promptTokenDetails.CachedTokens,
|
||||
}
|
||||
|
||||
// Add prediction tokens if available
|
||||
if completionTokenDetails.AcceptedPredictionTokens > 0 || completionTokenDetails.RejectedPredictionTokens > 0 {
|
||||
if completionTokenDetails.AcceptedPredictionTokens > 0 {
|
||||
streamProviderMetadata.AcceptedPredictionTokens = completionTokenDetails.AcceptedPredictionTokens
|
||||
}
|
||||
if completionTokenDetails.RejectedPredictionTokens > 0 {
|
||||
streamProviderMetadata.RejectedPredictionTokens = completionTokenDetails.RejectedPredictionTokens
|
||||
}
|
||||
}
|
||||
|
||||
return usage, fantasy.ProviderMetadata{
|
||||
Name: streamProviderMetadata,
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultStreamProviderMetadataFunc is the default implementation for handling stream provider metadata.
|
||||
func DefaultStreamProviderMetadataFunc(choice openai.ChatCompletionChoice, metadata fantasy.ProviderMetadata) fantasy.ProviderMetadata {
|
||||
if metadata == nil {
|
||||
metadata = fantasy.ProviderMetadata{}
|
||||
}
|
||||
streamProviderMetadata, ok := metadata[Name]
|
||||
if !ok {
|
||||
streamProviderMetadata = &ProviderMetadata{}
|
||||
}
|
||||
if converted, ok := streamProviderMetadata.(*ProviderMetadata); ok {
|
||||
converted.Logprobs = choice.Logprobs.Content
|
||||
metadata[Name] = converted
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
// DefaultToPrompt converts a fantasy prompt to OpenAI format with default handling.
|
||||
func DefaultToPrompt(prompt fantasy.Prompt, _, _ string) ([]openai.ChatCompletionMessageParamUnion, []fantasy.CallWarning) {
|
||||
var messages []openai.ChatCompletionMessageParamUnion
|
||||
var warnings []fantasy.CallWarning
|
||||
for _, msg := range prompt {
|
||||
switch msg.Role {
|
||||
case fantasy.MessageRoleSystem:
|
||||
var systemPromptParts []string
|
||||
for _, c := range msg.Content {
|
||||
if c.GetType() != fantasy.ContentTypeText {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "system prompt can only have text content",
|
||||
})
|
||||
continue
|
||||
}
|
||||
textPart, ok := fantasy.AsContentType[fantasy.TextPart](c)
|
||||
if !ok {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "system prompt text part does not have the right type",
|
||||
})
|
||||
continue
|
||||
}
|
||||
text := textPart.Text
|
||||
if strings.TrimSpace(text) != "" {
|
||||
systemPromptParts = append(systemPromptParts, textPart.Text)
|
||||
}
|
||||
}
|
||||
if len(systemPromptParts) == 0 {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "system prompt has no text parts",
|
||||
})
|
||||
continue
|
||||
}
|
||||
messages = append(messages, openai.SystemMessage(strings.Join(systemPromptParts, "\n")))
|
||||
case fantasy.MessageRoleUser:
|
||||
// simple user message just text content
|
||||
if len(msg.Content) == 1 && msg.Content[0].GetType() == fantasy.ContentTypeText {
|
||||
textPart, ok := fantasy.AsContentType[fantasy.TextPart](msg.Content[0])
|
||||
if !ok {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "user message text part does not have the right type",
|
||||
})
|
||||
continue
|
||||
}
|
||||
messages = append(messages, openai.UserMessage(textPart.Text))
|
||||
continue
|
||||
}
|
||||
// text content and attachments
|
||||
// for now we only support image content later we need to check
|
||||
// TODO: add the supported media types to the language model so we
|
||||
// can use that to validate the data here.
|
||||
var content []openai.ChatCompletionContentPartUnionParam
|
||||
for _, c := range msg.Content {
|
||||
switch c.GetType() {
|
||||
case fantasy.ContentTypeText:
|
||||
textPart, ok := fantasy.AsContentType[fantasy.TextPart](c)
|
||||
if !ok {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "user message text part does not have the right type",
|
||||
})
|
||||
continue
|
||||
}
|
||||
content = append(content, openai.ChatCompletionContentPartUnionParam{
|
||||
OfText: &openai.ChatCompletionContentPartTextParam{
|
||||
Text: textPart.Text,
|
||||
},
|
||||
})
|
||||
case fantasy.ContentTypeFile:
|
||||
filePart, ok := fantasy.AsContentType[fantasy.FilePart](c)
|
||||
if !ok {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "user message file part does not have the right type",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(filePart.MediaType, "image/"):
|
||||
// Handle image files
|
||||
base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
|
||||
data := "data:" + filePart.MediaType + ";base64," + base64Encoded
|
||||
imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: data}
|
||||
|
||||
// Check for provider-specific options like image detail
|
||||
if providerOptions, ok := filePart.ProviderOptions[Name]; ok {
|
||||
if detail, ok := providerOptions.(*ProviderFileOptions); ok {
|
||||
imageURL.Detail = detail.ImageDetail
|
||||
}
|
||||
}
|
||||
|
||||
imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
|
||||
content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
|
||||
|
||||
case filePart.MediaType == "audio/wav":
|
||||
// Handle WAV audio files
|
||||
base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
|
||||
audioBlock := openai.ChatCompletionContentPartInputAudioParam{
|
||||
InputAudio: openai.ChatCompletionContentPartInputAudioInputAudioParam{
|
||||
Data: base64Encoded,
|
||||
Format: "wav",
|
||||
},
|
||||
}
|
||||
content = append(content, openai.ChatCompletionContentPartUnionParam{OfInputAudio: &audioBlock})
|
||||
|
||||
case filePart.MediaType == "audio/mpeg" || filePart.MediaType == "audio/mp3":
|
||||
// Handle MP3 audio files
|
||||
base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
|
||||
audioBlock := openai.ChatCompletionContentPartInputAudioParam{
|
||||
InputAudio: openai.ChatCompletionContentPartInputAudioInputAudioParam{
|
||||
Data: base64Encoded,
|
||||
Format: "mp3",
|
||||
},
|
||||
}
|
||||
content = append(content, openai.ChatCompletionContentPartUnionParam{OfInputAudio: &audioBlock})
|
||||
|
||||
case filePart.MediaType == "application/pdf":
|
||||
// Handle PDF files
|
||||
dataStr := string(filePart.Data)
|
||||
|
||||
// Check if data looks like a file ID (starts with "file-")
|
||||
if strings.HasPrefix(dataStr, "file-") {
|
||||
fileBlock := openai.ChatCompletionContentPartFileParam{
|
||||
File: openai.ChatCompletionContentPartFileFileParam{
|
||||
FileID: param.NewOpt(dataStr),
|
||||
},
|
||||
}
|
||||
content = append(content, openai.ChatCompletionContentPartUnionParam{OfFile: &fileBlock})
|
||||
} else {
|
||||
// Handle as base64 data
|
||||
base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
|
||||
data := "data:application/pdf;base64," + base64Encoded
|
||||
|
||||
filename := filePart.Filename
|
||||
if filename == "" {
|
||||
// Generate default filename based on content index
|
||||
filename = fmt.Sprintf("part-%d.pdf", len(content))
|
||||
}
|
||||
|
||||
fileBlock := openai.ChatCompletionContentPartFileParam{
|
||||
File: openai.ChatCompletionContentPartFileFileParam{
|
||||
Filename: param.NewOpt(filename),
|
||||
FileData: param.NewOpt(data),
|
||||
},
|
||||
}
|
||||
content = append(content, openai.ChatCompletionContentPartUnionParam{OfFile: &fileBlock})
|
||||
}
|
||||
|
||||
default:
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: fmt.Sprintf("file part media type %s not supported", filePart.MediaType),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasVisibleUserContent(content) {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "dropping empty user message (contains neither user-facing content nor tool results)",
|
||||
})
|
||||
continue
|
||||
}
|
||||
messages = append(messages, openai.UserMessage(content))
|
||||
case fantasy.MessageRoleAssistant:
|
||||
// simple assistant message just text content
|
||||
if len(msg.Content) == 1 && msg.Content[0].GetType() == fantasy.ContentTypeText {
|
||||
textPart, ok := fantasy.AsContentType[fantasy.TextPart](msg.Content[0])
|
||||
if !ok {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "assistant message text part does not have the right type",
|
||||
})
|
||||
continue
|
||||
}
|
||||
messages = append(messages, openai.AssistantMessage(textPart.Text))
|
||||
continue
|
||||
}
|
||||
assistantMsg := openai.ChatCompletionAssistantMessageParam{
|
||||
Role: "assistant",
|
||||
}
|
||||
for _, c := range msg.Content {
|
||||
switch c.GetType() {
|
||||
case fantasy.ContentTypeText:
|
||||
textPart, ok := fantasy.AsContentType[fantasy.TextPart](c)
|
||||
if !ok {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "assistant message text part does not have the right type",
|
||||
})
|
||||
continue
|
||||
}
|
||||
assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
|
||||
OfString: param.NewOpt(textPart.Text),
|
||||
}
|
||||
case fantasy.ContentTypeToolCall:
|
||||
toolCallPart, ok := fantasy.AsContentType[fantasy.ToolCallPart](c)
|
||||
if !ok {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "assistant message tool part does not have the right type",
|
||||
})
|
||||
continue
|
||||
}
|
||||
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls,
|
||||
openai.ChatCompletionMessageToolCallUnionParam{
|
||||
OfFunction: &openai.ChatCompletionMessageFunctionToolCallParam{
|
||||
ID: toolCallPart.ToolCallID,
|
||||
Type: "function",
|
||||
Function: openai.ChatCompletionMessageFunctionToolCallFunctionParam{
|
||||
Name: toolCallPart.ToolName,
|
||||
Arguments: toolCallPart.Input,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
if !hasVisibleAssistantContent(&assistantMsg) {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "dropping empty assistant message (contains neither user-facing content nor tool calls)",
|
||||
})
|
||||
continue
|
||||
}
|
||||
messages = append(messages, openai.ChatCompletionMessageParamUnion{
|
||||
OfAssistant: &assistantMsg,
|
||||
})
|
||||
case fantasy.MessageRoleTool:
|
||||
for _, c := range msg.Content {
|
||||
if c.GetType() != fantasy.ContentTypeToolResult {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "tool message can only have tool result content",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
toolResultPart, ok := fantasy.AsContentType[fantasy.ToolResultPart](c)
|
||||
if !ok {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "tool message result part does not have the right type",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
switch toolResultPart.Output.GetType() {
|
||||
case fantasy.ToolResultContentTypeText:
|
||||
output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](toolResultPart.Output)
|
||||
if !ok {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "tool result output does not have the right type",
|
||||
})
|
||||
continue
|
||||
}
|
||||
messages = append(messages, openai.ToolMessage(output.Text, toolResultPart.ToolCallID))
|
||||
case fantasy.ToolResultContentTypeError:
|
||||
// TODO: check if better handling is needed
|
||||
output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](toolResultPart.Output)
|
||||
if !ok {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "tool result output does not have the right type",
|
||||
})
|
||||
continue
|
||||
}
|
||||
messages = append(messages, openai.ToolMessage(output.Error.Error(), toolResultPart.ToolCallID))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return messages, warnings
|
||||
}
|
||||
|
||||
func hasVisibleUserContent(content []openai.ChatCompletionContentPartUnionParam) bool {
|
||||
for _, part := range content {
|
||||
if part.OfText != nil || part.OfImageURL != nil || part.OfInputAudio != nil || part.OfFile != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasVisibleAssistantContent(msg *openai.ChatCompletionAssistantMessageParam) bool {
|
||||
// Check if there's text content
|
||||
if !param.IsOmitted(msg.Content.OfString) || len(msg.Content.OfArrayOfContentParts) > 0 {
|
||||
return true
|
||||
}
|
||||
// Check if there are tool calls
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
191
internal/fantasy/providers/openai/openai.go
Normal file
191
internal/fantasy/providers/openai/openai.go
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
// Package openai provides an implementation of the fantasy AI SDK for OpenAI's language models.
|
||||
package openai
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"maps"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/openai/openai-go/v2"
|
||||
"github.com/openai/openai-go/v2/option"
|
||||
)
|
||||
|
||||
const (
|
||||
// Name is the name of the OpenAI provider.
|
||||
Name = "openai"
|
||||
// DefaultURL is the default URL for the OpenAI API.
|
||||
DefaultURL = "https://api.openai.com/v1"
|
||||
)
|
||||
|
||||
type provider struct {
|
||||
options options
|
||||
}
|
||||
|
||||
type options struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
organization string
|
||||
project string
|
||||
name string
|
||||
useResponsesAPI bool
|
||||
headers map[string]string
|
||||
client option.HTTPClient
|
||||
sdkOptions []option.RequestOption
|
||||
objectMode fantasy.ObjectMode
|
||||
languageModelOptions []LanguageModelOption
|
||||
}
|
||||
|
||||
// Option defines a function that configures OpenAI provider options.
|
||||
type Option = func(*options)
|
||||
|
||||
// New creates a new OpenAI provider with the given options.
|
||||
func New(opts ...Option) (fantasy.Provider, error) {
|
||||
providerOptions := options{
|
||||
headers: map[string]string{},
|
||||
languageModelOptions: make([]LanguageModelOption, 0),
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(&providerOptions)
|
||||
}
|
||||
|
||||
providerOptions.baseURL = cmp.Or(providerOptions.baseURL, DefaultURL)
|
||||
providerOptions.name = cmp.Or(providerOptions.name, Name)
|
||||
|
||||
if providerOptions.organization != "" {
|
||||
providerOptions.headers["OpenAi-Organization"] = providerOptions.organization
|
||||
}
|
||||
if providerOptions.project != "" {
|
||||
providerOptions.headers["OpenAi-Project"] = providerOptions.project
|
||||
}
|
||||
|
||||
return &provider{options: providerOptions}, nil
|
||||
}
|
||||
|
||||
// WithBaseURL sets the base URL for the OpenAI provider.
|
||||
func WithBaseURL(baseURL string) Option {
|
||||
return func(o *options) {
|
||||
o.baseURL = baseURL
|
||||
}
|
||||
}
|
||||
|
||||
// WithAPIKey sets the API key for the OpenAI provider.
|
||||
func WithAPIKey(apiKey string) Option {
|
||||
return func(o *options) {
|
||||
o.apiKey = apiKey
|
||||
}
|
||||
}
|
||||
|
||||
// WithOrganization sets the organization for the OpenAI provider.
|
||||
func WithOrganization(organization string) Option {
|
||||
return func(o *options) {
|
||||
o.organization = organization
|
||||
}
|
||||
}
|
||||
|
||||
// WithProject sets the project for the OpenAI provider.
|
||||
func WithProject(project string) Option {
|
||||
return func(o *options) {
|
||||
o.project = project
|
||||
}
|
||||
}
|
||||
|
||||
// WithName sets the name for the OpenAI provider.
|
||||
func WithName(name string) Option {
|
||||
return func(o *options) {
|
||||
o.name = name
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaders sets the headers for the OpenAI provider.
|
||||
func WithHeaders(headers map[string]string) Option {
|
||||
return func(o *options) {
|
||||
maps.Copy(o.headers, headers)
|
||||
}
|
||||
}
|
||||
|
||||
// WithHTTPClient sets the HTTP client for the OpenAI provider.
|
||||
func WithHTTPClient(client option.HTTPClient) Option {
|
||||
return func(o *options) {
|
||||
o.client = client
|
||||
}
|
||||
}
|
||||
|
||||
// WithSDKOptions sets the SDK options for the OpenAI provider.
|
||||
func WithSDKOptions(opts ...option.RequestOption) Option {
|
||||
return func(o *options) {
|
||||
o.sdkOptions = append(o.sdkOptions, opts...)
|
||||
}
|
||||
}
|
||||
|
||||
// WithLanguageModelOptions sets the language model options for the OpenAI provider.
|
||||
func WithLanguageModelOptions(opts ...LanguageModelOption) Option {
|
||||
return func(o *options) {
|
||||
o.languageModelOptions = append(o.languageModelOptions, opts...)
|
||||
}
|
||||
}
|
||||
|
||||
// WithUseResponsesAPI configures the provider to use the responses API for models that support it.
|
||||
func WithUseResponsesAPI() Option {
|
||||
return func(o *options) {
|
||||
o.useResponsesAPI = true
|
||||
}
|
||||
}
|
||||
|
||||
// WithObjectMode sets the object generation mode.
|
||||
func WithObjectMode(om fantasy.ObjectMode) Option {
|
||||
return func(o *options) {
|
||||
// not supported
|
||||
if om == fantasy.ObjectModeJSON {
|
||||
om = fantasy.ObjectModeAuto
|
||||
}
|
||||
o.objectMode = om
|
||||
}
|
||||
}
|
||||
|
||||
// LanguageModel implements fantasy.Provider.
|
||||
func (o *provider) LanguageModel(_ context.Context, modelID string) (fantasy.LanguageModel, error) {
|
||||
openaiClientOptions := make([]option.RequestOption, 0, 5+len(o.options.headers)+len(o.options.sdkOptions))
|
||||
openaiClientOptions = append(openaiClientOptions, option.WithMaxRetries(0))
|
||||
|
||||
if o.options.apiKey != "" {
|
||||
openaiClientOptions = append(openaiClientOptions, option.WithAPIKey(o.options.apiKey))
|
||||
}
|
||||
if o.options.baseURL != "" {
|
||||
openaiClientOptions = append(openaiClientOptions, option.WithBaseURL(o.options.baseURL))
|
||||
}
|
||||
|
||||
for key, value := range o.options.headers {
|
||||
openaiClientOptions = append(openaiClientOptions, option.WithHeader(key, value))
|
||||
}
|
||||
|
||||
if o.options.client != nil {
|
||||
openaiClientOptions = append(openaiClientOptions, option.WithHTTPClient(o.options.client))
|
||||
}
|
||||
|
||||
openaiClientOptions = append(openaiClientOptions, o.options.sdkOptions...)
|
||||
|
||||
client := openai.NewClient(openaiClientOptions...)
|
||||
|
||||
if o.options.useResponsesAPI && IsResponsesModel(modelID) {
|
||||
// Not supported for responses API
|
||||
objectMode := o.options.objectMode
|
||||
if objectMode == fantasy.ObjectModeJSON {
|
||||
objectMode = fantasy.ObjectModeAuto
|
||||
}
|
||||
return newResponsesLanguageModel(modelID, o.options.name, client, objectMode), nil
|
||||
}
|
||||
|
||||
o.options.languageModelOptions = append(o.options.languageModelOptions, WithLanguageModelObjectMode(o.options.objectMode))
|
||||
|
||||
return newLanguageModel(
|
||||
modelID,
|
||||
o.options.name,
|
||||
client,
|
||||
o.options.languageModelOptions...,
|
||||
), nil
|
||||
}
|
||||
|
||||
func (o *provider) Name() string {
|
||||
return Name
|
||||
}
|
||||
3249
internal/fantasy/providers/openai/openai_test.go
Normal file
3249
internal/fantasy/providers/openai/openai_test.go
Normal file
File diff suppressed because it is too large
Load diff
174
internal/fantasy/providers/openai/provider_options.go
Normal file
174
internal/fantasy/providers/openai/provider_options.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// Package openai provides an implementation of the fantasy AI SDK for OpenAI's language models.
|
||||
package openai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/openai/openai-go/v2"
|
||||
)
|
||||
|
||||
// ReasoningEffort represents the reasoning effort level for OpenAI models.
|
||||
type ReasoningEffort string
|
||||
|
||||
const (
|
||||
// ReasoningEffortMinimal represents minimal reasoning effort.
|
||||
ReasoningEffortMinimal ReasoningEffort = "minimal"
|
||||
// ReasoningEffortLow represents low reasoning effort.
|
||||
ReasoningEffortLow ReasoningEffort = "low"
|
||||
// ReasoningEffortMedium represents medium reasoning effort.
|
||||
ReasoningEffortMedium ReasoningEffort = "medium"
|
||||
// ReasoningEffortHigh represents high reasoning effort.
|
||||
ReasoningEffortHigh ReasoningEffort = "high"
|
||||
)
|
||||
|
||||
// Global type identifiers for OpenAI-specific provider data.
|
||||
const (
|
||||
TypeProviderOptions = Name + ".options"
|
||||
TypeProviderFileOptions = Name + ".file_options"
|
||||
TypeProviderMetadata = Name + ".metadata"
|
||||
)
|
||||
|
||||
// Register OpenAI provider-specific types with the global registry.
|
||||
func init() {
|
||||
fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
|
||||
var v ProviderOptions
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
})
|
||||
fantasy.RegisterProviderType(TypeProviderFileOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
|
||||
var v ProviderFileOptions
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
})
|
||||
fantasy.RegisterProviderType(TypeProviderMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) {
|
||||
var v ProviderMetadata
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ProviderMetadata represents additional metadata from OpenAI provider.
|
||||
type ProviderMetadata struct {
|
||||
Logprobs []openai.ChatCompletionTokenLogprob `json:"logprobs"`
|
||||
AcceptedPredictionTokens int64 `json:"accepted_prediction_tokens"`
|
||||
RejectedPredictionTokens int64 `json:"rejected_prediction_tokens"`
|
||||
}
|
||||
|
||||
// Options implements the ProviderOptions interface.
|
||||
func (*ProviderMetadata) Options() {}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling with type info for ProviderMetadata.
|
||||
func (m ProviderMetadata) MarshalJSON() ([]byte, error) {
|
||||
type plain ProviderMetadata
|
||||
return fantasy.MarshalProviderType(TypeProviderMetadata, plain(m))
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom JSON unmarshaling with type info for ProviderMetadata.
|
||||
func (m *ProviderMetadata) UnmarshalJSON(data []byte) error {
|
||||
type plain ProviderMetadata
|
||||
var p plain
|
||||
if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
|
||||
return err
|
||||
}
|
||||
*m = ProviderMetadata(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProviderOptions represents additional options for OpenAI provider.
|
||||
type ProviderOptions struct {
|
||||
LogitBias map[string]int64 `json:"logit_bias"`
|
||||
LogProbs *bool `json:"log_probs"`
|
||||
TopLogProbs *int64 `json:"top_log_probs"`
|
||||
ParallelToolCalls *bool `json:"parallel_tool_calls"`
|
||||
User *string `json:"user"`
|
||||
ReasoningEffort *ReasoningEffort `json:"reasoning_effort"`
|
||||
MaxCompletionTokens *int64 `json:"max_completion_tokens"`
|
||||
TextVerbosity *string `json:"text_verbosity"`
|
||||
Prediction map[string]any `json:"prediction"`
|
||||
Store *bool `json:"store"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
PromptCacheKey *string `json:"prompt_cache_key"`
|
||||
SafetyIdentifier *string `json:"safety_identifier"`
|
||||
ServiceTier *string `json:"service_tier"`
|
||||
StructuredOutputs *bool `json:"structured_outputs"`
|
||||
}
|
||||
|
||||
// Options implements the ProviderOptions interface.
|
||||
func (*ProviderOptions) Options() {}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling with type info for ProviderOptions.
|
||||
func (o ProviderOptions) MarshalJSON() ([]byte, error) {
|
||||
type plain ProviderOptions
|
||||
return fantasy.MarshalProviderType(TypeProviderOptions, plain(o))
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom JSON unmarshaling with type info for ProviderOptions.
|
||||
func (o *ProviderOptions) UnmarshalJSON(data []byte) error {
|
||||
type plain ProviderOptions
|
||||
var p plain
|
||||
if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
|
||||
return err
|
||||
}
|
||||
*o = ProviderOptions(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProviderFileOptions represents file options for OpenAI provider.
|
||||
type ProviderFileOptions struct {
|
||||
ImageDetail string `json:"image_detail"`
|
||||
}
|
||||
|
||||
// Options implements the ProviderOptions interface.
|
||||
func (*ProviderFileOptions) Options() {}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling with type info for ProviderFileOptions.
|
||||
func (o ProviderFileOptions) MarshalJSON() ([]byte, error) {
|
||||
type plain ProviderFileOptions
|
||||
return fantasy.MarshalProviderType(TypeProviderFileOptions, plain(o))
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom JSON unmarshaling with type info for ProviderFileOptions.
|
||||
func (o *ProviderFileOptions) UnmarshalJSON(data []byte) error {
|
||||
type plain ProviderFileOptions
|
||||
var p plain
|
||||
if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
|
||||
return err
|
||||
}
|
||||
*o = ProviderFileOptions(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReasoningEffortOption creates a pointer to a ReasoningEffort value.
|
||||
func ReasoningEffortOption(e ReasoningEffort) *ReasoningEffort {
|
||||
return &e
|
||||
}
|
||||
|
||||
// NewProviderOptions creates new provider options for OpenAI.
|
||||
func NewProviderOptions(opts *ProviderOptions) fantasy.ProviderOptions {
|
||||
return fantasy.ProviderOptions{
|
||||
Name: opts,
|
||||
}
|
||||
}
|
||||
|
||||
// NewProviderFileOptions creates new file options for OpenAI.
|
||||
func NewProviderFileOptions(opts *ProviderFileOptions) fantasy.ProviderOptions {
|
||||
return fantasy.ProviderOptions{
|
||||
Name: opts,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseOptions parses provider options from a map.
|
||||
func ParseOptions(data map[string]any) (*ProviderOptions, error) {
|
||||
var options ProviderOptions
|
||||
if err := fantasy.ParseOptions(data, &options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &options, nil
|
||||
}
|
||||
1342
internal/fantasy/providers/openai/responses_language_model.go
Normal file
1342
internal/fantasy/providers/openai/responses_language_model.go
Normal file
File diff suppressed because it is too large
Load diff
222
internal/fantasy/providers/openai/responses_options.go
Normal file
222
internal/fantasy/providers/openai/responses_options.go
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
// Package openai provides an implementation of the fantasy AI SDK for OpenAI's language models.
|
||||
package openai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
|
||||
// Global type identifiers for OpenAI Responses API-specific data.
|
||||
const (
|
||||
TypeResponsesProviderOptions = Name + ".responses.options"
|
||||
TypeResponsesReasoningMetadata = Name + ".responses.reasoning_metadata"
|
||||
)
|
||||
|
||||
// Register OpenAI Responses API-specific types with the global registry.
|
||||
func init() {
|
||||
fantasy.RegisterProviderType(TypeResponsesProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
|
||||
var v ResponsesProviderOptions
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
})
|
||||
fantasy.RegisterProviderType(TypeResponsesReasoningMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) {
|
||||
var v ResponsesReasoningMetadata
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ResponsesReasoningMetadata represents reasoning metadata for OpenAI Responses API.
|
||||
type ResponsesReasoningMetadata struct {
|
||||
ItemID string `json:"item_id"`
|
||||
EncryptedContent *string `json:"encrypted_content"`
|
||||
Summary []string `json:"summary"`
|
||||
}
|
||||
|
||||
// Options implements the ProviderOptions interface.
|
||||
func (*ResponsesReasoningMetadata) Options() {}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling with type info for ResponsesReasoningMetadata.
|
||||
func (m ResponsesReasoningMetadata) MarshalJSON() ([]byte, error) {
|
||||
type plain ResponsesReasoningMetadata
|
||||
return fantasy.MarshalProviderType(TypeResponsesReasoningMetadata, plain(m))
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom JSON unmarshaling with type info for ResponsesReasoningMetadata.
|
||||
func (m *ResponsesReasoningMetadata) UnmarshalJSON(data []byte) error {
|
||||
type plain ResponsesReasoningMetadata
|
||||
var p plain
|
||||
if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
|
||||
return err
|
||||
}
|
||||
*m = ResponsesReasoningMetadata(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// IncludeType represents the type of content to include for OpenAI Responses API.
|
||||
type IncludeType string
|
||||
|
||||
const (
|
||||
// IncludeReasoningEncryptedContent includes encrypted reasoning content.
|
||||
IncludeReasoningEncryptedContent IncludeType = "reasoning.encrypted_content"
|
||||
// IncludeFileSearchCallResults includes file search call results.
|
||||
IncludeFileSearchCallResults IncludeType = "file_search_call.results"
|
||||
// IncludeMessageOutputTextLogprobs includes message output text log probabilities.
|
||||
IncludeMessageOutputTextLogprobs IncludeType = "message.output_text.logprobs"
|
||||
)
|
||||
|
||||
// ServiceTier represents the service tier for OpenAI Responses API.
|
||||
type ServiceTier string
|
||||
|
||||
const (
|
||||
// ServiceTierAuto represents the auto service tier.
|
||||
ServiceTierAuto ServiceTier = "auto"
|
||||
// ServiceTierFlex represents the flex service tier.
|
||||
ServiceTierFlex ServiceTier = "flex"
|
||||
// ServiceTierPriority represents the priority service tier.
|
||||
ServiceTierPriority ServiceTier = "priority"
|
||||
)
|
||||
|
||||
// TextVerbosity represents the text verbosity level for OpenAI Responses API.
|
||||
type TextVerbosity string
|
||||
|
||||
const (
|
||||
// TextVerbosityLow represents low text verbosity.
|
||||
TextVerbosityLow TextVerbosity = "low"
|
||||
// TextVerbosityMedium represents medium text verbosity.
|
||||
TextVerbosityMedium TextVerbosity = "medium"
|
||||
// TextVerbosityHigh represents high text verbosity.
|
||||
TextVerbosityHigh TextVerbosity = "high"
|
||||
)
|
||||
|
||||
// ResponsesProviderOptions represents additional options for OpenAI Responses API.
|
||||
type ResponsesProviderOptions struct {
|
||||
Include []IncludeType `json:"include"`
|
||||
Instructions *string `json:"instructions"`
|
||||
Logprobs any `json:"logprobs"`
|
||||
MaxToolCalls *int64 `json:"max_tool_calls"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
ParallelToolCalls *bool `json:"parallel_tool_calls"`
|
||||
PromptCacheKey *string `json:"prompt_cache_key"`
|
||||
ReasoningEffort *ReasoningEffort `json:"reasoning_effort"`
|
||||
ReasoningSummary *string `json:"reasoning_summary"`
|
||||
SafetyIdentifier *string `json:"safety_identifier"`
|
||||
ServiceTier *ServiceTier `json:"service_tier"`
|
||||
StrictJSONSchema *bool `json:"strict_json_schema"`
|
||||
TextVerbosity *TextVerbosity `json:"text_verbosity"`
|
||||
User *string `json:"user"`
|
||||
}
|
||||
|
||||
// Options implements the ProviderOptions interface.
|
||||
func (*ResponsesProviderOptions) Options() {}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling with type info for ResponsesProviderOptions.
|
||||
func (o ResponsesProviderOptions) MarshalJSON() ([]byte, error) {
|
||||
type plain ResponsesProviderOptions
|
||||
return fantasy.MarshalProviderType(TypeResponsesProviderOptions, plain(o))
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom JSON unmarshaling with type info for ResponsesProviderOptions.
|
||||
func (o *ResponsesProviderOptions) UnmarshalJSON(data []byte) error {
|
||||
type plain ResponsesProviderOptions
|
||||
var p plain
|
||||
if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
|
||||
return err
|
||||
}
|
||||
*o = ResponsesProviderOptions(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// responsesReasoningModelIds lists the model IDs that support reasoning for OpenAI Responses API.
|
||||
var responsesReasoningModelIDs = []string{
|
||||
"o1",
|
||||
"o1-2024-12-17",
|
||||
"o3-mini",
|
||||
"o3-mini-2025-01-31",
|
||||
"o3",
|
||||
"o3-2025-04-16",
|
||||
"o4-mini",
|
||||
"o4-mini-2025-04-16",
|
||||
"codex-mini-latest",
|
||||
"gpt-5",
|
||||
"gpt-5-2025-08-07",
|
||||
"gpt-5-mini",
|
||||
"gpt-5-mini-2025-08-07",
|
||||
"gpt-5-nano",
|
||||
"gpt-5-nano-2025-08-07",
|
||||
"gpt-5-codex",
|
||||
"gpt-5-chat",
|
||||
"gpt-5-pro",
|
||||
"gpt-5.1",
|
||||
"gpt-5.1-codex",
|
||||
"gpt-5.1-codex-max",
|
||||
"gpt-5.1-codex-mini",
|
||||
"gpt-5.1-chat",
|
||||
"gpt-5.2",
|
||||
"gpt-5.2-codex",
|
||||
"gpt-5.3",
|
||||
"gpt-5.3-codex",
|
||||
"gpt-oss-120b",
|
||||
}
|
||||
|
||||
// responsesModelIds lists all model IDs for OpenAI Responses API.
|
||||
var responsesModelIDs = append([]string{
|
||||
"gpt-4.1",
|
||||
"gpt-4.1-2025-04-14",
|
||||
"gpt-4.1-mini",
|
||||
"gpt-4.1-mini-2025-04-14",
|
||||
"gpt-4.1-nano",
|
||||
"gpt-4.1-nano-2025-04-14",
|
||||
"gpt-4o",
|
||||
"gpt-4o-2024-05-13",
|
||||
"gpt-4o-2024-08-06",
|
||||
"gpt-4o-2024-11-20",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4o-mini-2024-07-18",
|
||||
"gpt-4-turbo",
|
||||
"gpt-4-turbo-2024-04-09",
|
||||
"gpt-4-turbo-preview",
|
||||
"gpt-4-0125-preview",
|
||||
"gpt-4-1106-preview",
|
||||
"gpt-4",
|
||||
"gpt-4-0613",
|
||||
"gpt-4.5-preview",
|
||||
"gpt-4.5-preview-2025-02-27",
|
||||
"gpt-3.5-turbo-0125",
|
||||
"gpt-3.5-turbo",
|
||||
"gpt-3.5-turbo-1106",
|
||||
"chatgpt-4o-latest",
|
||||
"gpt-5-chat-latest",
|
||||
}, responsesReasoningModelIDs...)
|
||||
|
||||
// NewResponsesProviderOptions creates new provider options for OpenAI Responses API.
|
||||
func NewResponsesProviderOptions(opts *ResponsesProviderOptions) fantasy.ProviderOptions {
|
||||
return fantasy.ProviderOptions{
|
||||
Name: opts,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseResponsesOptions parses provider options from a map for OpenAI Responses API.
|
||||
func ParseResponsesOptions(data map[string]any) (*ResponsesProviderOptions, error) {
|
||||
var options ResponsesProviderOptions
|
||||
if err := fantasy.ParseOptions(data, &options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &options, nil
|
||||
}
|
||||
|
||||
// IsResponsesModel checks if a model ID is a Responses API model for OpenAI.
|
||||
func IsResponsesModel(modelID string) bool {
|
||||
return slices.Contains(responsesModelIDs, modelID)
|
||||
}
|
||||
|
||||
// IsResponsesReasoningModel checks if a model ID is a Responses API reasoning model for OpenAI.
|
||||
func IsResponsesReasoningModel(modelID string) bool {
|
||||
return slices.Contains(responsesReasoningModelIDs, modelID)
|
||||
}
|
||||
444
internal/fantasy/providers/openaicompat/language_model_hooks.go
Normal file
444
internal/fantasy/providers/openaicompat/language_model_hooks.go
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
package openaicompat
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"charm.land/fantasy/providers/openai"
|
||||
openaisdk "github.com/openai/openai-go/v2"
|
||||
"github.com/openai/openai-go/v2/packages/param"
|
||||
"github.com/openai/openai-go/v2/shared"
|
||||
)
|
||||
|
||||
const reasoningStartedCtx = "reasoning_started"
|
||||
|
||||
// PrepareCallFunc prepares the call for the language model.
|
||||
func PrepareCallFunc(_ fantasy.LanguageModel, params *openaisdk.ChatCompletionNewParams, call fantasy.Call) ([]fantasy.CallWarning, error) {
|
||||
providerOptions := &ProviderOptions{}
|
||||
if v, ok := call.ProviderOptions[Name]; ok {
|
||||
providerOptions, ok = v.(*ProviderOptions)
|
||||
if !ok {
|
||||
return nil, &fantasy.Error{Title: "invalid argument", Message: "openai-compat provider options should be *openaicompat.ProviderOptions"}
|
||||
}
|
||||
}
|
||||
|
||||
if providerOptions.ReasoningEffort != nil {
|
||||
switch *providerOptions.ReasoningEffort {
|
||||
case openai.ReasoningEffortMinimal:
|
||||
params.ReasoningEffort = shared.ReasoningEffortMinimal
|
||||
case openai.ReasoningEffortLow:
|
||||
params.ReasoningEffort = shared.ReasoningEffortLow
|
||||
case openai.ReasoningEffortMedium:
|
||||
params.ReasoningEffort = shared.ReasoningEffortMedium
|
||||
case openai.ReasoningEffortHigh:
|
||||
params.ReasoningEffort = shared.ReasoningEffortHigh
|
||||
default:
|
||||
return nil, fmt.Errorf("reasoning model `%s` not supported", *providerOptions.ReasoningEffort)
|
||||
}
|
||||
}
|
||||
|
||||
if providerOptions.User != nil {
|
||||
params.User = param.NewOpt(*providerOptions.User)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ExtraContentFunc adds extra content to the response.
|
||||
func ExtraContentFunc(choice openaisdk.ChatCompletionChoice) []fantasy.Content {
|
||||
var content []fantasy.Content
|
||||
reasoningData := ReasoningData{}
|
||||
err := json.Unmarshal([]byte(choice.Message.RawJSON()), &reasoningData)
|
||||
if err != nil {
|
||||
return content
|
||||
}
|
||||
if reasoningData.ReasoningContent != "" {
|
||||
content = append(content, fantasy.ReasoningContent{
|
||||
Text: reasoningData.ReasoningContent,
|
||||
})
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func extractReasoningContext(ctx map[string]any) bool {
|
||||
reasoningStarted, ok := ctx[reasoningStartedCtx]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
b, ok := reasoningStarted.(bool)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// StreamExtraFunc handles extra functionality for streaming responses.
|
||||
func StreamExtraFunc(chunk openaisdk.ChatCompletionChunk, yield func(fantasy.StreamPart) bool, ctx map[string]any) (map[string]any, bool) {
|
||||
if len(chunk.Choices) == 0 {
|
||||
return ctx, true
|
||||
}
|
||||
|
||||
reasoningStarted := extractReasoningContext(ctx)
|
||||
|
||||
for inx, choice := range chunk.Choices {
|
||||
reasoningData := ReasoningData{}
|
||||
err := json.Unmarshal([]byte(choice.Delta.RawJSON()), &reasoningData)
|
||||
if err != nil {
|
||||
yield(fantasy.StreamPart{
|
||||
Type: fantasy.StreamPartTypeError,
|
||||
Error: &fantasy.Error{Title: "stream error", Message: "error unmarshalling delta", Cause: err},
|
||||
})
|
||||
return ctx, false
|
||||
}
|
||||
|
||||
emitEvent := func(reasoningContent string) bool {
|
||||
if !reasoningStarted {
|
||||
shouldContinue := yield(fantasy.StreamPart{
|
||||
Type: fantasy.StreamPartTypeReasoningStart,
|
||||
ID: fmt.Sprintf("%d", inx),
|
||||
})
|
||||
if !shouldContinue {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return yield(fantasy.StreamPart{
|
||||
Type: fantasy.StreamPartTypeReasoningDelta,
|
||||
ID: fmt.Sprintf("%d", inx),
|
||||
Delta: reasoningContent,
|
||||
})
|
||||
}
|
||||
if reasoningData.ReasoningContent != "" {
|
||||
if !reasoningStarted {
|
||||
ctx[reasoningStartedCtx] = true
|
||||
}
|
||||
return ctx, emitEvent(reasoningData.ReasoningContent)
|
||||
}
|
||||
if reasoningStarted && (choice.Delta.Content != "" || len(choice.Delta.ToolCalls) > 0) {
|
||||
ctx[reasoningStartedCtx] = false
|
||||
return ctx, yield(fantasy.StreamPart{
|
||||
Type: fantasy.StreamPartTypeReasoningEnd,
|
||||
ID: fmt.Sprintf("%d", inx),
|
||||
})
|
||||
}
|
||||
}
|
||||
return ctx, true
|
||||
}
|
||||
|
||||
// ToPromptFunc converts a fantasy prompt to OpenAI format with reasoning support.
|
||||
// It handles fantasy.ContentTypeReasoning in assistant messages by adding the
|
||||
// reasoning_content field to the message JSON.
|
||||
func ToPromptFunc(prompt fantasy.Prompt, _, _ string) ([]openaisdk.ChatCompletionMessageParamUnion, []fantasy.CallWarning) {
|
||||
var messages []openaisdk.ChatCompletionMessageParamUnion
|
||||
var warnings []fantasy.CallWarning
|
||||
for _, msg := range prompt {
|
||||
switch msg.Role {
|
||||
case fantasy.MessageRoleSystem:
|
||||
var systemPromptParts []string
|
||||
for _, c := range msg.Content {
|
||||
if c.GetType() != fantasy.ContentTypeText {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "system prompt can only have text content",
|
||||
})
|
||||
continue
|
||||
}
|
||||
textPart, ok := fantasy.AsContentType[fantasy.TextPart](c)
|
||||
if !ok {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "system prompt text part does not have the right type",
|
||||
})
|
||||
continue
|
||||
}
|
||||
text := textPart.Text
|
||||
if strings.TrimSpace(text) != "" {
|
||||
systemPromptParts = append(systemPromptParts, textPart.Text)
|
||||
}
|
||||
}
|
||||
if len(systemPromptParts) == 0 {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "system prompt has no text parts",
|
||||
})
|
||||
continue
|
||||
}
|
||||
messages = append(messages, openaisdk.SystemMessage(strings.Join(systemPromptParts, "\n")))
|
||||
case fantasy.MessageRoleUser:
|
||||
// simple user message just text content
|
||||
if len(msg.Content) == 1 && msg.Content[0].GetType() == fantasy.ContentTypeText {
|
||||
textPart, ok := fantasy.AsContentType[fantasy.TextPart](msg.Content[0])
|
||||
if !ok {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "user message text part does not have the right type",
|
||||
})
|
||||
continue
|
||||
}
|
||||
messages = append(messages, openaisdk.UserMessage(textPart.Text))
|
||||
continue
|
||||
}
|
||||
// text content and attachments
|
||||
var content []openaisdk.ChatCompletionContentPartUnionParam
|
||||
for _, c := range msg.Content {
|
||||
switch c.GetType() {
|
||||
case fantasy.ContentTypeText:
|
||||
textPart, ok := fantasy.AsContentType[fantasy.TextPart](c)
|
||||
if !ok {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "user message text part does not have the right type",
|
||||
})
|
||||
continue
|
||||
}
|
||||
content = append(content, openaisdk.ChatCompletionContentPartUnionParam{
|
||||
OfText: &openaisdk.ChatCompletionContentPartTextParam{
|
||||
Text: textPart.Text,
|
||||
},
|
||||
})
|
||||
case fantasy.ContentTypeFile:
|
||||
filePart, ok := fantasy.AsContentType[fantasy.FilePart](c)
|
||||
if !ok {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "user message file part does not have the right type",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(filePart.MediaType, "image/"):
|
||||
// Handle image files
|
||||
base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
|
||||
data := "data:" + filePart.MediaType + ";base64," + base64Encoded
|
||||
imageURL := openaisdk.ChatCompletionContentPartImageImageURLParam{URL: data}
|
||||
|
||||
// Check for provider-specific options like image detail
|
||||
if providerOptions, ok := filePart.ProviderOptions[openai.Name]; ok {
|
||||
if detail, ok := providerOptions.(*openai.ProviderFileOptions); ok {
|
||||
imageURL.Detail = detail.ImageDetail
|
||||
}
|
||||
}
|
||||
|
||||
imageBlock := openaisdk.ChatCompletionContentPartImageParam{ImageURL: imageURL}
|
||||
content = append(content, openaisdk.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
|
||||
|
||||
case filePart.MediaType == "audio/wav":
|
||||
// Handle WAV audio files
|
||||
base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
|
||||
audioBlock := openaisdk.ChatCompletionContentPartInputAudioParam{
|
||||
InputAudio: openaisdk.ChatCompletionContentPartInputAudioInputAudioParam{
|
||||
Data: base64Encoded,
|
||||
Format: "wav",
|
||||
},
|
||||
}
|
||||
content = append(content, openaisdk.ChatCompletionContentPartUnionParam{OfInputAudio: &audioBlock})
|
||||
|
||||
case filePart.MediaType == "audio/mpeg" || filePart.MediaType == "audio/mp3":
|
||||
// Handle MP3 audio files
|
||||
base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
|
||||
audioBlock := openaisdk.ChatCompletionContentPartInputAudioParam{
|
||||
InputAudio: openaisdk.ChatCompletionContentPartInputAudioInputAudioParam{
|
||||
Data: base64Encoded,
|
||||
Format: "mp3",
|
||||
},
|
||||
}
|
||||
content = append(content, openaisdk.ChatCompletionContentPartUnionParam{OfInputAudio: &audioBlock})
|
||||
|
||||
case filePart.MediaType == "application/pdf":
|
||||
// Handle PDF files
|
||||
dataStr := string(filePart.Data)
|
||||
|
||||
// Check if data looks like a file ID (starts with "file-")
|
||||
if strings.HasPrefix(dataStr, "file-") {
|
||||
fileBlock := openaisdk.ChatCompletionContentPartFileParam{
|
||||
File: openaisdk.ChatCompletionContentPartFileFileParam{
|
||||
FileID: param.NewOpt(dataStr),
|
||||
},
|
||||
}
|
||||
content = append(content, openaisdk.ChatCompletionContentPartUnionParam{OfFile: &fileBlock})
|
||||
} else {
|
||||
// Handle as base64 data
|
||||
base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
|
||||
data := "data:application/pdf;base64," + base64Encoded
|
||||
|
||||
filename := filePart.Filename
|
||||
if filename == "" {
|
||||
// Generate default filename based on content index
|
||||
filename = fmt.Sprintf("part-%d.pdf", len(content))
|
||||
}
|
||||
|
||||
fileBlock := openaisdk.ChatCompletionContentPartFileParam{
|
||||
File: openaisdk.ChatCompletionContentPartFileFileParam{
|
||||
Filename: param.NewOpt(filename),
|
||||
FileData: param.NewOpt(data),
|
||||
},
|
||||
}
|
||||
content = append(content, openaisdk.ChatCompletionContentPartUnionParam{OfFile: &fileBlock})
|
||||
}
|
||||
|
||||
default:
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: fmt.Sprintf("file part media type %s not supported", filePart.MediaType),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasVisibleCompatUserContent(content) {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "dropping empty user message (contains neither user-facing content nor tool results)",
|
||||
})
|
||||
continue
|
||||
}
|
||||
messages = append(messages, openaisdk.UserMessage(content))
|
||||
case fantasy.MessageRoleAssistant:
|
||||
// simple assistant message just text content
|
||||
if len(msg.Content) == 1 && msg.Content[0].GetType() == fantasy.ContentTypeText {
|
||||
textPart, ok := fantasy.AsContentType[fantasy.TextPart](msg.Content[0])
|
||||
if !ok {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "assistant message text part does not have the right type",
|
||||
})
|
||||
continue
|
||||
}
|
||||
messages = append(messages, openaisdk.AssistantMessage(textPart.Text))
|
||||
continue
|
||||
}
|
||||
assistantMsg := openaisdk.ChatCompletionAssistantMessageParam{
|
||||
Role: "assistant",
|
||||
}
|
||||
var reasoningText string
|
||||
for _, c := range msg.Content {
|
||||
switch c.GetType() {
|
||||
case fantasy.ContentTypeText:
|
||||
textPart, ok := fantasy.AsContentType[fantasy.TextPart](c)
|
||||
if !ok {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "assistant message text part does not have the right type",
|
||||
})
|
||||
continue
|
||||
}
|
||||
assistantMsg.Content = openaisdk.ChatCompletionAssistantMessageParamContentUnion{
|
||||
OfString: param.NewOpt(textPart.Text),
|
||||
}
|
||||
case fantasy.ContentTypeReasoning:
|
||||
reasoningPart, ok := fantasy.AsContentType[fantasy.ReasoningPart](c)
|
||||
if !ok {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "assistant message reasoning part does not have the right type",
|
||||
})
|
||||
continue
|
||||
}
|
||||
reasoningText = reasoningPart.Text
|
||||
case fantasy.ContentTypeToolCall:
|
||||
toolCallPart, ok := fantasy.AsContentType[fantasy.ToolCallPart](c)
|
||||
if !ok {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "assistant message tool part does not have the right type",
|
||||
})
|
||||
continue
|
||||
}
|
||||
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls,
|
||||
openaisdk.ChatCompletionMessageToolCallUnionParam{
|
||||
OfFunction: &openaisdk.ChatCompletionMessageFunctionToolCallParam{
|
||||
ID: toolCallPart.ToolCallID,
|
||||
Type: "function",
|
||||
Function: openaisdk.ChatCompletionMessageFunctionToolCallFunctionParam{
|
||||
Name: toolCallPart.ToolName,
|
||||
Arguments: toolCallPart.Input,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
// Add reasoning_content field if present
|
||||
if reasoningText != "" {
|
||||
assistantMsg.SetExtraFields(map[string]any{
|
||||
"reasoning_content": reasoningText,
|
||||
})
|
||||
}
|
||||
if !hasVisibleCompatAssistantContent(&assistantMsg) {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "dropping empty assistant message (contains neither user-facing content nor tool calls)",
|
||||
})
|
||||
continue
|
||||
}
|
||||
messages = append(messages, openaisdk.ChatCompletionMessageParamUnion{
|
||||
OfAssistant: &assistantMsg,
|
||||
})
|
||||
case fantasy.MessageRoleTool:
|
||||
for _, c := range msg.Content {
|
||||
if c.GetType() != fantasy.ContentTypeToolResult {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "tool message can only have tool result content",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
toolResultPart, ok := fantasy.AsContentType[fantasy.ToolResultPart](c)
|
||||
if !ok {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "tool message result part does not have the right type",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
switch toolResultPart.Output.GetType() {
|
||||
case fantasy.ToolResultContentTypeText:
|
||||
output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](toolResultPart.Output)
|
||||
if !ok {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "tool result output does not have the right type",
|
||||
})
|
||||
continue
|
||||
}
|
||||
messages = append(messages, openaisdk.ToolMessage(output.Text, toolResultPart.ToolCallID))
|
||||
case fantasy.ToolResultContentTypeError:
|
||||
output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](toolResultPart.Output)
|
||||
if !ok {
|
||||
warnings = append(warnings, fantasy.CallWarning{
|
||||
Type: fantasy.CallWarningTypeOther,
|
||||
Message: "tool result output does not have the right type",
|
||||
})
|
||||
continue
|
||||
}
|
||||
messages = append(messages, openaisdk.ToolMessage(output.Error.Error(), toolResultPart.ToolCallID))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return messages, warnings
|
||||
}
|
||||
|
||||
func hasVisibleCompatUserContent(content []openaisdk.ChatCompletionContentPartUnionParam) bool {
|
||||
for _, part := range content {
|
||||
if part.OfText != nil || part.OfImageURL != nil || part.OfInputAudio != nil || part.OfFile != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasVisibleCompatAssistantContent(msg *openaisdk.ChatCompletionAssistantMessageParam) bool {
|
||||
// Check if there's text content
|
||||
if !param.IsOmitted(msg.Content.OfString) || len(msg.Content.OfArrayOfContentParts) > 0 {
|
||||
return true
|
||||
}
|
||||
// Check if there are tool calls
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
116
internal/fantasy/providers/openaicompat/openaicompat.go
Normal file
116
internal/fantasy/providers/openaicompat/openaicompat.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// Package openaicompat provides an implementation of the fantasy AI SDK for OpenAI-compatible APIs.
|
||||
package openaicompat
|
||||
|
||||
import (
|
||||
"charm.land/fantasy"
|
||||
"charm.land/fantasy/providers/openai"
|
||||
"github.com/openai/openai-go/v2/option"
|
||||
)
|
||||
|
||||
type options struct {
|
||||
openaiOptions []openai.Option
|
||||
languageModelOptions []openai.LanguageModelOption
|
||||
sdkOptions []option.RequestOption
|
||||
objectMode fantasy.ObjectMode
|
||||
}
|
||||
|
||||
const (
|
||||
// Name is the name of the OpenAI-compatible provider.
|
||||
Name = "openai-compat"
|
||||
)
|
||||
|
||||
// Option defines a function that configures OpenAI-compatible provider options.
|
||||
type Option = func(*options)
|
||||
|
||||
// New creates a new OpenAI-compatible provider with the given options.
|
||||
func New(opts ...Option) (fantasy.Provider, error) {
|
||||
providerOptions := options{
|
||||
openaiOptions: []openai.Option{
|
||||
openai.WithName(Name),
|
||||
},
|
||||
languageModelOptions: []openai.LanguageModelOption{
|
||||
openai.WithLanguageModelPrepareCallFunc(PrepareCallFunc),
|
||||
openai.WithLanguageModelStreamExtraFunc(StreamExtraFunc),
|
||||
openai.WithLanguageModelExtraContentFunc(ExtraContentFunc),
|
||||
openai.WithLanguageModelToPromptFunc(ToPromptFunc),
|
||||
},
|
||||
objectMode: fantasy.ObjectModeTool, // Default to tool mode for openai-compat
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(&providerOptions)
|
||||
}
|
||||
|
||||
// Handle object mode: convert unsupported modes to tool
|
||||
// OpenAI-compat endpoints don't support native JSON mode, so we use tool or text
|
||||
objectMode := providerOptions.objectMode
|
||||
if objectMode == fantasy.ObjectModeAuto || objectMode == fantasy.ObjectModeJSON {
|
||||
objectMode = fantasy.ObjectModeTool
|
||||
}
|
||||
|
||||
providerOptions.openaiOptions = append(
|
||||
providerOptions.openaiOptions,
|
||||
openai.WithSDKOptions(providerOptions.sdkOptions...),
|
||||
openai.WithLanguageModelOptions(providerOptions.languageModelOptions...),
|
||||
openai.WithObjectMode(objectMode),
|
||||
)
|
||||
return openai.New(providerOptions.openaiOptions...)
|
||||
}
|
||||
|
||||
// WithBaseURL sets the base URL for the OpenAI-compatible provider.
|
||||
func WithBaseURL(url string) Option {
|
||||
return func(o *options) {
|
||||
o.openaiOptions = append(o.openaiOptions, openai.WithBaseURL(url))
|
||||
}
|
||||
}
|
||||
|
||||
// WithAPIKey sets the API key for the OpenAI-compatible provider.
|
||||
func WithAPIKey(apiKey string) Option {
|
||||
return func(o *options) {
|
||||
o.openaiOptions = append(o.openaiOptions, openai.WithAPIKey(apiKey))
|
||||
}
|
||||
}
|
||||
|
||||
// WithName sets the name for the OpenAI-compatible provider.
|
||||
func WithName(name string) Option {
|
||||
return func(o *options) {
|
||||
o.openaiOptions = append(o.openaiOptions, openai.WithName(name))
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaders sets the headers for the OpenAI-compatible provider.
|
||||
func WithHeaders(headers map[string]string) Option {
|
||||
return func(o *options) {
|
||||
o.openaiOptions = append(o.openaiOptions, openai.WithHeaders(headers))
|
||||
}
|
||||
}
|
||||
|
||||
// WithHTTPClient sets the HTTP client for the OpenAI-compatible provider.
|
||||
func WithHTTPClient(client option.HTTPClient) Option {
|
||||
return func(o *options) {
|
||||
o.openaiOptions = append(o.openaiOptions, openai.WithHTTPClient(client))
|
||||
}
|
||||
}
|
||||
|
||||
// WithSDKOptions sets the SDK options for the OpenAI-compatible provider.
|
||||
func WithSDKOptions(opts ...option.RequestOption) Option {
|
||||
return func(o *options) {
|
||||
o.sdkOptions = append(o.sdkOptions, opts...)
|
||||
}
|
||||
}
|
||||
|
||||
// WithObjectMode sets the object generation mode for the OpenAI-compatible provider.
|
||||
// Supported modes: ObjectModeTool, ObjectModeText.
|
||||
// ObjectModeAuto and ObjectModeJSON are automatically converted to ObjectModeTool
|
||||
// since OpenAI-compatible endpoints typically don't support native JSON mode.
|
||||
func WithObjectMode(om fantasy.ObjectMode) Option {
|
||||
return func(o *options) {
|
||||
o.objectMode = om
|
||||
}
|
||||
}
|
||||
|
||||
// WithUseResponsesAPI configures the provider to use the responses API for models that support it.
|
||||
func WithUseResponsesAPI() Option {
|
||||
return func(o *options) {
|
||||
o.openaiOptions = append(o.openaiOptions, openai.WithUseResponsesAPI())
|
||||
}
|
||||
}
|
||||
434
internal/fantasy/providers/openaicompat/openaicompat_test.go
Normal file
434
internal/fantasy/providers/openaicompat/openaicompat_test.go
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
package openaicompat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestToPromptFunc_ReasoningContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("should add reasoning_content field to assistant messages", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "What is 2+2?"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: fantasy.MessageRoleAssistant,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.ReasoningPart{Text: "Let me think... 2+2 equals 4."},
|
||||
fantasy.TextPart{Text: "The answer is 4."},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "What about 3+3?"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
messages, warnings := ToPromptFunc(prompt, "", "")
|
||||
|
||||
require.Empty(t, warnings)
|
||||
require.Len(t, messages, 3)
|
||||
|
||||
// First message (user) - no reasoning
|
||||
msg1 := messages[0].OfUser
|
||||
require.NotNil(t, msg1)
|
||||
require.Equal(t, "What is 2+2?", msg1.Content.OfString.Value)
|
||||
|
||||
// Second message (assistant) - with reasoning
|
||||
msg2 := messages[1].OfAssistant
|
||||
require.NotNil(t, msg2)
|
||||
require.Equal(t, "The answer is 4.", msg2.Content.OfString.Value)
|
||||
// Check reasoning_content in extra fields
|
||||
extraFields := msg2.ExtraFields()
|
||||
reasoningContent, hasReasoning := extraFields["reasoning_content"]
|
||||
require.True(t, hasReasoning)
|
||||
require.Equal(t, "Let me think... 2+2 equals 4.", reasoningContent)
|
||||
|
||||
// Third message (user) - no reasoning
|
||||
msg3 := messages[2].OfUser
|
||||
require.NotNil(t, msg3)
|
||||
require.Equal(t, "What about 3+3?", msg3.Content.OfString.Value)
|
||||
})
|
||||
|
||||
t.Run("should handle assistant messages with only reasoning content", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "Hello"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: fantasy.MessageRoleAssistant,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.ReasoningPart{Text: "Internal reasoning only..."},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
messages, warnings := ToPromptFunc(prompt, "", "")
|
||||
|
||||
require.Len(t, warnings, 1)
|
||||
require.Contains(t, warnings[0].Message, "dropping empty assistant message")
|
||||
require.Len(t, messages, 1) // Only user message, assistant message dropped
|
||||
|
||||
// User message - unchanged
|
||||
msg := messages[0].OfUser
|
||||
require.NotNil(t, msg)
|
||||
require.Equal(t, "Hello", msg.Content.OfString.Value)
|
||||
})
|
||||
|
||||
t.Run("should not add reasoning_content to messages without reasoning", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "Hello"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: fantasy.MessageRoleAssistant,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "Hi there!"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
messages, warnings := ToPromptFunc(prompt, "", "")
|
||||
|
||||
require.Empty(t, warnings)
|
||||
require.Len(t, messages, 2)
|
||||
|
||||
// Assistant message without reasoning
|
||||
msg := messages[1].OfAssistant
|
||||
require.NotNil(t, msg)
|
||||
require.Equal(t, "Hi there!", msg.Content.OfString.Value)
|
||||
extraFields := msg.ExtraFields()
|
||||
_, hasReasoning := extraFields["reasoning_content"]
|
||||
require.False(t, hasReasoning)
|
||||
})
|
||||
|
||||
t.Run("should preserve system and user messages unchanged", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleSystem,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "You are helpful."},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "Hello"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
messages, warnings := ToPromptFunc(prompt, "", "")
|
||||
|
||||
require.Empty(t, warnings)
|
||||
require.Len(t, messages, 2)
|
||||
|
||||
// System message - unchanged
|
||||
systemMsg := messages[0].OfSystem
|
||||
require.NotNil(t, systemMsg)
|
||||
require.Equal(t, "You are helpful.", systemMsg.Content.OfString.Value)
|
||||
|
||||
// User message - unchanged
|
||||
userMsg := messages[1].OfUser
|
||||
require.NotNil(t, userMsg)
|
||||
require.Equal(t, "Hello", userMsg.Content.OfString.Value)
|
||||
})
|
||||
|
||||
t.Run("should use last assistant TextPart only", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "Hello"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: fantasy.MessageRoleAssistant,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "First part. "},
|
||||
fantasy.TextPart{Text: "Second part. "},
|
||||
fantasy.TextPart{Text: "Third part."},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
messages, warnings := ToPromptFunc(prompt, "", "")
|
||||
|
||||
require.Empty(t, warnings)
|
||||
require.Len(t, messages, 2)
|
||||
|
||||
// Assistant message should use only the last TextPart (matching openai behavior)
|
||||
assistantMsg := messages[1].OfAssistant
|
||||
require.NotNil(t, assistantMsg)
|
||||
require.Equal(t, "Third part.", assistantMsg.Content.OfString.Value)
|
||||
})
|
||||
|
||||
t.Run("should include user messages with only unsupported attachments", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "Hello"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.FilePart{
|
||||
MediaType: "application/x-unsupported",
|
||||
Data: []byte("unsupported data"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "After unsupported"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
messages, warnings := ToPromptFunc(prompt, "", "")
|
||||
|
||||
require.Len(t, warnings, 2) // unsupported type + empty message
|
||||
require.Contains(t, warnings[0].Message, "not supported")
|
||||
require.Contains(t, warnings[1].Message, "dropping empty user message")
|
||||
// Should have only 2 messages (empty content message is now dropped)
|
||||
require.Len(t, messages, 2)
|
||||
|
||||
msg1 := messages[0].OfUser
|
||||
require.NotNil(t, msg1)
|
||||
require.Equal(t, "Hello", msg1.Content.OfString.Value)
|
||||
|
||||
msg2 := messages[1].OfUser
|
||||
require.NotNil(t, msg2)
|
||||
require.Equal(t, "After unsupported", msg2.Content.OfString.Value)
|
||||
})
|
||||
|
||||
t.Run("should detect PDF file IDs using strings.HasPrefix", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "Check this PDF"},
|
||||
fantasy.FilePart{
|
||||
MediaType: "application/pdf",
|
||||
Data: []byte("file-abc123xyz"),
|
||||
Filename: "test.pdf",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
messages, warnings := ToPromptFunc(prompt, "", "")
|
||||
|
||||
require.Empty(t, warnings)
|
||||
require.Len(t, messages, 1)
|
||||
|
||||
userMsg := messages[0].OfUser
|
||||
require.NotNil(t, userMsg)
|
||||
|
||||
content := userMsg.Content.OfArrayOfContentParts
|
||||
require.Len(t, content, 2)
|
||||
|
||||
// Second content part should be file with file_id
|
||||
filePart := content[1].OfFile
|
||||
require.NotNil(t, filePart)
|
||||
require.Equal(t, "file-abc123xyz", filePart.File.FileID.Value)
|
||||
})
|
||||
}
|
||||
|
||||
func TestToPromptFunc_DropsEmptyMessages(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("should drop truly empty assistant messages", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "Hello"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: fantasy.MessageRoleAssistant,
|
||||
Content: []fantasy.MessagePart{},
|
||||
},
|
||||
}
|
||||
|
||||
messages, warnings := ToPromptFunc(prompt, "", "")
|
||||
|
||||
require.Len(t, messages, 1, "should only have user message")
|
||||
require.Len(t, warnings, 1)
|
||||
require.Equal(t, fantasy.CallWarningTypeOther, warnings[0].Type)
|
||||
require.Contains(t, warnings[0].Message, "dropping empty assistant message")
|
||||
})
|
||||
|
||||
t.Run("should keep assistant messages with text content", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "Hello"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: fantasy.MessageRoleAssistant,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "Hi there!"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
messages, warnings := ToPromptFunc(prompt, "", "")
|
||||
|
||||
require.Len(t, messages, 2, "should have both user and assistant messages")
|
||||
require.Empty(t, warnings)
|
||||
})
|
||||
|
||||
t.Run("should keep assistant messages with tool calls", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "What's the weather?"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: fantasy.MessageRoleAssistant,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.ToolCallPart{
|
||||
ToolCallID: "call_123",
|
||||
ToolName: "get_weather",
|
||||
Input: `{"location":"NYC"}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
messages, warnings := ToPromptFunc(prompt, "", "")
|
||||
|
||||
require.Len(t, messages, 2, "should have both user and assistant messages")
|
||||
require.Empty(t, warnings)
|
||||
})
|
||||
|
||||
t.Run("should drop user messages without visible content", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.FilePart{
|
||||
Data: []byte("not supported"),
|
||||
MediaType: "application/unknown",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
messages, warnings := ToPromptFunc(prompt, "", "")
|
||||
|
||||
require.Empty(t, messages)
|
||||
require.Len(t, warnings, 2) // unsupported type + empty message
|
||||
require.Contains(t, warnings[1].Message, "dropping empty user message")
|
||||
})
|
||||
|
||||
t.Run("should keep user messages with image content", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.FilePart{
|
||||
Data: []byte{0x01, 0x02, 0x03},
|
||||
MediaType: "image/png",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
messages, warnings := ToPromptFunc(prompt, "", "")
|
||||
|
||||
require.Len(t, messages, 1)
|
||||
require.Empty(t, warnings)
|
||||
})
|
||||
|
||||
t.Run("should keep user messages with tool results", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleTool,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.ToolResultPart{
|
||||
ToolCallID: "call_123",
|
||||
Output: fantasy.ToolResultOutputContentText{Text: "done"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
messages, warnings := ToPromptFunc(prompt, "", "")
|
||||
|
||||
require.Len(t, messages, 1)
|
||||
require.Empty(t, warnings)
|
||||
})
|
||||
|
||||
t.Run("should keep user messages with tool error results", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
{
|
||||
Role: fantasy.MessageRoleTool,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.ToolResultPart{
|
||||
ToolCallID: "call_456",
|
||||
Output: fantasy.ToolResultOutputContentError{Error: errors.New("boom")},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
messages, warnings := ToPromptFunc(prompt, "", "")
|
||||
|
||||
require.Len(t, messages, 1)
|
||||
require.Empty(t, warnings)
|
||||
})
|
||||
}
|
||||
72
internal/fantasy/providers/openaicompat/provider_options.go
Normal file
72
internal/fantasy/providers/openaicompat/provider_options.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// Package openaicompat provides an implementation of the fantasy AI SDK for OpenAI-compatible APIs.
|
||||
package openaicompat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"charm.land/fantasy/providers/openai"
|
||||
)
|
||||
|
||||
// Global type identifiers for OpenAI-compatible provider data.
|
||||
const (
|
||||
TypeProviderOptions = Name + ".options"
|
||||
)
|
||||
|
||||
// Register OpenAI-compatible provider-specific types with the global registry.
|
||||
func init() {
|
||||
fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
|
||||
var v ProviderOptions
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ProviderOptions represents additional options for the OpenAI-compatible provider.
|
||||
type ProviderOptions struct {
|
||||
User *string `json:"user"`
|
||||
ReasoningEffort *openai.ReasoningEffort `json:"reasoning_effort"`
|
||||
}
|
||||
|
||||
// ReasoningData represents reasoning data for OpenAI-compatible provider.
|
||||
type ReasoningData struct {
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
}
|
||||
|
||||
// Options implements the ProviderOptions interface.
|
||||
func (*ProviderOptions) Options() {}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling with type info for ProviderOptions.
|
||||
func (o ProviderOptions) MarshalJSON() ([]byte, error) {
|
||||
type plain ProviderOptions
|
||||
return fantasy.MarshalProviderType(TypeProviderOptions, plain(o))
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom JSON unmarshaling with type info for ProviderOptions.
|
||||
func (o *ProviderOptions) UnmarshalJSON(data []byte) error {
|
||||
type plain ProviderOptions
|
||||
var p plain
|
||||
if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
|
||||
return err
|
||||
}
|
||||
*o = ProviderOptions(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewProviderOptions creates new provider options for the OpenAI-compatible provider.
|
||||
func NewProviderOptions(opts *ProviderOptions) fantasy.ProviderOptions {
|
||||
return fantasy.ProviderOptions{
|
||||
Name: opts,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseOptions parses provider options from a map for OpenAI-compatible provider.
|
||||
func ParseOptions(data map[string]any) (*ProviderOptions, error) {
|
||||
var options ProviderOptions
|
||||
if err := fantasy.ParseOptions(data, &options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &options, nil
|
||||
}
|
||||
1036
internal/fantasy/providers/openrouter/language_model_hooks.go
Normal file
1036
internal/fantasy/providers/openrouter/language_model_hooks.go
Normal file
File diff suppressed because it is too large
Load diff
113
internal/fantasy/providers/openrouter/openrouter.go
Normal file
113
internal/fantasy/providers/openrouter/openrouter.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// Package openrouter provides an implementation of the fantasy AI SDK for OpenRouter's language models.
|
||||
package openrouter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"charm.land/fantasy/providers/openai"
|
||||
"github.com/openai/openai-go/v2/option"
|
||||
)
|
||||
|
||||
type options struct {
|
||||
openaiOptions []openai.Option
|
||||
languageModelOptions []openai.LanguageModelOption
|
||||
objectMode fantasy.ObjectMode
|
||||
}
|
||||
|
||||
const (
|
||||
// DefaultURL is the default URL for the OpenRouter API.
|
||||
DefaultURL = "https://openrouter.ai/api/v1"
|
||||
// Name is the name of the OpenRouter provider.
|
||||
Name = "openrouter"
|
||||
)
|
||||
|
||||
// Option defines a function that configures OpenRouter provider options.
|
||||
type Option = func(*options)
|
||||
|
||||
// New creates a new OpenRouter provider with the given options.
|
||||
func New(opts ...Option) (fantasy.Provider, error) {
|
||||
providerOptions := options{
|
||||
openaiOptions: []openai.Option{
|
||||
openai.WithName(Name),
|
||||
openai.WithBaseURL(DefaultURL),
|
||||
},
|
||||
languageModelOptions: []openai.LanguageModelOption{
|
||||
openai.WithLanguageModelPrepareCallFunc(languagePrepareModelCall),
|
||||
openai.WithLanguageModelUsageFunc(languageModelUsage),
|
||||
openai.WithLanguageModelStreamUsageFunc(languageModelStreamUsage),
|
||||
openai.WithLanguageModelStreamExtraFunc(languageModelStreamExtra),
|
||||
openai.WithLanguageModelExtraContentFunc(languageModelExtraContent),
|
||||
openai.WithLanguageModelToPromptFunc(languageModelToPrompt),
|
||||
},
|
||||
objectMode: fantasy.ObjectModeTool, // Default to tool mode for openrouter
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(&providerOptions)
|
||||
}
|
||||
|
||||
// Handle object mode: convert unsupported modes to tool
|
||||
// OpenRouter doesn't support native JSON mode, so we use tool or text
|
||||
objectMode := providerOptions.objectMode
|
||||
if objectMode == fantasy.ObjectModeAuto || objectMode == fantasy.ObjectModeJSON {
|
||||
objectMode = fantasy.ObjectModeTool
|
||||
}
|
||||
|
||||
providerOptions.openaiOptions = append(
|
||||
providerOptions.openaiOptions,
|
||||
openai.WithLanguageModelOptions(providerOptions.languageModelOptions...),
|
||||
openai.WithObjectMode(objectMode),
|
||||
)
|
||||
return openai.New(providerOptions.openaiOptions...)
|
||||
}
|
||||
|
||||
// WithAPIKey sets the API key for the OpenRouter provider.
|
||||
func WithAPIKey(apiKey string) Option {
|
||||
return func(o *options) {
|
||||
o.openaiOptions = append(o.openaiOptions, openai.WithAPIKey(apiKey))
|
||||
}
|
||||
}
|
||||
|
||||
// WithName sets the name for the OpenRouter provider.
|
||||
func WithName(name string) Option {
|
||||
return func(o *options) {
|
||||
o.openaiOptions = append(o.openaiOptions, openai.WithName(name))
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaders sets the headers for the OpenRouter provider.
|
||||
func WithHeaders(headers map[string]string) Option {
|
||||
return func(o *options) {
|
||||
o.openaiOptions = append(o.openaiOptions, openai.WithHeaders(headers))
|
||||
}
|
||||
}
|
||||
|
||||
// WithHTTPClient sets the HTTP client for the OpenRouter provider.
|
||||
func WithHTTPClient(client option.HTTPClient) Option {
|
||||
return func(o *options) {
|
||||
o.openaiOptions = append(o.openaiOptions, openai.WithHTTPClient(client))
|
||||
}
|
||||
}
|
||||
|
||||
// WithObjectMode sets the object generation mode for the OpenRouter provider.
|
||||
// Supported modes: ObjectModeTool, ObjectModeText.
|
||||
// ObjectModeAuto and ObjectModeJSON are automatically converted to ObjectModeTool
|
||||
// since OpenRouter doesn't support native JSON mode.
|
||||
func WithObjectMode(om fantasy.ObjectMode) Option {
|
||||
return func(o *options) {
|
||||
o.objectMode = om
|
||||
}
|
||||
}
|
||||
|
||||
func structToMapJSON(s any) (map[string]any, error) {
|
||||
var result map[string]any
|
||||
jsonBytes, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal(jsonBytes, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
210
internal/fantasy/providers/openrouter/provider_options.go
Normal file
210
internal/fantasy/providers/openrouter/provider_options.go
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
// Package openrouter provides an implementation of the fantasy AI SDK for OpenRouter's language models.
|
||||
package openrouter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
|
||||
// ReasoningEffort represents the reasoning effort level for OpenRouter models.
|
||||
type ReasoningEffort string
|
||||
|
||||
const (
|
||||
// ReasoningEffortLow represents low reasoning effort.
|
||||
ReasoningEffortLow ReasoningEffort = "low"
|
||||
// ReasoningEffortMedium represents medium reasoning effort.
|
||||
ReasoningEffortMedium ReasoningEffort = "medium"
|
||||
// ReasoningEffortHigh represents high reasoning effort.
|
||||
ReasoningEffortHigh ReasoningEffort = "high"
|
||||
)
|
||||
|
||||
// Global type identifiers for OpenRouter-specific provider data.
|
||||
const (
|
||||
TypeProviderOptions = Name + ".options"
|
||||
TypeProviderMetadata = Name + ".metadata"
|
||||
)
|
||||
|
||||
// Register OpenRouter provider-specific types with the global registry.
|
||||
func init() {
|
||||
fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
|
||||
var v ProviderOptions
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
})
|
||||
fantasy.RegisterProviderType(TypeProviderMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) {
|
||||
var v ProviderMetadata
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
})
|
||||
}
|
||||
|
||||
// PromptTokensDetails represents details about prompt tokens for OpenRouter.
|
||||
type PromptTokensDetails struct {
|
||||
CachedTokens int64 `json:"cached_tokens"`
|
||||
}
|
||||
|
||||
// CompletionTokensDetails represents details about completion tokens for OpenRouter.
|
||||
type CompletionTokensDetails struct {
|
||||
ReasoningTokens int64 `json:"reasoning_tokens"`
|
||||
}
|
||||
|
||||
// CostDetails represents cost details for OpenRouter.
|
||||
type CostDetails struct {
|
||||
UpstreamInferenceCost float64 `json:"upstream_inference_cost"`
|
||||
UpstreamInferencePromptCost float64 `json:"upstream_inference_prompt_cost"`
|
||||
UpstreamInferenceCompletionsCost float64 `json:"upstream_inference_completions_cost"`
|
||||
}
|
||||
|
||||
// UsageAccounting represents usage accounting details for OpenRouter.
|
||||
type UsageAccounting struct {
|
||||
PromptTokens int64 `json:"prompt_tokens"`
|
||||
PromptTokensDetails PromptTokensDetails `json:"prompt_tokens_details"`
|
||||
CompletionTokens int64 `json:"completion_tokens"`
|
||||
CompletionTokensDetails CompletionTokensDetails `json:"completion_tokens_details"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
Cost float64 `json:"cost"`
|
||||
CostDetails CostDetails `json:"cost_details"`
|
||||
}
|
||||
|
||||
// ProviderMetadata represents metadata from OpenRouter provider.
|
||||
type ProviderMetadata struct {
|
||||
Provider string `json:"provider"`
|
||||
Usage UsageAccounting `json:"usage"`
|
||||
}
|
||||
|
||||
// Options implements the ProviderOptionsData interface for ProviderMetadata.
|
||||
func (*ProviderMetadata) Options() {}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling with type info for ProviderMetadata.
|
||||
func (m ProviderMetadata) MarshalJSON() ([]byte, error) {
|
||||
type plain ProviderMetadata
|
||||
return fantasy.MarshalProviderType(TypeProviderMetadata, plain(m))
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom JSON unmarshaling with type info for ProviderMetadata.
|
||||
func (m *ProviderMetadata) UnmarshalJSON(data []byte) error {
|
||||
type plain ProviderMetadata
|
||||
var p plain
|
||||
if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
|
||||
return err
|
||||
}
|
||||
*m = ProviderMetadata(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReasoningOptions represents reasoning options for OpenRouter.
|
||||
type ReasoningOptions struct {
|
||||
// Whether reasoning is enabled
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
// Whether to exclude reasoning from the response
|
||||
Exclude *bool `json:"exclude,omitempty"`
|
||||
// Maximum number of tokens to use for reasoning
|
||||
MaxTokens *int64 `json:"max_tokens,omitempty"`
|
||||
// Reasoning effort level: "low" | "medium" | "high"
|
||||
Effort *ReasoningEffort `json:"effort,omitempty"`
|
||||
}
|
||||
|
||||
// Provider represents provider routing preferences for OpenRouter.
|
||||
type Provider struct {
|
||||
// List of provider slugs to try in order (e.g. ["anthropic", "openai"])
|
||||
Order []string `json:"order,omitempty"`
|
||||
// Whether to allow backup providers when primary is unavailable (default: true)
|
||||
AllowFallbacks *bool `json:"allow_fallbacks,omitempty"`
|
||||
// Only use providers that support all parameters in your request (default: false)
|
||||
RequireParameters *bool `json:"require_parameters,omitempty"`
|
||||
// Control whether to use providers that may store data: "allow" | "deny"
|
||||
DataCollection *string `json:"data_collection,omitempty"`
|
||||
// List of provider slugs to allow for this request
|
||||
Only []string `json:"only,omitempty"`
|
||||
// List of provider slugs to skip for this request
|
||||
Ignore []string `json:"ignore,omitempty"`
|
||||
// List of quantization levels to filter by (e.g. ["int4", "int8"])
|
||||
Quantizations []string `json:"quantizations,omitempty"`
|
||||
// Sort providers by "price" | "throughput" | "latency"
|
||||
Sort *string `json:"sort,omitempty"`
|
||||
}
|
||||
|
||||
// ProviderOptions represents additional options for OpenRouter provider.
|
||||
type ProviderOptions struct {
|
||||
Reasoning *ReasoningOptions `json:"reasoning,omitempty"`
|
||||
ExtraBody map[string]any `json:"extra_body,omitempty"`
|
||||
IncludeUsage *bool `json:"include_usage,omitempty"`
|
||||
// Modify the likelihood of specified tokens appearing in the completion.
|
||||
// Accepts a map that maps tokens (specified by their token ID) to an associated bias value from -100 to 100.
|
||||
// The bias is added to the logits generated by the model prior to sampling.
|
||||
LogitBias map[string]int64 `json:"logit_bias,omitempty"`
|
||||
// Return the log probabilities of the tokens. Including logprobs will increase the response size.
|
||||
// Setting to true will return the log probabilities of the tokens that were generated.
|
||||
LogProbs *bool `json:"log_probs,omitempty"`
|
||||
// Whether to enable parallel function calling during tool use. Default to true.
|
||||
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
|
||||
// A unique identifier representing your end-user, which can help OpenRouter to monitor and detect abuse.
|
||||
User *string `json:"user,omitempty"`
|
||||
// Provider routing preferences to control request routing behavior
|
||||
Provider *Provider `json:"provider,omitempty"`
|
||||
// TODO: add the web search plugin config
|
||||
}
|
||||
|
||||
// Options implements the ProviderOptionsData interface for ProviderOptions.
|
||||
func (*ProviderOptions) Options() {}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling with type info for ProviderOptions.
|
||||
func (o ProviderOptions) MarshalJSON() ([]byte, error) {
|
||||
type plain ProviderOptions
|
||||
return fantasy.MarshalProviderType(TypeProviderOptions, plain(o))
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom JSON unmarshaling with type info for ProviderOptions.
|
||||
func (o *ProviderOptions) UnmarshalJSON(data []byte) error {
|
||||
type plain ProviderOptions
|
||||
var p plain
|
||||
if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
|
||||
return err
|
||||
}
|
||||
*o = ProviderOptions(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReasoningDetail represents a reasoning detail for OpenRouter.
|
||||
type ReasoningDetail struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
Index int `json:"index"`
|
||||
}
|
||||
|
||||
// ReasoningData represents reasoning data for OpenRouter.
|
||||
type ReasoningData struct {
|
||||
Reasoning string `json:"reasoning"`
|
||||
ReasoningDetails []ReasoningDetail `json:"reasoning_details"`
|
||||
}
|
||||
|
||||
// ReasoningEffortOption creates a pointer to a ReasoningEffort value for OpenRouter.
|
||||
func ReasoningEffortOption(e ReasoningEffort) *ReasoningEffort {
|
||||
return &e
|
||||
}
|
||||
|
||||
// NewProviderOptions creates new provider options for OpenRouter.
|
||||
func NewProviderOptions(opts *ProviderOptions) fantasy.ProviderOptions {
|
||||
return fantasy.ProviderOptions{
|
||||
Name: opts,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseOptions parses provider options from a map for OpenRouter.
|
||||
func ParseOptions(data map[string]any) (*ProviderOptions, error) {
|
||||
var options ProviderOptions
|
||||
if err := fantasy.ParseOptions(data, &options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &options, nil
|
||||
}
|
||||
1052
internal/fantasy/providers/vercel/language_model_hooks.go
Normal file
1052
internal/fantasy/providers/vercel/language_model_hooks.go
Normal file
File diff suppressed because it is too large
Load diff
191
internal/fantasy/providers/vercel/provider_options.go
Normal file
191
internal/fantasy/providers/vercel/provider_options.go
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
// Package vercel provides an implementation of the fantasy AI SDK for Vercel AI Gateway.
|
||||
package vercel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
|
||||
// Global type identifiers for Vercel-specific provider data.
|
||||
const (
|
||||
TypeProviderOptions = Name + ".options"
|
||||
TypeProviderMetadata = Name + ".metadata"
|
||||
)
|
||||
|
||||
// Register Vercel provider-specific types with the global registry.
|
||||
func init() {
|
||||
fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
|
||||
var v ProviderOptions
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
})
|
||||
fantasy.RegisterProviderType(TypeProviderMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) {
|
||||
var v ProviderMetadata
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ReasoningEffort represents the reasoning effort level for Vercel AI Gateway.
|
||||
type ReasoningEffort string
|
||||
|
||||
const (
|
||||
// ReasoningEffortNone disables reasoning.
|
||||
ReasoningEffortNone ReasoningEffort = "none"
|
||||
// ReasoningEffortMinimal represents minimal reasoning effort (~10% of max_tokens).
|
||||
ReasoningEffortMinimal ReasoningEffort = "minimal"
|
||||
// ReasoningEffortLow represents low reasoning effort (~20% of max_tokens).
|
||||
ReasoningEffortLow ReasoningEffort = "low"
|
||||
// ReasoningEffortMedium represents medium reasoning effort (~50% of max_tokens).
|
||||
ReasoningEffortMedium ReasoningEffort = "medium"
|
||||
// ReasoningEffortHigh represents high reasoning effort (~80% of max_tokens).
|
||||
ReasoningEffortHigh ReasoningEffort = "high"
|
||||
// ReasoningEffortXHigh represents extra high reasoning effort (~95% of max_tokens).
|
||||
ReasoningEffortXHigh ReasoningEffort = "xhigh"
|
||||
)
|
||||
|
||||
// ReasoningOptions represents reasoning configuration for Vercel AI Gateway.
|
||||
type ReasoningOptions struct {
|
||||
// Enabled enables reasoning output. When true, the model will provide its reasoning process.
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
// MaxTokens is the maximum number of tokens to allocate for reasoning.
|
||||
// Cannot be used with Effort.
|
||||
MaxTokens *int64 `json:"max_tokens,omitempty"`
|
||||
// Effort controls reasoning effort level.
|
||||
// Mutually exclusive with MaxTokens.
|
||||
Effort *ReasoningEffort `json:"effort,omitempty"`
|
||||
// Exclude excludes reasoning content from the response but still generates it internally.
|
||||
Exclude *bool `json:"exclude,omitempty"`
|
||||
}
|
||||
|
||||
// GatewayProviderOptions represents provider routing preferences for Vercel AI Gateway.
|
||||
type GatewayProviderOptions struct {
|
||||
// Order is the list of provider slugs to try in order (e.g. ["vertex", "anthropic"]).
|
||||
Order []string `json:"order,omitempty"`
|
||||
// Models is the list of fallback models to try if the primary model fails.
|
||||
Models []string `json:"models,omitempty"`
|
||||
}
|
||||
|
||||
// BYOKCredential represents a single provider credential for BYOK.
|
||||
type BYOKCredential struct {
|
||||
APIKey string `json:"apiKey,omitempty"`
|
||||
}
|
||||
|
||||
// BYOKOptions represents Bring Your Own Key options for Vercel AI Gateway.
|
||||
type BYOKOptions struct {
|
||||
Anthropic map[string][]BYOKCredential `json:"anthropic,omitempty"`
|
||||
OpenAI map[string][]BYOKCredential `json:"openai,omitempty"`
|
||||
Vertex map[string][]BYOKCredential `json:"vertex,omitempty"`
|
||||
Bedrock map[string][]BYOKCredential `json:"bedrock,omitempty"`
|
||||
}
|
||||
|
||||
// ProviderOptions represents additional options for Vercel AI Gateway provider.
|
||||
type ProviderOptions struct {
|
||||
// Reasoning configuration for models that support extended thinking.
|
||||
Reasoning *ReasoningOptions `json:"reasoning,omitempty"`
|
||||
// ProviderOptions for gateway routing preferences.
|
||||
ProviderOptions *GatewayProviderOptions `json:"providerOptions,omitempty"`
|
||||
// BYOK for request-scoped provider credentials.
|
||||
BYOK *BYOKOptions `json:"byok,omitempty"`
|
||||
// User is a unique identifier representing your end-user.
|
||||
User *string `json:"user,omitempty"`
|
||||
// LogitBias modifies the likelihood of specified tokens appearing in the completion.
|
||||
LogitBias map[string]int64 `json:"logit_bias,omitempty"`
|
||||
// LogProbs returns the log probabilities of the tokens.
|
||||
LogProbs *bool `json:"logprobs,omitempty"`
|
||||
// TopLogProbs is the number of top log probabilities to return.
|
||||
TopLogProbs *int64 `json:"top_logprobs,omitempty"`
|
||||
// ParallelToolCalls enables parallel function calling during tool use.
|
||||
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
|
||||
// ExtraBody for additional request body fields.
|
||||
ExtraBody map[string]any `json:"extra_body,omitempty"`
|
||||
}
|
||||
|
||||
// Options implements the ProviderOptionsData interface for ProviderOptions.
|
||||
func (*ProviderOptions) Options() {}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling with type info for ProviderOptions.
|
||||
func (o ProviderOptions) MarshalJSON() ([]byte, error) {
|
||||
type plain ProviderOptions
|
||||
return fantasy.MarshalProviderType(TypeProviderOptions, plain(o))
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom JSON unmarshaling with type info for ProviderOptions.
|
||||
func (o *ProviderOptions) UnmarshalJSON(data []byte) error {
|
||||
type plain ProviderOptions
|
||||
var p plain
|
||||
if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
|
||||
return err
|
||||
}
|
||||
*o = ProviderOptions(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProviderMetadata represents metadata from Vercel AI Gateway provider.
|
||||
type ProviderMetadata struct {
|
||||
Provider string `json:"provider,omitempty"`
|
||||
}
|
||||
|
||||
// Options implements the ProviderOptionsData interface for ProviderMetadata.
|
||||
func (*ProviderMetadata) Options() {}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling with type info for ProviderMetadata.
|
||||
func (m ProviderMetadata) MarshalJSON() ([]byte, error) {
|
||||
type plain ProviderMetadata
|
||||
return fantasy.MarshalProviderType(TypeProviderMetadata, plain(m))
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom JSON unmarshaling with type info for ProviderMetadata.
|
||||
func (m *ProviderMetadata) UnmarshalJSON(data []byte) error {
|
||||
type plain ProviderMetadata
|
||||
var p plain
|
||||
if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
|
||||
return err
|
||||
}
|
||||
*m = ProviderMetadata(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReasoningDetail represents a reasoning detail from Vercel AI Gateway.
|
||||
type ReasoningDetail struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
Index int `json:"index"`
|
||||
}
|
||||
|
||||
// ReasoningData represents reasoning data from Vercel AI Gateway response.
|
||||
type ReasoningData struct {
|
||||
Reasoning string `json:"reasoning,omitempty"`
|
||||
ReasoningDetails []ReasoningDetail `json:"reasoning_details,omitempty"`
|
||||
}
|
||||
|
||||
// ReasoningEffortOption creates a pointer to a ReasoningEffort value.
|
||||
func ReasoningEffortOption(e ReasoningEffort) *ReasoningEffort {
|
||||
return &e
|
||||
}
|
||||
|
||||
// NewProviderOptions creates new provider options for Vercel.
|
||||
func NewProviderOptions(opts *ProviderOptions) fantasy.ProviderOptions {
|
||||
return fantasy.ProviderOptions{
|
||||
Name: opts,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseOptions parses provider options from a map for Vercel.
|
||||
func ParseOptions(data map[string]any) (*ProviderOptions, error) {
|
||||
var options ProviderOptions
|
||||
if err := fantasy.ParseOptions(data, &options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &options, nil
|
||||
}
|
||||
114
internal/fantasy/providers/vercel/vercel.go
Normal file
114
internal/fantasy/providers/vercel/vercel.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// Package vercel provides an implementation of the fantasy AI SDK for Vercel AI Gateway.
|
||||
package vercel
|
||||
|
||||
import (
|
||||
"charm.land/fantasy"
|
||||
"charm.land/fantasy/providers/openai"
|
||||
"github.com/openai/openai-go/v2/option"
|
||||
)
|
||||
|
||||
type options struct {
|
||||
openaiOptions []openai.Option
|
||||
languageModelOptions []openai.LanguageModelOption
|
||||
sdkOptions []option.RequestOption
|
||||
objectMode fantasy.ObjectMode
|
||||
}
|
||||
|
||||
const (
|
||||
// DefaultURL is the default URL for the Vercel AI Gateway API.
|
||||
DefaultURL = "https://ai-gateway.vercel.sh/v1"
|
||||
// Name is the name of the Vercel provider.
|
||||
Name = "vercel"
|
||||
)
|
||||
|
||||
// Option defines a function that configures Vercel provider options.
|
||||
type Option = func(*options)
|
||||
|
||||
// New creates a new Vercel AI Gateway provider with the given options.
|
||||
func New(opts ...Option) (fantasy.Provider, error) {
|
||||
providerOptions := options{
|
||||
openaiOptions: []openai.Option{
|
||||
openai.WithName(Name),
|
||||
openai.WithBaseURL(DefaultURL),
|
||||
},
|
||||
languageModelOptions: []openai.LanguageModelOption{
|
||||
openai.WithLanguageModelPrepareCallFunc(languagePrepareModelCall),
|
||||
openai.WithLanguageModelUsageFunc(languageModelUsage),
|
||||
openai.WithLanguageModelStreamUsageFunc(languageModelStreamUsage),
|
||||
openai.WithLanguageModelStreamExtraFunc(languageModelStreamExtra),
|
||||
openai.WithLanguageModelExtraContentFunc(languageModelExtraContent),
|
||||
openai.WithLanguageModelToPromptFunc(languageModelToPrompt),
|
||||
},
|
||||
objectMode: fantasy.ObjectModeTool, // Default to tool mode for vercel
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(&providerOptions)
|
||||
}
|
||||
|
||||
// Handle object mode: convert unsupported modes to tool
|
||||
// Vercel AI Gateway doesn't support native JSON mode, so we use tool or text
|
||||
objectMode := providerOptions.objectMode
|
||||
if objectMode == fantasy.ObjectModeAuto || objectMode == fantasy.ObjectModeJSON {
|
||||
objectMode = fantasy.ObjectModeTool
|
||||
}
|
||||
|
||||
providerOptions.openaiOptions = append(
|
||||
providerOptions.openaiOptions,
|
||||
openai.WithSDKOptions(providerOptions.sdkOptions...),
|
||||
openai.WithLanguageModelOptions(providerOptions.languageModelOptions...),
|
||||
openai.WithObjectMode(objectMode),
|
||||
)
|
||||
return openai.New(providerOptions.openaiOptions...)
|
||||
}
|
||||
|
||||
// WithAPIKey sets the API key for the Vercel provider.
|
||||
func WithAPIKey(apiKey string) Option {
|
||||
return func(o *options) {
|
||||
o.openaiOptions = append(o.openaiOptions, openai.WithAPIKey(apiKey))
|
||||
}
|
||||
}
|
||||
|
||||
// WithBaseURL sets the base URL for the Vercel provider.
|
||||
func WithBaseURL(url string) Option {
|
||||
return func(o *options) {
|
||||
o.openaiOptions = append(o.openaiOptions, openai.WithBaseURL(url))
|
||||
}
|
||||
}
|
||||
|
||||
// WithName sets the name for the Vercel provider.
|
||||
func WithName(name string) Option {
|
||||
return func(o *options) {
|
||||
o.openaiOptions = append(o.openaiOptions, openai.WithName(name))
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaders sets the headers for the Vercel provider.
|
||||
func WithHeaders(headers map[string]string) Option {
|
||||
return func(o *options) {
|
||||
o.openaiOptions = append(o.openaiOptions, openai.WithHeaders(headers))
|
||||
}
|
||||
}
|
||||
|
||||
// WithHTTPClient sets the HTTP client for the Vercel provider.
|
||||
func WithHTTPClient(client option.HTTPClient) Option {
|
||||
return func(o *options) {
|
||||
o.openaiOptions = append(o.openaiOptions, openai.WithHTTPClient(client))
|
||||
}
|
||||
}
|
||||
|
||||
// WithSDKOptions sets the SDK options for the Vercel provider.
|
||||
func WithSDKOptions(opts ...option.RequestOption) Option {
|
||||
return func(o *options) {
|
||||
o.sdkOptions = append(o.sdkOptions, opts...)
|
||||
}
|
||||
}
|
||||
|
||||
// WithObjectMode sets the object generation mode for the Vercel provider.
|
||||
// Supported modes: ObjectModeTool, ObjectModeText.
|
||||
// ObjectModeAuto and ObjectModeJSON are automatically converted to ObjectModeTool
|
||||
// since Vercel AI Gateway doesn't support native JSON mode.
|
||||
func WithObjectMode(om fantasy.ObjectMode) Option {
|
||||
return func(o *options) {
|
||||
o.objectMode = om
|
||||
}
|
||||
}
|
||||
13
internal/fantasy/providertests/.env.sample
Normal file
13
internal/fantasy/providertests/.env.sample
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
FANTASY_ANTHROPIC_API_KEY=
|
||||
FANTASY_AZURE_API_KEY=
|
||||
FANTASY_AZURE_BASE_URL=
|
||||
FANTASY_BEDROCK_API_KEY=
|
||||
FANTASY_GEMINI_API_KEY=
|
||||
FANTASY_GROQ_API_KEY=
|
||||
FANTASY_HUGGINGFACE_API_KEY=
|
||||
FANTASY_OPENAI_API_KEY=
|
||||
FANTASY_OPENROUTER_API_KEY=
|
||||
FANTASY_VERTEX_LOCATION=us-east5
|
||||
FANTASY_VERTEX_PROJECT=fantasy-playground-472418
|
||||
FANTASY_XAI_API_KEY=
|
||||
FANTASY_ZAI_API_KEY=
|
||||
157
internal/fantasy/providertests/anthropic_test.go
Normal file
157
internal/fantasy/providertests/anthropic_test.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
package providertests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"charm.land/fantasy/providers/anthropic"
|
||||
"charm.land/x/vcr"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var anthropicTestModels = []testModel{
|
||||
{"claude-sonnet-4", "claude-sonnet-4-20250514", true},
|
||||
}
|
||||
|
||||
func TestAnthropicCommon(t *testing.T) {
|
||||
var pairs []builderPair
|
||||
for _, m := range anthropicTestModels {
|
||||
pairs = append(pairs, builderPair{m.name, anthropicBuilder(m.model), nil, nil})
|
||||
}
|
||||
testCommon(t, pairs)
|
||||
}
|
||||
|
||||
func addAnthropicCaching(ctx context.Context, options fantasy.PrepareStepFunctionOptions) (context.Context, fantasy.PrepareStepResult, error) {
|
||||
prepared := fantasy.PrepareStepResult{}
|
||||
prepared.Messages = options.Messages
|
||||
|
||||
for i := range prepared.Messages {
|
||||
prepared.Messages[i].ProviderOptions = nil
|
||||
}
|
||||
providerOption := fantasy.ProviderOptions{
|
||||
anthropic.Name: &anthropic.ProviderCacheControlOptions{
|
||||
CacheControl: anthropic.CacheControl{Type: "ephemeral"},
|
||||
},
|
||||
}
|
||||
|
||||
lastSystemRoleInx := 0
|
||||
systemMessageUpdated := false
|
||||
for i, msg := range prepared.Messages {
|
||||
// only add cache control to the last message
|
||||
if msg.Role == fantasy.MessageRoleSystem {
|
||||
lastSystemRoleInx = i
|
||||
} else if !systemMessageUpdated {
|
||||
prepared.Messages[lastSystemRoleInx].ProviderOptions = providerOption
|
||||
systemMessageUpdated = true
|
||||
}
|
||||
// than add cache control to the last 2 messages
|
||||
if i > len(prepared.Messages)-3 {
|
||||
prepared.Messages[i].ProviderOptions = providerOption
|
||||
}
|
||||
}
|
||||
return ctx, prepared, nil
|
||||
}
|
||||
|
||||
func TestAnthropicCommonWithCacheControl(t *testing.T) {
|
||||
var pairs []builderPair
|
||||
for _, m := range anthropicTestModels {
|
||||
pairs = append(pairs, builderPair{m.name, anthropicBuilder(m.model), nil, addAnthropicCaching})
|
||||
}
|
||||
testCommon(t, pairs)
|
||||
}
|
||||
|
||||
func TestAnthropicThinking(t *testing.T) {
|
||||
opts := fantasy.ProviderOptions{
|
||||
anthropic.Name: &anthropic.ProviderOptions{
|
||||
Thinking: &anthropic.ThinkingProviderOption{
|
||||
BudgetTokens: 4000,
|
||||
},
|
||||
},
|
||||
}
|
||||
var pairs []builderPair
|
||||
for _, m := range anthropicTestModels {
|
||||
if !m.reasoning {
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs, builderPair{m.name, anthropicBuilder(m.model), opts, nil})
|
||||
}
|
||||
testThinking(t, pairs, testAnthropicThinking)
|
||||
}
|
||||
|
||||
func TestAnthropicThinkingWithCacheControl(t *testing.T) {
|
||||
opts := fantasy.ProviderOptions{
|
||||
anthropic.Name: &anthropic.ProviderOptions{
|
||||
Thinking: &anthropic.ThinkingProviderOption{
|
||||
BudgetTokens: 4000,
|
||||
},
|
||||
},
|
||||
}
|
||||
var pairs []builderPair
|
||||
for _, m := range anthropicTestModels {
|
||||
if !m.reasoning {
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs, builderPair{m.name, anthropicBuilder(m.model), opts, addAnthropicCaching})
|
||||
}
|
||||
testThinking(t, pairs, testAnthropicThinking)
|
||||
}
|
||||
|
||||
func TestAnthropicObjectGeneration(t *testing.T) {
|
||||
var pairs []builderPair
|
||||
for _, m := range anthropicTestModels {
|
||||
pairs = append(pairs, builderPair{m.name, anthropicBuilder(m.model), nil, nil})
|
||||
}
|
||||
testObjectGeneration(t, pairs)
|
||||
}
|
||||
|
||||
func testAnthropicThinking(t *testing.T, result *fantasy.AgentResult) {
|
||||
reasoningContentCount := 0
|
||||
signaturesCount := 0
|
||||
// Test if we got the signature
|
||||
for _, step := range result.Steps {
|
||||
for _, msg := range step.Messages {
|
||||
for _, content := range msg.Content {
|
||||
if content.GetType() == fantasy.ContentTypeReasoning {
|
||||
reasoningContentCount += 1
|
||||
reasoningContent, ok := fantasy.AsContentType[fantasy.ReasoningPart](content)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if len(reasoningContent.ProviderOptions) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
anthropicReasoningMetadata, ok := reasoningContent.ProviderOptions[anthropic.Name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if reasoningContent.Text != "" {
|
||||
if typed, ok := anthropicReasoningMetadata.(*anthropic.ReasoningOptionMetadata); ok {
|
||||
require.NotEmpty(t, typed.Signature)
|
||||
signaturesCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
require.Greater(t, reasoningContentCount, 0)
|
||||
require.Greater(t, signaturesCount, 0)
|
||||
require.Equal(t, reasoningContentCount, signaturesCount)
|
||||
}
|
||||
|
||||
func anthropicBuilder(model string) builderFunc {
|
||||
return func(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
|
||||
provider, err := anthropic.New(
|
||||
anthropic.WithAPIKey(os.Getenv("FANTASY_ANTHROPIC_API_KEY")),
|
||||
anthropic.WithHTTPClient(&http.Client{Transport: r}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.LanguageModel(t.Context(), model)
|
||||
}
|
||||
}
|
||||
98
internal/fantasy/providertests/azure_responses_test.go
Normal file
98
internal/fantasy/providertests/azure_responses_test.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package providertests
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"charm.land/fantasy/providers/azure"
|
||||
"charm.land/fantasy/providers/openai"
|
||||
"charm.land/x/vcr"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAzureResponsesCommon(t *testing.T) {
|
||||
var pairs []builderPair
|
||||
models := []testModel{
|
||||
{"azure-gpt-5-mini", "gpt-5-mini", true},
|
||||
{"azure-o4-mini", "o4-mini", true},
|
||||
}
|
||||
for _, m := range models {
|
||||
pairs = append(pairs, builderPair{m.name, azureReasoningBuilder(m.model), nil, nil})
|
||||
}
|
||||
testCommon(t, pairs)
|
||||
}
|
||||
|
||||
func azureReasoningBuilder(model string) builderFunc {
|
||||
return func(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
|
||||
provider, err := azure.New(
|
||||
azure.WithBaseURL(cmp.Or(os.Getenv("FANTASY_AZURE_BASE_URL"), defaultBaseURL)),
|
||||
azure.WithAPIKey(cmp.Or(os.Getenv("FANTASY_AZURE_API_KEY"), "(missing)")),
|
||||
azure.WithHTTPClient(&http.Client{Transport: r}),
|
||||
azure.WithUseResponsesAPI(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.LanguageModel(t.Context(), model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureResponsesWithSummaryThinking(t *testing.T) {
|
||||
opts := fantasy.ProviderOptions{
|
||||
openai.Name: &openai.ResponsesProviderOptions{
|
||||
Include: []openai.IncludeType{
|
||||
openai.IncludeReasoningEncryptedContent,
|
||||
},
|
||||
ReasoningEffort: openai.ReasoningEffortOption(openai.ReasoningEffortHigh),
|
||||
ReasoningSummary: fantasy.Opt("auto"),
|
||||
},
|
||||
}
|
||||
var pairs []builderPair
|
||||
models := []testModel{
|
||||
{"azure-gpt-5-mini", "gpt-5-mini", true},
|
||||
}
|
||||
for _, m := range models {
|
||||
if !m.reasoning {
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs, builderPair{m.name, azureReasoningBuilder(m.model), opts, nil})
|
||||
}
|
||||
testThinking(t, pairs, testAzureResponsesThinkingWithSummaryThinking)
|
||||
}
|
||||
|
||||
func testAzureResponsesThinkingWithSummaryThinking(t *testing.T, result *fantasy.AgentResult) {
|
||||
reasoningContentCount := 0
|
||||
encryptedData := 0
|
||||
// Test if we got the signature
|
||||
for _, step := range result.Steps {
|
||||
for _, msg := range step.Messages {
|
||||
for _, content := range msg.Content {
|
||||
if content.GetType() == fantasy.ContentTypeReasoning {
|
||||
reasoningContentCount += 1
|
||||
reasoningContent, ok := fantasy.AsContentType[fantasy.ReasoningPart](content)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if len(reasoningContent.ProviderOptions) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
openaiReasoningMetadata, ok := reasoningContent.ProviderOptions[openai.Name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if typed, ok := openaiReasoningMetadata.(*openai.ResponsesReasoningMetadata); ok {
|
||||
require.NotEmpty(t, typed.EncryptedContent)
|
||||
encryptedData += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
require.Greater(t, reasoningContentCount, 0)
|
||||
require.Greater(t, encryptedData, 0)
|
||||
require.Equal(t, reasoningContentCount, encryptedData)
|
||||
}
|
||||
76
internal/fantasy/providertests/azure_test.go
Normal file
76
internal/fantasy/providertests/azure_test.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package providertests
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"charm.land/fantasy/providers/azure"
|
||||
"charm.land/fantasy/providers/openai"
|
||||
"charm.land/x/vcr"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const defaultBaseURL = "https://fantasy-playground-resource.openai.azure.com"
|
||||
|
||||
func TestAzureCommon(t *testing.T) {
|
||||
testCommon(t, []builderPair{
|
||||
{"azure-o4-mini", builderAzureO4Mini, nil, nil},
|
||||
{"azure-gpt-5-mini", builderAzureGpt5Mini, nil, nil},
|
||||
{"azure-grok-3-mini", builderAzureGrok3Mini, nil, nil},
|
||||
})
|
||||
}
|
||||
|
||||
func TestAzureThinking(t *testing.T) {
|
||||
opts := fantasy.ProviderOptions{
|
||||
openai.Name: &openai.ProviderOptions{
|
||||
ReasoningEffort: openai.ReasoningEffortOption(openai.ReasoningEffortHigh),
|
||||
},
|
||||
}
|
||||
testThinking(t, []builderPair{
|
||||
{"azure-gpt-5-mini", builderAzureGpt5Mini, opts, nil},
|
||||
{"azure-grok-3-mini", builderAzureGrok3Mini, opts, nil},
|
||||
}, testAzureThinking)
|
||||
}
|
||||
|
||||
func testAzureThinking(t *testing.T, result *fantasy.AgentResult) {
|
||||
require.Greater(t, result.Response.Usage.ReasoningTokens, int64(0), "expected reasoning tokens, got none")
|
||||
}
|
||||
|
||||
func builderAzureO4Mini(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
|
||||
provider, err := azure.New(
|
||||
azure.WithBaseURL(cmp.Or(os.Getenv("FANTASY_AZURE_BASE_URL"), defaultBaseURL)),
|
||||
azure.WithAPIKey(cmp.Or(os.Getenv("FANTASY_AZURE_API_KEY"), "(missing)")),
|
||||
azure.WithHTTPClient(&http.Client{Transport: r}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.LanguageModel(t.Context(), "o4-mini")
|
||||
}
|
||||
|
||||
func builderAzureGpt5Mini(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
|
||||
provider, err := azure.New(
|
||||
azure.WithBaseURL(cmp.Or(os.Getenv("FANTASY_AZURE_BASE_URL"), defaultBaseURL)),
|
||||
azure.WithAPIKey(cmp.Or(os.Getenv("FANTASY_AZURE_API_KEY"), "(missing)")),
|
||||
azure.WithHTTPClient(&http.Client{Transport: r}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.LanguageModel(t.Context(), "gpt-5-mini")
|
||||
}
|
||||
|
||||
func builderAzureGrok3Mini(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
|
||||
provider, err := azure.New(
|
||||
azure.WithBaseURL(cmp.Or(os.Getenv("FANTASY_AZURE_BASE_URL"), defaultBaseURL)),
|
||||
azure.WithAPIKey(cmp.Or(os.Getenv("FANTASY_AZURE_API_KEY"), "(missing)")),
|
||||
azure.WithHTTPClient(&http.Client{Transport: r}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.LanguageModel(t.Context(), "grok-3-mini")
|
||||
}
|
||||
68
internal/fantasy/providertests/bedrock_test.go
Normal file
68
internal/fantasy/providertests/bedrock_test.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package providertests
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"charm.land/fantasy/providers/bedrock"
|
||||
"charm.land/x/vcr"
|
||||
)
|
||||
|
||||
func TestBedrockCommon(t *testing.T) {
|
||||
testCommon(t, []builderPair{
|
||||
{"bedrock-anthropic-claude-3-sonnet", builderBedrockClaude3Sonnet, nil, nil},
|
||||
{"bedrock-anthropic-claude-3-opus", builderBedrockClaude3Opus, nil, nil},
|
||||
{"bedrock-anthropic-claude-3-haiku", builderBedrockClaude3Haiku, nil, nil},
|
||||
})
|
||||
}
|
||||
|
||||
func TestBedrockBasicAuth(t *testing.T) {
|
||||
testSimple(t, builderPair{"bedrock-anthropic-claude-3-sonnet", buildersBedrockBasicAuth, nil, nil})
|
||||
}
|
||||
|
||||
func builderBedrockClaude3Sonnet(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
|
||||
provider, err := bedrock.New(
|
||||
bedrock.WithHTTPClient(&http.Client{Transport: r}),
|
||||
bedrock.WithSkipAuth(!r.IsRecording()),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.LanguageModel(t.Context(), "anthropic.claude-3-sonnet-20240229-v1:0")
|
||||
}
|
||||
|
||||
func builderBedrockClaude3Opus(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
|
||||
provider, err := bedrock.New(
|
||||
bedrock.WithHTTPClient(&http.Client{Transport: r}),
|
||||
bedrock.WithSkipAuth(!r.IsRecording()),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.LanguageModel(t.Context(), "anthropic.claude-3-opus-20240229-v1:0")
|
||||
}
|
||||
|
||||
func builderBedrockClaude3Haiku(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
|
||||
provider, err := bedrock.New(
|
||||
bedrock.WithHTTPClient(&http.Client{Transport: r}),
|
||||
bedrock.WithSkipAuth(!r.IsRecording()),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.LanguageModel(t.Context(), "anthropic.claude-3-haiku-20240307-v1:0")
|
||||
}
|
||||
|
||||
func buildersBedrockBasicAuth(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
|
||||
provider, err := bedrock.New(
|
||||
bedrock.WithHTTPClient(&http.Client{Transport: r}),
|
||||
bedrock.WithAPIKey(os.Getenv("FANTASY_BEDROCK_API_KEY")),
|
||||
bedrock.WithSkipAuth(true),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.LanguageModel(t.Context(), "anthropic.claude-3-sonnet-20240229-v1:0")
|
||||
}
|
||||
361
internal/fantasy/providertests/common_test.go
Normal file
361
internal/fantasy/providertests/common_test.go
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
package providertests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"charm.land/x/vcr"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func init() {
|
||||
if _, err := os.Stat(".env"); err == nil {
|
||||
godotenv.Load(".env")
|
||||
} else {
|
||||
godotenv.Load(".env.sample")
|
||||
}
|
||||
}
|
||||
|
||||
type testModel struct {
|
||||
name string
|
||||
model string
|
||||
reasoning bool
|
||||
}
|
||||
|
||||
type builderFunc func(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error)
|
||||
|
||||
type builderPair struct {
|
||||
name string
|
||||
builder builderFunc
|
||||
providerOptions fantasy.ProviderOptions
|
||||
prepareStep fantasy.PrepareStepFunction
|
||||
}
|
||||
|
||||
func testCommon(t *testing.T, pairs []builderPair) {
|
||||
for _, pair := range pairs {
|
||||
t.Run(pair.name, func(t *testing.T) {
|
||||
testSimple(t, pair)
|
||||
testTool(t, pair)
|
||||
testMultiTool(t, pair)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testSimple(t *testing.T, pair builderPair) {
|
||||
checkResult := func(t *testing.T, result *fantasy.AgentResult) {
|
||||
options := []string{"Oi", "oi", "Olá", "olá"}
|
||||
got := result.Response.Content.Text()
|
||||
require.True(t, containsAny(got, options...), "unexpected response: got %q, want any of: %q", got, options)
|
||||
}
|
||||
|
||||
t.Run("simple", func(t *testing.T) {
|
||||
r := vcr.NewRecorder(t)
|
||||
|
||||
languageModel, err := pair.builder(t, r)
|
||||
require.NoError(t, err, "failed to build language model")
|
||||
|
||||
agent := fantasy.NewAgent(
|
||||
languageModel,
|
||||
fantasy.WithSystemPrompt("You are a helpful assistant"),
|
||||
)
|
||||
result, err := agent.Generate(t.Context(), fantasy.AgentCall{
|
||||
Prompt: "Say hi in Portuguese",
|
||||
ProviderOptions: pair.providerOptions,
|
||||
MaxOutputTokens: fantasy.Opt(int64(4000)),
|
||||
PrepareStep: pair.prepareStep,
|
||||
})
|
||||
require.NoError(t, err, "failed to generate")
|
||||
checkResult(t, result)
|
||||
})
|
||||
t.Run("simple streaming", func(t *testing.T) {
|
||||
r := vcr.NewRecorder(t)
|
||||
|
||||
languageModel, err := pair.builder(t, r)
|
||||
require.NoError(t, err, "failed to build language model")
|
||||
|
||||
agent := fantasy.NewAgent(
|
||||
languageModel,
|
||||
fantasy.WithSystemPrompt("You are a helpful assistant"),
|
||||
)
|
||||
result, err := agent.Stream(t.Context(), fantasy.AgentStreamCall{
|
||||
Prompt: "Say hi in Portuguese",
|
||||
ProviderOptions: pair.providerOptions,
|
||||
MaxOutputTokens: fantasy.Opt(int64(4000)),
|
||||
PrepareStep: pair.prepareStep,
|
||||
})
|
||||
require.NoError(t, err, "failed to generate")
|
||||
checkResult(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func testTool(t *testing.T, pair builderPair) {
|
||||
type WeatherInput struct {
|
||||
Location string `json:"location" description:"the city"`
|
||||
}
|
||||
|
||||
weatherTool := fantasy.NewAgentTool(
|
||||
"weather",
|
||||
"Get weather information for a location",
|
||||
func(ctx context.Context, input WeatherInput, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
return fantasy.NewTextResponse("40 C"), nil
|
||||
},
|
||||
)
|
||||
checkResult := func(t *testing.T, result *fantasy.AgentResult) {
|
||||
require.GreaterOrEqual(t, len(result.Steps), 2)
|
||||
|
||||
var toolCalls []fantasy.ToolCallContent
|
||||
for _, content := range result.Steps[0].Content {
|
||||
if content.GetType() == fantasy.ContentTypeToolCall {
|
||||
toolCalls = append(toolCalls, content.(fantasy.ToolCallContent))
|
||||
}
|
||||
}
|
||||
for _, tc := range toolCalls {
|
||||
require.False(t, tc.Invalid)
|
||||
}
|
||||
require.Len(t, toolCalls, 1)
|
||||
require.Equal(t, toolCalls[0].ToolName, "weather")
|
||||
|
||||
want1 := "Florence"
|
||||
want2 := "40"
|
||||
got := result.Response.Content.Text()
|
||||
require.True(t, strings.Contains(got, want1) && strings.Contains(got, want2), "unexpected response: got %q, want %q %q", got, want1, want2)
|
||||
}
|
||||
|
||||
t.Run("tool", func(t *testing.T) {
|
||||
r := vcr.NewRecorder(t)
|
||||
|
||||
languageModel, err := pair.builder(t, r)
|
||||
require.NoError(t, err, "failed to build language model")
|
||||
|
||||
agent := fantasy.NewAgent(
|
||||
languageModel,
|
||||
fantasy.WithSystemPrompt("You are a helpful assistant"),
|
||||
fantasy.WithTools(weatherTool),
|
||||
)
|
||||
result, err := agent.Generate(t.Context(), fantasy.AgentCall{
|
||||
Prompt: "What's the weather in Florence,Italy?",
|
||||
ProviderOptions: pair.providerOptions,
|
||||
MaxOutputTokens: fantasy.Opt(int64(4000)),
|
||||
PrepareStep: pair.prepareStep,
|
||||
})
|
||||
require.NoError(t, err, "failed to generate")
|
||||
checkResult(t, result)
|
||||
})
|
||||
t.Run("tool streaming", func(t *testing.T) {
|
||||
r := vcr.NewRecorder(t)
|
||||
|
||||
languageModel, err := pair.builder(t, r)
|
||||
require.NoError(t, err, "failed to build language model")
|
||||
|
||||
agent := fantasy.NewAgent(
|
||||
languageModel,
|
||||
fantasy.WithSystemPrompt("You are a helpful assistant"),
|
||||
fantasy.WithTools(weatherTool),
|
||||
)
|
||||
result, err := agent.Stream(t.Context(), fantasy.AgentStreamCall{
|
||||
Prompt: "What's the weather in Florence,Italy?",
|
||||
ProviderOptions: pair.providerOptions,
|
||||
MaxOutputTokens: fantasy.Opt(int64(4000)),
|
||||
PrepareStep: pair.prepareStep,
|
||||
})
|
||||
require.NoError(t, err, "failed to generate")
|
||||
checkResult(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func testMultiTool(t *testing.T, pair builderPair) {
|
||||
// Apparently, Azure and Vertex+Anthropic do not support multi-tools calls at all?
|
||||
if strings.Contains(pair.name, "azure") {
|
||||
t.Skip("skipping multi-tool tests for azure as it does not support parallel multi-tool calls")
|
||||
}
|
||||
if strings.Contains(pair.name, "vertex") && strings.Contains(pair.name, "claude") {
|
||||
t.Skip("skipping multi-tool tests for vertex claude as it does not support parallel multi-tool calls")
|
||||
}
|
||||
if strings.Contains(pair.name, "bedrock") && strings.Contains(pair.name, "claude") {
|
||||
t.Skip("skipping multi-tool tests for bedrock claude as it does not support parallel multi-tool calls")
|
||||
}
|
||||
if strings.Contains(pair.name, "openai") && strings.Contains(pair.name, "o4-mini") {
|
||||
t.Skip("skipping multi-tool tests for openai o4-mini it for some reason is not doing parallel tool calls even if asked")
|
||||
}
|
||||
if strings.Contains(pair.name, "llama-cpp") && strings.Contains(pair.name, "gpt-oss") {
|
||||
t.Skip("skipping multi-tool tests for llama-cpp gpt-oss as it does not support parallel multi-tool calls")
|
||||
}
|
||||
|
||||
type CalculatorInput struct {
|
||||
A int `json:"a" description:"first number"`
|
||||
B int `json:"b" description:"second number"`
|
||||
}
|
||||
|
||||
addTool := fantasy.NewAgentTool(
|
||||
"add",
|
||||
"Add two numbers",
|
||||
func(ctx context.Context, input CalculatorInput, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
result := input.A + input.B
|
||||
return fantasy.NewTextResponse(strings.TrimSpace(strconv.Itoa(result))), nil
|
||||
},
|
||||
)
|
||||
multiplyTool := fantasy.NewAgentTool(
|
||||
"multiply",
|
||||
"Multiply two numbers",
|
||||
func(ctx context.Context, input CalculatorInput, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
result := input.A * input.B
|
||||
return fantasy.NewTextResponse(strings.TrimSpace(strconv.Itoa(result))), nil
|
||||
},
|
||||
)
|
||||
checkResult := func(t *testing.T, result *fantasy.AgentResult) {
|
||||
require.Len(t, result.Steps, 2)
|
||||
|
||||
var toolCalls []fantasy.ToolCallContent
|
||||
for _, content := range result.Steps[0].Content {
|
||||
if content.GetType() == fantasy.ContentTypeToolCall {
|
||||
toolCalls = append(toolCalls, content.(fantasy.ToolCallContent))
|
||||
}
|
||||
}
|
||||
for _, tc := range toolCalls {
|
||||
require.False(t, tc.Invalid)
|
||||
}
|
||||
require.Len(t, toolCalls, 2)
|
||||
|
||||
finalText := result.Response.Content.Text()
|
||||
require.Contains(t, finalText, "5", "expected response to contain '5', got: %q", finalText)
|
||||
require.Contains(t, finalText, "6", "expected response to contain '6', got: %q", finalText)
|
||||
}
|
||||
|
||||
t.Run("multi tool", func(t *testing.T) {
|
||||
r := vcr.NewRecorder(t)
|
||||
|
||||
languageModel, err := pair.builder(t, r)
|
||||
require.NoError(t, err, "failed to build language model")
|
||||
|
||||
agent := fantasy.NewAgent(
|
||||
languageModel,
|
||||
fantasy.WithSystemPrompt("You are a helpful assistant. CRITICAL: Always use both add and multiply at the same time ALWAYS."),
|
||||
fantasy.WithTools(addTool),
|
||||
fantasy.WithTools(multiplyTool),
|
||||
)
|
||||
result, err := agent.Generate(t.Context(), fantasy.AgentCall{
|
||||
Prompt: "Add and multiply the number 2 and 3",
|
||||
ProviderOptions: pair.providerOptions,
|
||||
MaxOutputTokens: fantasy.Opt(int64(4000)),
|
||||
PrepareStep: pair.prepareStep,
|
||||
})
|
||||
require.NoError(t, err, "failed to generate")
|
||||
checkResult(t, result)
|
||||
})
|
||||
t.Run("multi tool streaming", func(t *testing.T) {
|
||||
r := vcr.NewRecorder(t)
|
||||
|
||||
languageModel, err := pair.builder(t, r)
|
||||
require.NoError(t, err, "failed to build language model")
|
||||
|
||||
agent := fantasy.NewAgent(
|
||||
languageModel,
|
||||
fantasy.WithSystemPrompt("You are a helpful assistant. Always use both add and multiply at the same time."),
|
||||
fantasy.WithTools(addTool),
|
||||
fantasy.WithTools(multiplyTool),
|
||||
)
|
||||
result, err := agent.Stream(t.Context(), fantasy.AgentStreamCall{
|
||||
Prompt: "Add and multiply the number 2 and 3",
|
||||
ProviderOptions: pair.providerOptions,
|
||||
MaxOutputTokens: fantasy.Opt(int64(4000)),
|
||||
PrepareStep: pair.prepareStep,
|
||||
})
|
||||
require.NoError(t, err, "failed to generate")
|
||||
checkResult(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func testThinking(t *testing.T, pairs []builderPair, thinkChecks func(*testing.T, *fantasy.AgentResult)) {
|
||||
for _, pair := range pairs {
|
||||
t.Run(pair.name, func(t *testing.T) {
|
||||
t.Run("thinking", func(t *testing.T) {
|
||||
r := vcr.NewRecorder(t)
|
||||
|
||||
languageModel, err := pair.builder(t, r)
|
||||
require.NoError(t, err, "failed to build language model")
|
||||
|
||||
type WeatherInput struct {
|
||||
Location string `json:"location" description:"the city"`
|
||||
}
|
||||
|
||||
weatherTool := fantasy.NewAgentTool(
|
||||
"weather",
|
||||
"Get weather information for a location",
|
||||
func(ctx context.Context, input WeatherInput, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
return fantasy.NewTextResponse("40 C"), nil
|
||||
},
|
||||
)
|
||||
|
||||
agent := fantasy.NewAgent(
|
||||
languageModel,
|
||||
fantasy.WithSystemPrompt("You are a helpful assistant"),
|
||||
fantasy.WithTools(weatherTool),
|
||||
)
|
||||
result, err := agent.Generate(t.Context(), fantasy.AgentCall{
|
||||
Prompt: "What's the weather in Florence, Italy?",
|
||||
ProviderOptions: pair.providerOptions,
|
||||
PrepareStep: pair.prepareStep,
|
||||
})
|
||||
require.NoError(t, err, "failed to generate")
|
||||
|
||||
want1 := "Florence"
|
||||
want2 := "40"
|
||||
got := result.Response.Content.Text()
|
||||
require.True(t, strings.Contains(got, want1) && strings.Contains(got, want2), "unexpected response: got %q, want %q %q", got, want1, want2)
|
||||
|
||||
thinkChecks(t, result)
|
||||
})
|
||||
t.Run("thinking-streaming", func(t *testing.T) {
|
||||
r := vcr.NewRecorder(t)
|
||||
|
||||
languageModel, err := pair.builder(t, r)
|
||||
require.NoError(t, err, "failed to build language model")
|
||||
|
||||
type WeatherInput struct {
|
||||
Location string `json:"location" description:"the city"`
|
||||
}
|
||||
|
||||
weatherTool := fantasy.NewAgentTool(
|
||||
"weather",
|
||||
"Get weather information for a location",
|
||||
func(ctx context.Context, input WeatherInput, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
return fantasy.NewTextResponse("40 C"), nil
|
||||
},
|
||||
)
|
||||
|
||||
agent := fantasy.NewAgent(
|
||||
languageModel,
|
||||
fantasy.WithSystemPrompt("You are a helpful assistant"),
|
||||
fantasy.WithTools(weatherTool),
|
||||
)
|
||||
result, err := agent.Stream(t.Context(), fantasy.AgentStreamCall{
|
||||
Prompt: "What's the weather in Florence, Italy?",
|
||||
ProviderOptions: pair.providerOptions,
|
||||
PrepareStep: pair.prepareStep,
|
||||
})
|
||||
require.NoError(t, err, "failed to generate")
|
||||
|
||||
want1 := "Florence"
|
||||
want2 := "40"
|
||||
got := result.Response.Content.Text()
|
||||
require.True(t, strings.Contains(got, want1) && strings.Contains(got, want2), "unexpected response: got %q, want %q %q", got, want1, want2)
|
||||
|
||||
thinkChecks(t, result)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func containsAny(s string, subs ...string) bool {
|
||||
for _, sub := range subs {
|
||||
if strings.Contains(s, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
125
internal/fantasy/providertests/google_test.go
Normal file
125
internal/fantasy/providertests/google_test.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package providertests
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"charm.land/fantasy/providers/google"
|
||||
"charm.land/x/vcr"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var geminiTestModels = []testModel{
|
||||
{"gemini-3-pro-preview", "gemini-3-pro-preview", true},
|
||||
{"gemini-2.5-flash", "gemini-2.5-flash", true},
|
||||
{"gemini-2.5-pro", "gemini-2.5-pro", true},
|
||||
}
|
||||
|
||||
var vertexTestModels = []testModel{
|
||||
{"vertex-gemini-2-5-flash", "gemini-2.5-flash", true},
|
||||
{"vertex-gemini-2-5-pro", "gemini-2.5-pro", true},
|
||||
{"vertex-claude-3-7-sonnet", "claude-3-7-sonnet@20250219", true},
|
||||
}
|
||||
|
||||
func TestGoogleCommon(t *testing.T) {
|
||||
var pairs []builderPair
|
||||
for _, m := range geminiTestModels {
|
||||
pairs = append(pairs, builderPair{m.name, geminiBuilder(m.model), nil, nil})
|
||||
}
|
||||
for _, m := range vertexTestModels {
|
||||
pairs = append(pairs, builderPair{m.name, vertexBuilder(m.model), nil, nil})
|
||||
}
|
||||
testCommon(t, pairs)
|
||||
}
|
||||
|
||||
func TestGoogleThinking(t *testing.T) {
|
||||
opts := fantasy.ProviderOptions{
|
||||
google.Name: &google.ProviderOptions{
|
||||
ThinkingConfig: &google.ThinkingConfig{
|
||||
ThinkingBudget: fantasy.Opt(int64(100)),
|
||||
IncludeThoughts: fantasy.Opt(true),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var pairs []builderPair
|
||||
for _, m := range geminiTestModels {
|
||||
if !m.reasoning {
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs, builderPair{m.name, geminiBuilder(m.model), opts, nil})
|
||||
}
|
||||
testThinking(t, pairs, testGoogleThinking)
|
||||
}
|
||||
|
||||
func TestGoogleObjectGeneration(t *testing.T) {
|
||||
var pairs []builderPair
|
||||
for _, m := range geminiTestModels {
|
||||
pairs = append(pairs, builderPair{m.name, geminiBuilder(m.model), nil, nil})
|
||||
}
|
||||
testObjectGeneration(t, pairs)
|
||||
}
|
||||
|
||||
func TestGoogleVertexObjectGeneration(t *testing.T) {
|
||||
var pairs []builderPair
|
||||
for _, m := range vertexTestModels {
|
||||
pairs = append(pairs, builderPair{m.name, vertexBuilder(m.model), nil, nil})
|
||||
}
|
||||
testObjectGeneration(t, pairs)
|
||||
}
|
||||
|
||||
func testGoogleThinking(t *testing.T, result *fantasy.AgentResult) {
|
||||
reasoningContentCount := 0
|
||||
// Test if we got the signature
|
||||
for _, step := range result.Steps {
|
||||
for _, msg := range step.Messages {
|
||||
for _, content := range msg.Content {
|
||||
if content.GetType() == fantasy.ContentTypeReasoning {
|
||||
reasoningContentCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
require.Greater(t, reasoningContentCount, 0)
|
||||
}
|
||||
|
||||
func generateIDMock() google.ToolCallIDFunc {
|
||||
id := 0
|
||||
return func() string {
|
||||
id++
|
||||
return fmt.Sprintf("%d", id)
|
||||
}
|
||||
}
|
||||
|
||||
func geminiBuilder(model string) builderFunc {
|
||||
return func(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
|
||||
provider, err := google.New(
|
||||
google.WithGeminiAPIKey(cmp.Or(os.Getenv("FANTASY_GEMINI_API_KEY"), "(missing)")),
|
||||
google.WithHTTPClient(&http.Client{Transport: r}),
|
||||
google.WithToolCallIDFunc(generateIDMock()),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.LanguageModel(t.Context(), model)
|
||||
}
|
||||
}
|
||||
|
||||
func vertexBuilder(model string) builderFunc {
|
||||
return func(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
|
||||
provider, err := google.New(
|
||||
google.WithVertex(os.Getenv("FANTASY_VERTEX_PROJECT"), os.Getenv("FANTASY_VERTEX_LOCATION")),
|
||||
google.WithHTTPClient(&http.Client{Transport: r}),
|
||||
google.WithSkipAuth(!r.IsRecording()),
|
||||
google.WithToolCallIDFunc(generateIDMock()),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.LanguageModel(t.Context(), model)
|
||||
}
|
||||
}
|
||||
146
internal/fantasy/providertests/image_upload_test.go
Normal file
146
internal/fantasy/providertests/image_upload_test.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package providertests
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"charm.land/fantasy/providers/anthropic"
|
||||
"charm.land/fantasy/providers/google"
|
||||
"charm.land/fantasy/providers/openai"
|
||||
"charm.land/x/vcr"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func anthropicImageBuilder(model string) builderFunc {
|
||||
return func(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
|
||||
provider, err := anthropic.New(
|
||||
anthropic.WithAPIKey(cmp.Or(os.Getenv("FANTASY_ANTHROPIC_API_KEY"), "(missing)")),
|
||||
anthropic.WithHTTPClient(&http.Client{Transport: r}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.LanguageModel(t.Context(), model)
|
||||
}
|
||||
}
|
||||
|
||||
func openAIImageBuilder(model string) builderFunc {
|
||||
return func(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
|
||||
provider, err := openai.New(
|
||||
openai.WithAPIKey(cmp.Or(os.Getenv("FANTASY_OPENAI_API_KEY"), "(missing)")),
|
||||
openai.WithHTTPClient(&http.Client{Transport: r}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.LanguageModel(t.Context(), model)
|
||||
}
|
||||
}
|
||||
|
||||
func geminiImageBuilder(model string) builderFunc {
|
||||
return func(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
|
||||
provider, err := google.New(
|
||||
google.WithGeminiAPIKey(cmp.Or(os.Getenv("FANTASY_GEMINI_API_KEY"), "(missing)")),
|
||||
google.WithHTTPClient(&http.Client{Transport: r}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.LanguageModel(t.Context(), model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageUploadAgent(t *testing.T) {
|
||||
pairs := []builderPair{
|
||||
{
|
||||
name: "anthropic-claude-sonnet-4",
|
||||
builder: anthropicImageBuilder("claude-sonnet-4-20250514"),
|
||||
},
|
||||
{
|
||||
name: "openai-gpt-5",
|
||||
builder: openAIImageBuilder("gpt-5"),
|
||||
},
|
||||
{
|
||||
name: "gemini-2.5-pro",
|
||||
builder: geminiImageBuilder("gemini-2.5-pro"),
|
||||
},
|
||||
}
|
||||
|
||||
img, err := os.ReadFile("testdata/wish.png")
|
||||
require.NoError(t, err)
|
||||
|
||||
file := fantasy.FilePart{Filename: "wish.png", Data: img, MediaType: "image/png"}
|
||||
|
||||
for _, pair := range pairs {
|
||||
t.Run(pair.name, func(t *testing.T) {
|
||||
r := vcr.NewRecorder(t)
|
||||
|
||||
lm, err := pair.builder(t, r)
|
||||
require.NoError(t, err)
|
||||
|
||||
agent := fantasy.NewAgent(
|
||||
lm,
|
||||
fantasy.WithSystemPrompt("You are a helpful assistant"),
|
||||
)
|
||||
|
||||
result, err := agent.Generate(t.Context(), fantasy.AgentCall{
|
||||
Prompt: "Describe the image briefly in English.",
|
||||
Files: []fantasy.FilePart{file},
|
||||
ProviderOptions: pair.providerOptions,
|
||||
MaxOutputTokens: fantasy.Opt(int64(4000)),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
got := result.Response.Content.Text()
|
||||
require.NotEmpty(t, got, "expected non-empty description for %s", pair.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageUploadAgentStreaming(t *testing.T) {
|
||||
pairs := []builderPair{
|
||||
{
|
||||
name: "anthropic-claude-sonnet-4",
|
||||
builder: anthropicImageBuilder("claude-sonnet-4-20250514"),
|
||||
},
|
||||
{
|
||||
name: "openai-gpt-5",
|
||||
builder: openAIImageBuilder("gpt-5"),
|
||||
},
|
||||
{
|
||||
name: "gemini-2.5-pro",
|
||||
builder: geminiImageBuilder("gemini-2.5-pro"),
|
||||
},
|
||||
}
|
||||
|
||||
img, err := os.ReadFile("testdata/wish.png")
|
||||
require.NoError(t, err)
|
||||
|
||||
file := fantasy.FilePart{Filename: "wish.png", Data: img, MediaType: "image/png"}
|
||||
|
||||
for _, pair := range pairs {
|
||||
t.Run(pair.name+"-stream", func(t *testing.T) {
|
||||
r := vcr.NewRecorder(t)
|
||||
|
||||
lm, err := pair.builder(t, r)
|
||||
require.NoError(t, err)
|
||||
|
||||
agent := fantasy.NewAgent(
|
||||
lm,
|
||||
fantasy.WithSystemPrompt("You are a helpful assistant"),
|
||||
)
|
||||
|
||||
result, err := agent.Stream(t.Context(), fantasy.AgentStreamCall{
|
||||
Prompt: "Describe the image briefly in English.",
|
||||
Files: []fantasy.FilePart{file},
|
||||
ProviderOptions: pair.providerOptions,
|
||||
MaxOutputTokens: fantasy.Opt(int64(4000)),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
got := result.Response.Content.Text()
|
||||
require.NotEmpty(t, got, "expected non-empty description for %s", pair.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
422
internal/fantasy/providertests/object_test.go
Normal file
422
internal/fantasy/providertests/object_test.go
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
package providertests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"charm.land/x/vcr"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Object generation tests for providers.
|
||||
//
|
||||
// These test functions can be used to test structured object generation
|
||||
// (GenerateObject and StreamObject) for any provider implementation.
|
||||
//
|
||||
// Usage example:
|
||||
//
|
||||
// func TestMyProviderObjectGeneration(t *testing.T) {
|
||||
// var pairs []builderPair
|
||||
// for _, m := range myTestModels {
|
||||
// pairs = append(pairs, builderPair{m.name, myBuilder(m.model), nil, nil})
|
||||
// }
|
||||
// testObjectGeneration(t, pairs)
|
||||
// }
|
||||
//
|
||||
// The tests cover:
|
||||
// - Simple object generation (flat schema with basic types)
|
||||
// - Complex object generation (nested objects and arrays)
|
||||
// - Streaming object generation (progressive updates)
|
||||
// - Object generation with custom repair functions
|
||||
|
||||
// testObjectGeneration tests structured object generation for a provider.
|
||||
// It includes both non-streaming (GenerateObject) and streaming (StreamObject) tests.
|
||||
func testObjectGeneration(t *testing.T, pairs []builderPair) {
|
||||
for _, pair := range pairs {
|
||||
t.Run(pair.name, func(t *testing.T) {
|
||||
testSimpleObject(t, pair)
|
||||
testComplexObject(t, pair)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testSimpleObject(t *testing.T, pair builderPair) {
|
||||
// Define a simple schema for a person object
|
||||
schema := fantasy.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*fantasy.Schema{
|
||||
"name": {
|
||||
Type: "string",
|
||||
Description: "The person's name",
|
||||
},
|
||||
"age": {
|
||||
Type: "integer",
|
||||
Description: "The person's age",
|
||||
},
|
||||
"city": {
|
||||
Type: "string",
|
||||
Description: "The city where the person lives",
|
||||
},
|
||||
},
|
||||
Required: []string{"name", "age", "city"},
|
||||
}
|
||||
|
||||
checkResult := func(t *testing.T, obj any, rawText string, usage fantasy.Usage) {
|
||||
require.NotNil(t, obj, "object should not be nil")
|
||||
require.NotEmpty(t, rawText, "raw text should not be empty")
|
||||
require.Greater(t, usage.TotalTokens, int64(0), "usage should be tracked")
|
||||
|
||||
// Validate structure
|
||||
objMap, ok := obj.(map[string]any)
|
||||
require.True(t, ok, "object should be a map")
|
||||
require.Contains(t, objMap, "name")
|
||||
require.Contains(t, objMap, "age")
|
||||
require.Contains(t, objMap, "city")
|
||||
|
||||
// Validate types
|
||||
name, ok := objMap["name"].(string)
|
||||
require.True(t, ok, "name should be a string")
|
||||
require.NotEmpty(t, name, "name should not be empty")
|
||||
|
||||
// Age could be float64 from JSON unmarshaling
|
||||
age, ok := objMap["age"].(float64)
|
||||
require.True(t, ok, "age should be a number")
|
||||
require.Greater(t, age, 0.0, "age should be greater than 0")
|
||||
|
||||
city, ok := objMap["city"].(string)
|
||||
require.True(t, ok, "city should be a string")
|
||||
require.NotEmpty(t, city, "city should not be empty")
|
||||
}
|
||||
|
||||
t.Run("simple object", func(t *testing.T) {
|
||||
r := vcr.NewRecorder(t)
|
||||
|
||||
languageModel, err := pair.builder(t, r)
|
||||
require.NoError(t, err, "failed to build language model")
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
fantasy.NewUserMessage("Generate information about a person named Alice who is 30 years old and lives in Paris."),
|
||||
}
|
||||
|
||||
response, err := languageModel.GenerateObject(t.Context(), fantasy.ObjectCall{
|
||||
Prompt: prompt,
|
||||
Schema: schema,
|
||||
SchemaName: "Person",
|
||||
SchemaDescription: "A person with name, age, and city",
|
||||
MaxOutputTokens: fantasy.Opt(int64(4000)),
|
||||
ProviderOptions: pair.providerOptions,
|
||||
})
|
||||
require.NoError(t, err, "failed to generate object")
|
||||
require.NotNil(t, response, "response should not be nil")
|
||||
checkResult(t, response.Object, response.RawText, response.Usage)
|
||||
})
|
||||
|
||||
t.Run("simple object streaming", func(t *testing.T) {
|
||||
r := vcr.NewRecorder(t)
|
||||
|
||||
languageModel, err := pair.builder(t, r)
|
||||
require.NoError(t, err, "failed to build language model")
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
fantasy.NewUserMessage("Generate information about a person named Alice who is 30 years old and lives in Paris."),
|
||||
}
|
||||
|
||||
stream, err := languageModel.StreamObject(t.Context(), fantasy.ObjectCall{
|
||||
Prompt: prompt,
|
||||
Schema: schema,
|
||||
SchemaName: "Person",
|
||||
SchemaDescription: "A person with name, age, and city",
|
||||
MaxOutputTokens: fantasy.Opt(int64(4000)),
|
||||
ProviderOptions: pair.providerOptions,
|
||||
})
|
||||
require.NoError(t, err, "failed to create object stream")
|
||||
require.NotNil(t, stream, "stream should not be nil")
|
||||
|
||||
var lastObject any
|
||||
var rawText string
|
||||
var usage fantasy.Usage
|
||||
var finishReason fantasy.FinishReason
|
||||
objectCount := 0
|
||||
|
||||
for part := range stream {
|
||||
switch part.Type {
|
||||
case fantasy.ObjectStreamPartTypeObject:
|
||||
lastObject = part.Object
|
||||
objectCount++
|
||||
case fantasy.ObjectStreamPartTypeTextDelta:
|
||||
rawText += part.Delta
|
||||
case fantasy.ObjectStreamPartTypeFinish:
|
||||
usage = part.Usage
|
||||
finishReason = part.FinishReason
|
||||
case fantasy.ObjectStreamPartTypeError:
|
||||
t.Fatalf("stream error: %v", part.Error)
|
||||
}
|
||||
}
|
||||
|
||||
require.NotNil(t, lastObject, "should have received at least one object")
|
||||
require.Greater(t, objectCount, 0, "should have received object updates")
|
||||
require.NotEqual(t, fantasy.FinishReasonUnknown, finishReason, "should have a finish reason")
|
||||
|
||||
// Validate object structure without requiring rawText (may be empty in tool-based mode)
|
||||
require.NotNil(t, lastObject, "object should not be nil")
|
||||
require.Greater(t, usage.TotalTokens, int64(0), "usage should be tracked")
|
||||
|
||||
// Validate structure
|
||||
objMap, ok := lastObject.(map[string]any)
|
||||
require.True(t, ok, "object should be a map")
|
||||
require.Contains(t, objMap, "name")
|
||||
require.Contains(t, objMap, "age")
|
||||
require.Contains(t, objMap, "city")
|
||||
|
||||
// Validate types
|
||||
name, ok := objMap["name"].(string)
|
||||
require.True(t, ok, "name should be a string")
|
||||
require.NotEmpty(t, name, "name should not be empty")
|
||||
|
||||
// Age could be float64 from JSON unmarshaling
|
||||
age, ok := objMap["age"].(float64)
|
||||
require.True(t, ok, "age should be a number")
|
||||
require.Greater(t, age, 0.0, "age should be greater than 0")
|
||||
|
||||
city, ok := objMap["city"].(string)
|
||||
require.True(t, ok, "city should be a string")
|
||||
require.NotEmpty(t, city, "city should not be empty")
|
||||
})
|
||||
}
|
||||
|
||||
func testComplexObject(t *testing.T, pair builderPair) {
|
||||
// Define a more complex schema with nested objects and arrays
|
||||
schema := fantasy.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*fantasy.Schema{
|
||||
"title": {
|
||||
Type: "string",
|
||||
Description: "The book title",
|
||||
},
|
||||
"author": {
|
||||
Type: "object",
|
||||
Properties: map[string]*fantasy.Schema{
|
||||
"name": {
|
||||
Type: "string",
|
||||
Description: "Author's name",
|
||||
},
|
||||
"nationality": {
|
||||
Type: "string",
|
||||
Description: "Author's nationality",
|
||||
},
|
||||
},
|
||||
Required: []string{"name", "nationality"},
|
||||
},
|
||||
"genres": {
|
||||
Type: "array",
|
||||
Items: &fantasy.Schema{
|
||||
Type: "string",
|
||||
},
|
||||
Description: "List of genres",
|
||||
},
|
||||
"published_year": {
|
||||
Type: "integer",
|
||||
Description: "Year the book was published",
|
||||
},
|
||||
},
|
||||
Required: []string{"title", "author", "genres", "published_year"},
|
||||
}
|
||||
|
||||
checkResult := func(t *testing.T, obj any, rawText string, usage fantasy.Usage) {
|
||||
require.NotNil(t, obj, "object should not be nil")
|
||||
require.NotEmpty(t, rawText, "raw text should not be empty")
|
||||
require.Greater(t, usage.TotalTokens, int64(0), "usage should be tracked")
|
||||
|
||||
// Validate structure
|
||||
objMap, ok := obj.(map[string]any)
|
||||
require.True(t, ok, "object should be a map")
|
||||
require.Contains(t, objMap, "title")
|
||||
require.Contains(t, objMap, "author")
|
||||
require.Contains(t, objMap, "genres")
|
||||
require.Contains(t, objMap, "published_year")
|
||||
|
||||
// Validate title
|
||||
title, ok := objMap["title"].(string)
|
||||
require.True(t, ok, "title should be a string")
|
||||
require.True(t, strings.Contains(strings.ToLower(title), "rings"), "title should contain 'rings'")
|
||||
|
||||
// Validate nested author object
|
||||
author, ok := objMap["author"].(map[string]any)
|
||||
require.True(t, ok, "author should be an object")
|
||||
require.Contains(t, author, "name")
|
||||
require.Contains(t, author, "nationality")
|
||||
|
||||
// Validate genres array
|
||||
genres, ok := objMap["genres"].([]any)
|
||||
require.True(t, ok, "genres should be an array")
|
||||
require.Greater(t, len(genres), 0, "genres should have at least one item")
|
||||
for _, genre := range genres {
|
||||
_, ok := genre.(string)
|
||||
require.True(t, ok, "each genre should be a string")
|
||||
}
|
||||
|
||||
// Validate published_year
|
||||
year, ok := objMap["published_year"].(float64)
|
||||
require.True(t, ok, "published_year should be a number")
|
||||
require.Greater(t, year, 1900.0, "published_year should be after 1900")
|
||||
}
|
||||
|
||||
t.Run("complex object", func(t *testing.T) {
|
||||
r := vcr.NewRecorder(t)
|
||||
|
||||
languageModel, err := pair.builder(t, r)
|
||||
require.NoError(t, err, "failed to build language model")
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
fantasy.NewUserMessage("Generate information about 'The Lord of the Rings' book by J.R.R. Tolkien, including genres like fantasy and adventure, and its publication year (1954)."),
|
||||
}
|
||||
|
||||
response, err := languageModel.GenerateObject(t.Context(), fantasy.ObjectCall{
|
||||
Prompt: prompt,
|
||||
Schema: schema,
|
||||
SchemaName: "Book",
|
||||
SchemaDescription: "A book with title, author, genres, and publication year",
|
||||
MaxOutputTokens: fantasy.Opt(int64(4000)),
|
||||
ProviderOptions: pair.providerOptions,
|
||||
})
|
||||
require.NoError(t, err, "failed to generate object")
|
||||
require.NotNil(t, response, "response should not be nil")
|
||||
checkResult(t, response.Object, response.RawText, response.Usage)
|
||||
})
|
||||
|
||||
t.Run("complex object streaming", func(t *testing.T) {
|
||||
r := vcr.NewRecorder(t)
|
||||
|
||||
languageModel, err := pair.builder(t, r)
|
||||
require.NoError(t, err, "failed to build language model")
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
fantasy.NewUserMessage("Generate information about 'The Lord of the Rings' book by J.R.R. Tolkien, including genres like fantasy and adventure, and its publication year (1954)."),
|
||||
}
|
||||
|
||||
stream, err := languageModel.StreamObject(t.Context(), fantasy.ObjectCall{
|
||||
Prompt: prompt,
|
||||
Schema: schema,
|
||||
SchemaName: "Book",
|
||||
SchemaDescription: "A book with title, author, genres, and publication year",
|
||||
MaxOutputTokens: fantasy.Opt(int64(4000)),
|
||||
ProviderOptions: pair.providerOptions,
|
||||
})
|
||||
require.NoError(t, err, "failed to create object stream")
|
||||
require.NotNil(t, stream, "stream should not be nil")
|
||||
|
||||
var lastObject any
|
||||
var rawText string
|
||||
var usage fantasy.Usage
|
||||
var finishReason fantasy.FinishReason
|
||||
objectCount := 0
|
||||
|
||||
for part := range stream {
|
||||
switch part.Type {
|
||||
case fantasy.ObjectStreamPartTypeObject:
|
||||
lastObject = part.Object
|
||||
objectCount++
|
||||
case fantasy.ObjectStreamPartTypeTextDelta:
|
||||
rawText += part.Delta
|
||||
case fantasy.ObjectStreamPartTypeFinish:
|
||||
usage = part.Usage
|
||||
finishReason = part.FinishReason
|
||||
case fantasy.ObjectStreamPartTypeError:
|
||||
t.Fatalf("stream error: %v", part.Error)
|
||||
}
|
||||
}
|
||||
|
||||
require.NotNil(t, lastObject, "should have received at least one object")
|
||||
require.Greater(t, objectCount, 0, "should have received object updates")
|
||||
require.NotEqual(t, fantasy.FinishReasonUnknown, finishReason, "should have a finish reason")
|
||||
|
||||
// Validate object structure without requiring rawText (may be empty in tool-based mode)
|
||||
require.NotNil(t, lastObject, "object should not be nil")
|
||||
require.Greater(t, usage.TotalTokens, int64(0), "usage should be tracked")
|
||||
|
||||
// Validate structure
|
||||
objMap, ok := lastObject.(map[string]any)
|
||||
require.True(t, ok, "object should be a map")
|
||||
require.Contains(t, objMap, "title")
|
||||
require.Contains(t, objMap, "author")
|
||||
require.Contains(t, objMap, "genres")
|
||||
require.Contains(t, objMap, "published_year")
|
||||
|
||||
// Validate title
|
||||
title, ok := objMap["title"].(string)
|
||||
require.True(t, ok, "title should be a string")
|
||||
require.True(t, strings.Contains(strings.ToLower(title), "rings"), "title should contain 'rings'")
|
||||
|
||||
// Validate nested author object
|
||||
author, ok := objMap["author"].(map[string]any)
|
||||
require.True(t, ok, "author should be an object")
|
||||
require.Contains(t, author, "name")
|
||||
require.Contains(t, author, "nationality")
|
||||
|
||||
// Validate genres array
|
||||
genres, ok := objMap["genres"].([]any)
|
||||
require.True(t, ok, "genres should be an array")
|
||||
require.Greater(t, len(genres), 0, "genres should have at least one item")
|
||||
for _, genre := range genres {
|
||||
_, ok := genre.(string)
|
||||
require.True(t, ok, "each genre should be a string")
|
||||
}
|
||||
|
||||
// Validate published_year
|
||||
year, ok := objMap["published_year"].(float64)
|
||||
require.True(t, ok, "published_year should be a number")
|
||||
require.Greater(t, year, 1900.0, "published_year should be after 1900")
|
||||
})
|
||||
}
|
||||
|
||||
// testObjectWithRepair tests object generation with custom repair functionality.
|
||||
func testObjectWithRepair(t *testing.T, pairs []builderPair) {
|
||||
for _, pair := range pairs {
|
||||
t.Run(pair.name, func(t *testing.T) {
|
||||
t.Run("object with repair", func(t *testing.T) {
|
||||
r := vcr.NewRecorder(t)
|
||||
|
||||
languageModel, err := pair.builder(t, r)
|
||||
require.NoError(t, err, "failed to build language model")
|
||||
|
||||
minVal := 1.0
|
||||
schema := fantasy.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*fantasy.Schema{
|
||||
"count": {
|
||||
Type: "integer",
|
||||
Description: "A count that must be positive",
|
||||
Minimum: &minVal,
|
||||
},
|
||||
},
|
||||
Required: []string{"count"},
|
||||
}
|
||||
|
||||
prompt := fantasy.Prompt{
|
||||
fantasy.NewUserMessage("Return a count of 5"),
|
||||
}
|
||||
|
||||
repairFunc := func(ctx context.Context, text string, err error) (string, error) {
|
||||
// Simple repair: if the JSON is malformed, try to fix it
|
||||
// This is a placeholder - real repair would be more sophisticated
|
||||
return text, nil
|
||||
}
|
||||
|
||||
response, err := languageModel.GenerateObject(t.Context(), fantasy.ObjectCall{
|
||||
Prompt: prompt,
|
||||
Schema: schema,
|
||||
SchemaName: "Count",
|
||||
SchemaDescription: "A simple count object",
|
||||
MaxOutputTokens: fantasy.Opt(int64(4000)),
|
||||
RepairText: repairFunc,
|
||||
ProviderOptions: pair.providerOptions,
|
||||
})
|
||||
require.NoError(t, err, "failed to generate object")
|
||||
require.NotNil(t, response, "response should not be nil")
|
||||
require.NotNil(t, response.Object, "object should not be nil")
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
96
internal/fantasy/providertests/openai_responses_test.go
Normal file
96
internal/fantasy/providertests/openai_responses_test.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package providertests
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"charm.land/fantasy/providers/openai"
|
||||
"charm.land/x/vcr"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpenAIResponsesCommon(t *testing.T) {
|
||||
var pairs []builderPair
|
||||
for _, m := range openaiTestModels {
|
||||
pairs = append(pairs, builderPair{m.name, openAIReasoningBuilder(m.model), nil, nil})
|
||||
}
|
||||
testCommon(t, pairs)
|
||||
}
|
||||
|
||||
func openAIReasoningBuilder(model string) builderFunc {
|
||||
return func(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
|
||||
provider, err := openai.New(
|
||||
openai.WithAPIKey(os.Getenv("FANTASY_OPENAI_API_KEY")),
|
||||
openai.WithHTTPClient(&http.Client{Transport: r}),
|
||||
openai.WithUseResponsesAPI(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.LanguageModel(t.Context(), model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesWithSummaryThinking(t *testing.T) {
|
||||
opts := fantasy.ProviderOptions{
|
||||
openai.Name: &openai.ResponsesProviderOptions{
|
||||
Include: []openai.IncludeType{
|
||||
openai.IncludeReasoningEncryptedContent,
|
||||
},
|
||||
ReasoningEffort: openai.ReasoningEffortOption(openai.ReasoningEffortHigh),
|
||||
ReasoningSummary: fantasy.Opt("auto"),
|
||||
},
|
||||
}
|
||||
var pairs []builderPair
|
||||
for _, m := range openaiTestModels {
|
||||
if !m.reasoning {
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs, builderPair{m.name, openAIReasoningBuilder(m.model), opts, nil})
|
||||
}
|
||||
testThinking(t, pairs, testOpenAIResponsesThinkingWithSummaryThinking)
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesObjectGeneration(t *testing.T) {
|
||||
var pairs []builderPair
|
||||
for _, m := range openaiTestModels {
|
||||
pairs = append(pairs, builderPair{m.name, openAIReasoningBuilder(m.model), nil, nil})
|
||||
}
|
||||
testObjectGeneration(t, pairs)
|
||||
}
|
||||
|
||||
func testOpenAIResponsesThinkingWithSummaryThinking(t *testing.T, result *fantasy.AgentResult) {
|
||||
reasoningContentCount := 0
|
||||
encryptedData := 0
|
||||
// Test if we got the signature
|
||||
for _, step := range result.Steps {
|
||||
for _, msg := range step.Messages {
|
||||
for _, content := range msg.Content {
|
||||
if content.GetType() == fantasy.ContentTypeReasoning {
|
||||
reasoningContentCount += 1
|
||||
reasoningContent, ok := fantasy.AsContentType[fantasy.ReasoningPart](content)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if len(reasoningContent.ProviderOptions) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
openaiReasoningMetadata, ok := reasoningContent.ProviderOptions[openai.Name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if typed, ok := openaiReasoningMetadata.(*openai.ResponsesReasoningMetadata); ok {
|
||||
require.NotEmpty(t, typed.EncryptedContent)
|
||||
encryptedData += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
require.Greater(t, reasoningContentCount, 0)
|
||||
require.Greater(t, encryptedData, 0)
|
||||
require.Equal(t, reasoningContentCount, encryptedData)
|
||||
}
|
||||
47
internal/fantasy/providertests/openai_test.go
Normal file
47
internal/fantasy/providertests/openai_test.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package providertests
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"charm.land/fantasy/providers/openai"
|
||||
"charm.land/x/vcr"
|
||||
)
|
||||
|
||||
var openaiTestModels = []testModel{
|
||||
{"openai-gpt-4o", "gpt-4o", false},
|
||||
{"openai-gpt-4o-mini", "gpt-4o-mini", false},
|
||||
{"openai-gpt-5", "gpt-5", true},
|
||||
{"openai-o4-mini", "o4-mini", true},
|
||||
}
|
||||
|
||||
func TestOpenAICommon(t *testing.T) {
|
||||
var pairs []builderPair
|
||||
for _, m := range openaiTestModels {
|
||||
pairs = append(pairs, builderPair{m.name, openAIBuilder(m.model), nil, nil})
|
||||
}
|
||||
testCommon(t, pairs)
|
||||
}
|
||||
|
||||
func TestOpenAIObjectGeneration(t *testing.T) {
|
||||
var pairs []builderPair
|
||||
for _, m := range openaiTestModels {
|
||||
pairs = append(pairs, builderPair{m.name, openAIBuilder(m.model), nil, nil})
|
||||
}
|
||||
testObjectGeneration(t, pairs)
|
||||
}
|
||||
|
||||
func openAIBuilder(model string) builderFunc {
|
||||
return func(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
|
||||
provider, err := openai.New(
|
||||
openai.WithAPIKey(os.Getenv("FANTASY_OPENAI_API_KEY")),
|
||||
openai.WithHTTPClient(&http.Client{Transport: r}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.LanguageModel(t.Context(), model)
|
||||
}
|
||||
}
|
||||
142
internal/fantasy/providertests/openaicompat_test.go
Normal file
142
internal/fantasy/providertests/openaicompat_test.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
package providertests
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"charm.land/fantasy/providers/openai"
|
||||
"charm.land/fantasy/providers/openaicompat"
|
||||
"charm.land/x/vcr"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpenAICompatibleCommon(t *testing.T) {
|
||||
testCommon(t, []builderPair{
|
||||
{"xai-grok-4-fast", builderXAIGrok4Fast, nil, nil},
|
||||
{"xai-grok-code-fast", builderXAIGrokCodeFast, nil, nil},
|
||||
{"groq-kimi-k2", builderGroq, nil, nil},
|
||||
{"zai-glm-4.5", builderZAIGLM45, nil, nil},
|
||||
{"huggingface-qwen3-coder", builderHuggingFace, nil, nil},
|
||||
{"llama-cpp-gpt-oss", builderLlamaCppGptOss, nil, nil},
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenAICompatObjectGeneration(t *testing.T) {
|
||||
testObjectGeneration(t, []builderPair{
|
||||
{"xai-grok-4-fast", builderXAIGrok4Fast, nil, nil},
|
||||
{"xai-grok-code-fast", builderXAIGrokCodeFast, nil, nil},
|
||||
{"zai-glm-4.5", builderZAIGLM45, nil, nil},
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleThinking(t *testing.T) {
|
||||
opts := fantasy.ProviderOptions{
|
||||
openaicompat.Name: &openaicompat.ProviderOptions{
|
||||
ReasoningEffort: openai.ReasoningEffortOption(openai.ReasoningEffortHigh),
|
||||
},
|
||||
}
|
||||
testThinking(t, []builderPair{
|
||||
{"xai-grok-3-mini", builderXAIGrok3Mini, opts, nil},
|
||||
{"zai-glm-4.5", builderZAIGLM45, opts, nil},
|
||||
{"llama-cpp-gpt-oss", builderLlamaCppGptOss, opts, nil},
|
||||
}, testOpenAICompatThinking)
|
||||
}
|
||||
|
||||
func testOpenAICompatThinking(t *testing.T, result *fantasy.AgentResult) {
|
||||
reasoningContentCount := 0
|
||||
for _, step := range result.Steps {
|
||||
for _, msg := range step.Messages {
|
||||
for _, content := range msg.Content {
|
||||
if content.GetType() == fantasy.ContentTypeReasoning {
|
||||
reasoningContentCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
require.Greater(t, reasoningContentCount, 0, "expected reasoning content, got none")
|
||||
}
|
||||
|
||||
func builderXAIGrokCodeFast(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
|
||||
provider, err := openaicompat.New(
|
||||
openaicompat.WithBaseURL("https://api.x.ai/v1"),
|
||||
openaicompat.WithAPIKey(os.Getenv("FANTASY_XAI_API_KEY")),
|
||||
openaicompat.WithHTTPClient(&http.Client{Transport: r}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.LanguageModel(t.Context(), "grok-code-fast-1")
|
||||
}
|
||||
|
||||
func builderXAIGrok4Fast(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
|
||||
provider, err := openaicompat.New(
|
||||
openaicompat.WithBaseURL("https://api.x.ai/v1"),
|
||||
openaicompat.WithAPIKey(os.Getenv("FANTASY_XAI_API_KEY")),
|
||||
openaicompat.WithHTTPClient(&http.Client{Transport: r}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.LanguageModel(t.Context(), "grok-4-fast")
|
||||
}
|
||||
|
||||
func builderXAIGrok3Mini(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
|
||||
provider, err := openaicompat.New(
|
||||
openaicompat.WithBaseURL("https://api.x.ai/v1"),
|
||||
openaicompat.WithAPIKey(os.Getenv("FANTASY_XAI_API_KEY")),
|
||||
openaicompat.WithHTTPClient(&http.Client{Transport: r}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.LanguageModel(t.Context(), "grok-3-mini")
|
||||
}
|
||||
|
||||
func builderZAIGLM45(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
|
||||
provider, err := openaicompat.New(
|
||||
openaicompat.WithBaseURL("https://api.z.ai/api/coding/paas/v4"),
|
||||
openaicompat.WithAPIKey(os.Getenv("FANTASY_ZAI_API_KEY")),
|
||||
openaicompat.WithHTTPClient(&http.Client{Transport: r}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.LanguageModel(t.Context(), "glm-4.5")
|
||||
}
|
||||
|
||||
func builderGroq(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
|
||||
provider, err := openaicompat.New(
|
||||
openaicompat.WithBaseURL("https://api.groq.com/openai/v1"),
|
||||
openaicompat.WithAPIKey(os.Getenv("FANTASY_GROQ_API_KEY")),
|
||||
openaicompat.WithHTTPClient(&http.Client{Transport: r}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.LanguageModel(t.Context(), "moonshotai/kimi-k2-instruct-0905")
|
||||
}
|
||||
|
||||
func builderHuggingFace(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
|
||||
provider, err := openaicompat.New(
|
||||
openaicompat.WithBaseURL("https://router.huggingface.co/v1"),
|
||||
openaicompat.WithAPIKey(os.Getenv("FANTASY_HUGGINGFACE_API_KEY")),
|
||||
openaicompat.WithHTTPClient(&http.Client{Transport: r}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.LanguageModel(t.Context(), "zai-org/GLM-4.6:cerebras")
|
||||
}
|
||||
|
||||
func builderLlamaCppGptOss(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
|
||||
provider, err := openaicompat.New(
|
||||
openaicompat.WithBaseURL("http://localhost:8080/v1"),
|
||||
openaicompat.WithHTTPClient(&http.Client{Transport: r}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.LanguageModel(t.Context(), "openai/gpt-oss-20b")
|
||||
}
|
||||
129
internal/fantasy/providertests/openrouter_test.go
Normal file
129
internal/fantasy/providertests/openrouter_test.go
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
package providertests
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"charm.land/fantasy/providers/anthropic"
|
||||
"charm.land/fantasy/providers/openrouter"
|
||||
"charm.land/x/vcr"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var openrouterTestModels = []testModel{
|
||||
{"kimi-k2", "moonshotai/kimi-k2-0905", false},
|
||||
{"grok-code-fast-1", "x-ai/grok-code-fast-1", true},
|
||||
{"claude-sonnet-4", "anthropic/claude-sonnet-4", true},
|
||||
{"gemini-2.5-flash", "google/gemini-2.5-flash", false},
|
||||
{"deepseek-chat-v3.1-free", "deepseek/deepseek-chat-v3.1:free", false},
|
||||
{"qwen3-235b-a22b-2507", "qwen/qwen3-235b-a22b-2507", false},
|
||||
{"gpt-5", "openai/gpt-5", true},
|
||||
{"gemini-3-pro-preview", "google/gemini-3-pro-preview", true},
|
||||
{"glm-4.5", "z-ai/glm-4.5", false},
|
||||
{"glm-4.6", "z-ai/glm-4.6", true},
|
||||
}
|
||||
|
||||
func TestOpenRouterCommon(t *testing.T) {
|
||||
var pairs []builderPair
|
||||
for _, m := range openrouterTestModels {
|
||||
pairs = append(pairs, builderPair{m.name, openrouterBuilder(m.model), nil, nil})
|
||||
}
|
||||
testCommon(t, pairs)
|
||||
}
|
||||
|
||||
func TestOpenRouterCommonWithAnthropicCache(t *testing.T) {
|
||||
testCommon(t, []builderPair{
|
||||
{"claude-sonnet-4", openrouterBuilder("anthropic/claude-sonnet-4"), nil, addAnthropicCaching},
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenRouterThinking(t *testing.T) {
|
||||
opts := fantasy.ProviderOptions{
|
||||
openrouter.Name: &openrouter.ProviderOptions{
|
||||
Reasoning: &openrouter.ReasoningOptions{
|
||||
Effort: openrouter.ReasoningEffortOption(openrouter.ReasoningEffortMedium),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var pairs []builderPair
|
||||
for _, m := range openrouterTestModels {
|
||||
if !m.reasoning {
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs, builderPair{m.name, openrouterBuilder(m.model), opts, nil})
|
||||
}
|
||||
testThinking(t, pairs, testOpenrouterThinking)
|
||||
|
||||
// test anthropic signature
|
||||
testThinking(t, []builderPair{
|
||||
{"claude-sonnet-4-sig", openrouterBuilder("anthropic/claude-sonnet-4"), opts, nil},
|
||||
}, testOpenrouterThinkingWithSignature)
|
||||
}
|
||||
|
||||
func testOpenrouterThinkingWithSignature(t *testing.T, result *fantasy.AgentResult) {
|
||||
reasoningContentCount := 0
|
||||
signaturesCount := 0
|
||||
// Test if we got the signature
|
||||
for _, step := range result.Steps {
|
||||
for _, msg := range step.Messages {
|
||||
for _, content := range msg.Content {
|
||||
if content.GetType() == fantasy.ContentTypeReasoning {
|
||||
reasoningContentCount += 1
|
||||
reasoningContent, ok := fantasy.AsContentType[fantasy.ReasoningPart](content)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if len(reasoningContent.ProviderOptions) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
anthropicReasoningMetadata, ok := reasoningContent.ProviderOptions[anthropic.Name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if reasoningContent.Text != "" {
|
||||
if typed, ok := anthropicReasoningMetadata.(*anthropic.ReasoningOptionMetadata); ok {
|
||||
require.NotEmpty(t, typed.Signature)
|
||||
signaturesCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
require.Greater(t, reasoningContentCount, 0)
|
||||
require.Greater(t, signaturesCount, 0)
|
||||
require.Equal(t, reasoningContentCount, signaturesCount)
|
||||
// we also add the anthropic metadata so test that
|
||||
testAnthropicThinking(t, result)
|
||||
}
|
||||
|
||||
func testOpenrouterThinking(t *testing.T, result *fantasy.AgentResult) {
|
||||
reasoningContentCount := 0
|
||||
for _, step := range result.Steps {
|
||||
for _, msg := range step.Messages {
|
||||
for _, content := range msg.Content {
|
||||
if content.GetType() == fantasy.ContentTypeReasoning {
|
||||
reasoningContentCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
require.Greater(t, reasoningContentCount, 0)
|
||||
}
|
||||
|
||||
func openrouterBuilder(model string) builderFunc {
|
||||
return func(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
|
||||
provider, err := openrouter.New(
|
||||
openrouter.WithAPIKey(os.Getenv("FANTASY_OPENROUTER_API_KEY")),
|
||||
openrouter.WithHTTPClient(&http.Client{Transport: r}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider.LanguageModel(t.Context(), model)
|
||||
}
|
||||
}
|
||||
421
internal/fantasy/providertests/provider_registry_test.go
Normal file
421
internal/fantasy/providertests/provider_registry_test.go
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
package providertests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"charm.land/fantasy/providers/anthropic"
|
||||
"charm.land/fantasy/providers/google"
|
||||
"charm.land/fantasy/providers/openai"
|
||||
"charm.land/fantasy/providers/openaicompat"
|
||||
"charm.land/fantasy/providers/openrouter"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestProviderRegistry_Serialization_OpenAIOptions(t *testing.T) {
|
||||
msg := fantasy.Message{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "hi"},
|
||||
},
|
||||
ProviderOptions: fantasy.ProviderOptions{
|
||||
openai.Name: &openai.ProviderOptions{User: fantasy.Opt("tester")},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
require.NoError(t, err)
|
||||
|
||||
var raw struct {
|
||||
ProviderOptions map[string]map[string]any `json:"provider_options"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(data, &raw))
|
||||
|
||||
po, ok := raw.ProviderOptions[openai.Name]
|
||||
require.True(t, ok)
|
||||
require.Equal(t, openai.TypeProviderOptions, po["type"]) // no magic strings
|
||||
// ensure inner data has the field we set
|
||||
inner, ok := po["data"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "tester", inner["user"])
|
||||
|
||||
var decoded fantasy.Message
|
||||
require.NoError(t, json.Unmarshal(data, &decoded))
|
||||
|
||||
got, ok := decoded.ProviderOptions[openai.Name]
|
||||
require.True(t, ok)
|
||||
opt, ok := got.(*openai.ProviderOptions)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, opt.User)
|
||||
require.Equal(t, "tester", *opt.User)
|
||||
}
|
||||
|
||||
func TestProviderRegistry_Serialization_OpenAIResponses(t *testing.T) {
|
||||
// Use ResponsesProviderOptions in provider options
|
||||
msg := fantasy.Message{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "hello"},
|
||||
},
|
||||
ProviderOptions: fantasy.ProviderOptions{
|
||||
openai.Name: &openai.ResponsesProviderOptions{
|
||||
PromptCacheKey: fantasy.Opt("cache-key-1"),
|
||||
ParallelToolCalls: fantasy.Opt(true),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
require.NoError(t, err)
|
||||
|
||||
// JSON should include the typed wrapper with constant TypeResponsesProviderOptions
|
||||
var raw struct {
|
||||
ProviderOptions map[string]map[string]any `json:"provider_options"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(data, &raw))
|
||||
|
||||
po := raw.ProviderOptions[openai.Name]
|
||||
require.Equal(t, openai.TypeResponsesProviderOptions, po["type"]) // no magic strings
|
||||
inner, ok := po["data"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "cache-key-1", inner["prompt_cache_key"])
|
||||
require.Equal(t, true, inner["parallel_tool_calls"])
|
||||
|
||||
// Unmarshal back and assert concrete type
|
||||
var decoded fantasy.Message
|
||||
require.NoError(t, json.Unmarshal(data, &decoded))
|
||||
got := decoded.ProviderOptions[openai.Name]
|
||||
reqOpts, ok := got.(*openai.ResponsesProviderOptions)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, reqOpts.PromptCacheKey)
|
||||
require.Equal(t, "cache-key-1", *reqOpts.PromptCacheKey)
|
||||
require.NotNil(t, reqOpts.ParallelToolCalls)
|
||||
require.Equal(t, true, *reqOpts.ParallelToolCalls)
|
||||
}
|
||||
|
||||
func TestProviderRegistry_Serialization_OpenAIResponsesReasoningMetadata(t *testing.T) {
|
||||
resp := fantasy.Response{
|
||||
Content: []fantasy.Content{
|
||||
fantasy.TextContent{
|
||||
Text: "",
|
||||
ProviderMetadata: fantasy.ProviderMetadata{
|
||||
openai.Name: &openai.ResponsesReasoningMetadata{
|
||||
ItemID: "item-123",
|
||||
Summary: []string{"part1", "part2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(resp)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Ensure the provider metadata is wrapped with type using constant
|
||||
var raw struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Data map[string]any `json:"data"`
|
||||
} `json:"content"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(data, &raw))
|
||||
require.Greater(t, len(raw.Content), 0)
|
||||
tc := raw.Content[0]
|
||||
pm, ok := tc.Data["provider_metadata"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
om, ok := pm[openai.Name].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, openai.TypeResponsesReasoningMetadata, om["type"]) // no magic strings
|
||||
inner, ok := om["data"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "item-123", inner["item_id"])
|
||||
|
||||
// Unmarshal back
|
||||
var decoded fantasy.Response
|
||||
require.NoError(t, json.Unmarshal(data, &decoded))
|
||||
pmDecoded := decoded.Content[0].(fantasy.TextContent).ProviderMetadata
|
||||
val, ok := pmDecoded[openai.Name]
|
||||
require.True(t, ok)
|
||||
meta, ok := val.(*openai.ResponsesReasoningMetadata)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "item-123", meta.ItemID)
|
||||
require.Equal(t, []string{"part1", "part2"}, meta.Summary)
|
||||
}
|
||||
|
||||
func TestProviderRegistry_Serialization_AnthropicOptions(t *testing.T) {
|
||||
sendReasoning := true
|
||||
msg := fantasy.Message{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "test message"},
|
||||
},
|
||||
ProviderOptions: fantasy.ProviderOptions{
|
||||
anthropic.Name: &anthropic.ProviderOptions{
|
||||
SendReasoning: &sendReasoning,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
require.NoError(t, err)
|
||||
|
||||
var decoded fantasy.Message
|
||||
require.NoError(t, json.Unmarshal(data, &decoded))
|
||||
|
||||
got, ok := decoded.ProviderOptions[anthropic.Name]
|
||||
require.True(t, ok)
|
||||
opt, ok := got.(*anthropic.ProviderOptions)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, opt.SendReasoning)
|
||||
require.Equal(t, true, *opt.SendReasoning)
|
||||
}
|
||||
|
||||
func TestProviderRegistry_Serialization_GoogleOptions(t *testing.T) {
|
||||
msg := fantasy.Message{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "test message"},
|
||||
},
|
||||
ProviderOptions: fantasy.ProviderOptions{
|
||||
google.Name: &google.ProviderOptions{
|
||||
CachedContent: "cached-123",
|
||||
Threshold: "BLOCK_ONLY_HIGH",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
require.NoError(t, err)
|
||||
|
||||
var decoded fantasy.Message
|
||||
require.NoError(t, json.Unmarshal(data, &decoded))
|
||||
|
||||
got, ok := decoded.ProviderOptions[google.Name]
|
||||
require.True(t, ok)
|
||||
opt, ok := got.(*google.ProviderOptions)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "cached-123", opt.CachedContent)
|
||||
require.Equal(t, "BLOCK_ONLY_HIGH", opt.Threshold)
|
||||
}
|
||||
|
||||
func TestProviderRegistry_Serialization_OpenRouterOptions(t *testing.T) {
|
||||
includeUsage := true
|
||||
msg := fantasy.Message{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "test message"},
|
||||
},
|
||||
ProviderOptions: fantasy.ProviderOptions{
|
||||
openrouter.Name: &openrouter.ProviderOptions{
|
||||
IncludeUsage: &includeUsage,
|
||||
User: fantasy.Opt("test-user"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
require.NoError(t, err)
|
||||
|
||||
var decoded fantasy.Message
|
||||
require.NoError(t, json.Unmarshal(data, &decoded))
|
||||
|
||||
got, ok := decoded.ProviderOptions[openrouter.Name]
|
||||
require.True(t, ok)
|
||||
opt, ok := got.(*openrouter.ProviderOptions)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, opt.IncludeUsage)
|
||||
require.Equal(t, true, *opt.IncludeUsage)
|
||||
require.NotNil(t, opt.User)
|
||||
require.Equal(t, "test-user", *opt.User)
|
||||
}
|
||||
|
||||
func TestProviderRegistry_Serialization_OpenAICompatOptions(t *testing.T) {
|
||||
effort := openai.ReasoningEffortHigh
|
||||
msg := fantasy.Message{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "test message"},
|
||||
},
|
||||
ProviderOptions: fantasy.ProviderOptions{
|
||||
openaicompat.Name: &openaicompat.ProviderOptions{
|
||||
User: fantasy.Opt("test-user"),
|
||||
ReasoningEffort: &effort,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
require.NoError(t, err)
|
||||
|
||||
var decoded fantasy.Message
|
||||
require.NoError(t, json.Unmarshal(data, &decoded))
|
||||
|
||||
got, ok := decoded.ProviderOptions[openaicompat.Name]
|
||||
require.True(t, ok)
|
||||
opt, ok := got.(*openaicompat.ProviderOptions)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, opt.User)
|
||||
require.Equal(t, "test-user", *opt.User)
|
||||
require.NotNil(t, opt.ReasoningEffort)
|
||||
require.Equal(t, openai.ReasoningEffortHigh, *opt.ReasoningEffort)
|
||||
}
|
||||
|
||||
func TestProviderRegistry_MultiProvider(t *testing.T) {
|
||||
// Test with multiple providers in one message
|
||||
sendReasoning := true
|
||||
msg := fantasy.Message{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "test"},
|
||||
},
|
||||
ProviderOptions: fantasy.ProviderOptions{
|
||||
openai.Name: &openai.ProviderOptions{User: fantasy.Opt("user1")},
|
||||
anthropic.Name: &anthropic.ProviderOptions{
|
||||
SendReasoning: &sendReasoning,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
require.NoError(t, err)
|
||||
|
||||
var decoded fantasy.Message
|
||||
require.NoError(t, json.Unmarshal(data, &decoded))
|
||||
|
||||
// Check OpenAI options
|
||||
openaiOpt, ok := decoded.ProviderOptions[openai.Name]
|
||||
require.True(t, ok)
|
||||
openaiData, ok := openaiOpt.(*openai.ProviderOptions)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "user1", *openaiData.User)
|
||||
|
||||
// Check Anthropic options
|
||||
anthropicOpt, ok := decoded.ProviderOptions[anthropic.Name]
|
||||
require.True(t, ok)
|
||||
anthropicData, ok := anthropicOpt.(*anthropic.ProviderOptions)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, true, *anthropicData.SendReasoning)
|
||||
}
|
||||
|
||||
func TestProviderRegistry_ErrorHandling(t *testing.T) {
|
||||
t.Run("unknown provider type", func(t *testing.T) {
|
||||
invalidJSON := `{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "data": {"text": "hi"}}],
|
||||
"provider_options": {
|
||||
"unknown": {
|
||||
"type": "unknown.provider.type",
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
var msg fantasy.Message
|
||||
err := json.Unmarshal([]byte(invalidJSON), &msg)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unknown provider data type")
|
||||
})
|
||||
|
||||
t.Run("malformed provider data", func(t *testing.T) {
|
||||
invalidJSON := `{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "data": {"text": "hi"}}],
|
||||
"provider_options": {
|
||||
"openai": "not-an-object"
|
||||
}
|
||||
}`
|
||||
|
||||
var msg fantasy.Message
|
||||
err := json.Unmarshal([]byte(invalidJSON), &msg)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProviderRegistry_AllTypesRegistered(t *testing.T) {
|
||||
// Verify all expected provider types are registered
|
||||
// We test that unmarshaling with proper type IDs doesn't fail with "unknown provider data type"
|
||||
tests := []struct {
|
||||
name string
|
||||
providerName string
|
||||
data fantasy.ProviderOptionsData
|
||||
}{
|
||||
{"OpenAI Options", openai.Name, &openai.ProviderOptions{}},
|
||||
{"OpenAI File Options", openai.Name, &openai.ProviderFileOptions{}},
|
||||
{"OpenAI Metadata", openai.Name, &openai.ProviderMetadata{}},
|
||||
{"OpenAI Responses Options", openai.Name, &openai.ResponsesProviderOptions{}},
|
||||
{"Anthropic Options", anthropic.Name, &anthropic.ProviderOptions{}},
|
||||
{"Google Options", google.Name, &google.ProviderOptions{}},
|
||||
{"OpenRouter Options", openrouter.Name, &openrouter.ProviderOptions{}},
|
||||
{"OpenAICompat Options", openaicompat.Name, &openaicompat.ProviderOptions{}},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create a message with the provider options
|
||||
msg := fantasy.Message{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "test"},
|
||||
},
|
||||
ProviderOptions: fantasy.ProviderOptions{
|
||||
tc.providerName: tc.data,
|
||||
},
|
||||
}
|
||||
|
||||
// Marshal and unmarshal
|
||||
data, err := json.Marshal(msg)
|
||||
require.NoError(t, err)
|
||||
|
||||
var decoded fantasy.Message
|
||||
err = json.Unmarshal(data, &decoded)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the provider options exist
|
||||
_, ok := decoded.ProviderOptions[tc.providerName]
|
||||
require.True(t, ok, "Provider options should be present after round-trip")
|
||||
})
|
||||
}
|
||||
|
||||
// Test metadata types separately as they go in different field
|
||||
metadataTests := []struct {
|
||||
name string
|
||||
providerName string
|
||||
data fantasy.ProviderOptionsData
|
||||
}{
|
||||
{"OpenAI Responses Reasoning Metadata", openai.Name, &openai.ResponsesReasoningMetadata{}},
|
||||
{"Anthropic Reasoning Metadata", anthropic.Name, &anthropic.ReasoningOptionMetadata{}},
|
||||
{"Google Reasoning Metadata", google.Name, &google.ReasoningMetadata{}},
|
||||
{"OpenRouter Metadata", openrouter.Name, &openrouter.ProviderMetadata{}},
|
||||
}
|
||||
|
||||
for _, tc := range metadataTests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create a response with provider metadata
|
||||
resp := fantasy.Response{
|
||||
Content: []fantasy.Content{
|
||||
fantasy.TextContent{
|
||||
Text: "test",
|
||||
ProviderMetadata: fantasy.ProviderMetadata{
|
||||
tc.providerName: tc.data,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Marshal and unmarshal
|
||||
data, err := json.Marshal(resp)
|
||||
require.NoError(t, err)
|
||||
|
||||
var decoded fantasy.Response
|
||||
err = json.Unmarshal(data, &decoded)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the provider metadata exists
|
||||
textContent, ok := decoded.Content[0].(fantasy.TextContent)
|
||||
require.True(t, ok)
|
||||
_, ok = textContent.ProviderMetadata[tc.providerName]
|
||||
require.True(t, ok, "Provider metadata should be present after round-trip")
|
||||
})
|
||||
}
|
||||
}
|
||||
63
internal/fantasy/providertests/testdata/TestAnthropicCommon/claude-sonnet-4/multi_tool.yaml
vendored
Normal file
63
internal/fantasy/providertests/testdata/TestAnthropicCommon/claude-sonnet-4/multi_tool.yaml
vendored
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 815
|
||||
host: ""
|
||||
body: '{"max_tokens":4000,"messages":[{"content":[{"text":"Add and multiply the number 2 and 3","type":"text"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant. CRITICAL: Always use both add and multiply at the same time ALWAYS.","type":"text"}],"tool_choice":{"disable_parallel_tool_use":false,"type":"auto"},"tools":[{"input_schema":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"},"name":"add","description":"Add two numbers"},{"input_schema":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"},"name":"multiply","description":"Multiply two numbers"}]}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.10.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"id":"msg_012C9wnj2t5QUQ9h5kZsQYbY","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"I''ll add and multiply the numbers 2 and 3 for you."},{"type":"tool_use","id":"toolu_01RZ48N8E3iuoHP5TqX57bHP","name":"add","input":{"a":2,"b":3}},{"type":"tool_use","id":"toolu_01FLTgP8FxddWfSCSNsD3cpM","name":"multiply","input":{"a":2,"b":3}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":507,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":137,"service_tier":"standard"}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 2.215481584s
|
||||
- id: 1
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 1361
|
||||
host: ""
|
||||
body: '{"max_tokens":4000,"messages":[{"content":[{"text":"Add and multiply the number 2 and 3","type":"text"}],"role":"user"},{"content":[{"text":"I''ll add and multiply the numbers 2 and 3 for you.","type":"text"},{"id":"toolu_01RZ48N8E3iuoHP5TqX57bHP","input":{"a":2,"b":3},"name":"add","type":"tool_use"},{"id":"toolu_01FLTgP8FxddWfSCSNsD3cpM","input":{"a":2,"b":3},"name":"multiply","type":"tool_use"}],"role":"assistant"},{"content":[{"tool_use_id":"toolu_01RZ48N8E3iuoHP5TqX57bHP","content":[{"text":"5","type":"text"}],"type":"tool_result"},{"tool_use_id":"toolu_01FLTgP8FxddWfSCSNsD3cpM","content":[{"text":"6","type":"text"}],"type":"tool_result"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant. CRITICAL: Always use both add and multiply at the same time ALWAYS.","type":"text"}],"tool_choice":{"disable_parallel_tool_use":false,"type":"auto"},"tools":[{"input_schema":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"},"name":"add","description":"Add two numbers"},{"input_schema":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"},"name":"multiply","description":"Multiply two numbers"}]}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.10.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"id":"msg_01GXK2MWdJ8vNAhV63dyxdSV","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"The results are:\n- 2 + 3 = 5\n- 2 × 3 = 6"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":705,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":31,"service_tier":"standard"}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 2.172643959s
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 812
|
||||
host: ""
|
||||
body: '{"max_tokens":4000,"messages":[{"content":[{"text":"Add and multiply the number 2 and 3","type":"text"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant. Always use both add and multiply at the same time.","type":"text"}],"tool_choice":{"disable_parallel_tool_use":false,"type":"auto"},"tools":[{"input_schema":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"},"name":"add","description":"Add two numbers"},{"input_schema":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"},"name":"multiply","description":"Multiply two numbers"}],"stream":true}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.10.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
body: |+
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"id":"msg_01Sp6xSRhhVGg9eCXwHHDK4H","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":502,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard"}} }
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"I'll add an"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"d multiply the numbers 2 and 3 for"} }
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" you."} }
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0 }
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01GrN36ErVgy85ECZ6pbzBL4","name":"add","input":{}} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"a\": "} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"2"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":", \"b\": 3}"} }
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":1 }
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"toolu_0113xQa4VtccyoXcHaukkzom","name":"multiply","input":{}}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"a\": 2"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":", \"b\":"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":" 3}"} }
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":2 }
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":502,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":137} }
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop" }
|
||||
|
||||
headers:
|
||||
Content-Type:
|
||||
- text/event-stream; charset=utf-8
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 1.85757275s
|
||||
- id: 1
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 1358
|
||||
host: ""
|
||||
body: '{"max_tokens":4000,"messages":[{"content":[{"text":"Add and multiply the number 2 and 3","type":"text"}],"role":"user"},{"content":[{"text":"I''ll add and multiply the numbers 2 and 3 for you.","type":"text"},{"id":"toolu_01GrN36ErVgy85ECZ6pbzBL4","input":{"a":2,"b":3},"name":"add","type":"tool_use"},{"id":"toolu_0113xQa4VtccyoXcHaukkzom","input":{"a":2,"b":3},"name":"multiply","type":"tool_use"}],"role":"assistant"},{"content":[{"tool_use_id":"toolu_01GrN36ErVgy85ECZ6pbzBL4","content":[{"text":"5","type":"text"}],"type":"tool_result"},{"tool_use_id":"toolu_0113xQa4VtccyoXcHaukkzom","content":[{"text":"6","type":"text"}],"type":"tool_result"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant. Always use both add and multiply at the same time.","type":"text"}],"tool_choice":{"disable_parallel_tool_use":false,"type":"auto"},"tools":[{"input_schema":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"},"name":"add","description":"Add two numbers"},{"input_schema":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"},"name":"multiply","description":"Multiply two numbers"}],"stream":true}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.10.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
body: |+
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"id":"msg_015VFFpA2pw4T7BRXd2sXMNL","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":700,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard"}} }
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"The results"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" are:\n- 2 + 3 = "} }
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"5\n- 2 × 3 = 6"} }
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0 }
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":700,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":31} }
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop" }
|
||||
|
||||
headers:
|
||||
Content-Type:
|
||||
- text/event-stream; charset=utf-8
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 1.874751333s
|
||||
33
internal/fantasy/providertests/testdata/TestAnthropicCommon/claude-sonnet-4/simple.yaml
vendored
Normal file
33
internal/fantasy/providertests/testdata/TestAnthropicCommon/claude-sonnet-4/simple.yaml
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 205
|
||||
host: ""
|
||||
body: '{"max_tokens":4000,"messages":[{"content":[{"text":"Say hi in Portuguese","type":"text"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant","type":"text"}]}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.10.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"id":"msg_01P9AGfyVhqwFEY82kTBEeip","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Olá! (Oh-LAH)\n\nThis is the most common way to say \"hi\" in Portuguese. You could also use \"Oi!\" (OY) which is a bit more casual and friendly."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":16,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":50,"service_tier":"standard"}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 3.286588541s
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 219
|
||||
host: ""
|
||||
body: '{"max_tokens":4000,"messages":[{"content":[{"text":"Say hi in Portuguese","type":"text"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant","type":"text"}],"stream":true}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.10.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
body: |+
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"id":"msg_01LA3hHsa4pzM8hT7PTXtJGz","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":16,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard"}} }
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Olá!"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"\n\n(This is the most common way to say \"hi"} }
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"\" in Portuguese. You"}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" could also say \"Oi!\""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" for a more casual"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" greeting.)"} }
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0 }
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":16,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":38} }
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop" }
|
||||
|
||||
headers:
|
||||
Content-Type:
|
||||
- text/event-stream; charset=utf-8
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 1.544292291s
|
||||
63
internal/fantasy/providertests/testdata/TestAnthropicCommon/claude-sonnet-4/tool.yaml
vendored
Normal file
63
internal/fantasy/providertests/testdata/TestAnthropicCommon/claude-sonnet-4/tool.yaml
vendored
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 496
|
||||
host: ""
|
||||
body: '{"max_tokens":4000,"messages":[{"content":[{"text":"What''s the weather in Florence,Italy?","type":"text"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant","type":"text"}],"tool_choice":{"disable_parallel_tool_use":false,"type":"auto"},"tools":[{"input_schema":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"},"name":"weather","description":"Get weather information for a location"}]}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.10.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"id":"msg_015hRye3hWyU1hyU9Vsbzept","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"I''ll check the weather in Florence, Italy for you."},{"type":"tool_use","id":"toolu_01Dx5rwFL7pCC3c11qUZiSFH","name":"weather","input":{"location":"Florence, Italy"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":394,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":66,"service_tier":"standard"}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 2.2413465s
|
||||
- id: 1
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 859
|
||||
host: ""
|
||||
body: '{"max_tokens":4000,"messages":[{"content":[{"text":"What''s the weather in Florence,Italy?","type":"text"}],"role":"user"},{"content":[{"text":"I''ll check the weather in Florence, Italy for you.","type":"text"},{"id":"toolu_01Dx5rwFL7pCC3c11qUZiSFH","input":{"location":"Florence, Italy"},"name":"weather","type":"tool_use"}],"role":"assistant"},{"content":[{"tool_use_id":"toolu_01Dx5rwFL7pCC3c11qUZiSFH","content":[{"text":"40 C","type":"text"}],"type":"tool_result"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant","type":"text"}],"tool_choice":{"disable_parallel_tool_use":false,"type":"auto"},"tools":[{"input_schema":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"},"name":"weather","description":"Get weather information for a location"}]}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.10.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"id":"msg_01DvxRTrWQwennnPVCKYDpVg","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"The current weather in Florence, Italy is 40°C (104°F). That''s quite hot! It''s a very warm day in Florence."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":475,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":34,"service_tier":"standard"}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 2.441049125s
|
||||
148
internal/fantasy/providertests/testdata/TestAnthropicCommon/claude-sonnet-4/tool_streaming.yaml
vendored
Normal file
148
internal/fantasy/providertests/testdata/TestAnthropicCommon/claude-sonnet-4/tool_streaming.yaml
vendored
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 510
|
||||
host: ""
|
||||
body: '{"max_tokens":4000,"messages":[{"content":[{"text":"What''s the weather in Florence,Italy?","type":"text"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant","type":"text"}],"tool_choice":{"disable_parallel_tool_use":false,"type":"auto"},"tools":[{"input_schema":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"},"name":"weather","description":"Get weather information for a location"}],"stream":true}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.10.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
body: |+
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"id":"msg_01YNtSKCnRnNJoaUmmcZVnsF","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":394,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":8,"service_tier":"standard"}}}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"I'll check the weather in Florence,"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Italy for you."} }
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0 }
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01TQLWhUiZa4j3esJhrzHpRV","name":"weather","input":{}} }
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"lo"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"cation\""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":": "} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"Florence"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":",Italy\"}"} }
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":1 }
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":394,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":66} }
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop" }
|
||||
|
||||
headers:
|
||||
Content-Type:
|
||||
- text/event-stream; charset=utf-8
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 1.173477625s
|
||||
- id: 1
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 872
|
||||
host: ""
|
||||
body: '{"max_tokens":4000,"messages":[{"content":[{"text":"What''s the weather in Florence,Italy?","type":"text"}],"role":"user"},{"content":[{"text":"I''ll check the weather in Florence, Italy for you.","type":"text"},{"id":"toolu_01TQLWhUiZa4j3esJhrzHpRV","input":{"location":"Florence,Italy"},"name":"weather","type":"tool_use"}],"role":"assistant"},{"content":[{"tool_use_id":"toolu_01TQLWhUiZa4j3esJhrzHpRV","content":[{"text":"40 C","type":"text"}],"type":"tool_result"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant","type":"text"}],"tool_choice":{"disable_parallel_tool_use":false,"type":"auto"},"tools":[{"input_schema":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"},"name":"weather","description":"Get weather information for a location"}],"stream":true}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.10.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
body: |+
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"id":"msg_01Xib3pEmJjt4hNy7sEx4k5S","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":475,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}} }
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"The"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" current weather in Florence, Italy shows a temperature of 40°C (104"} }
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"°F). That's quite hot! Make sure to stay hydrated and seek"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" shade or air conditioning if you're planning to be out"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"doors."} }
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0 }
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":475,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":49} }
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop" }
|
||||
|
||||
headers:
|
||||
Content-Type:
|
||||
- text/event-stream; charset=utf-8
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 2.470388833s
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 889
|
||||
host: ""
|
||||
body: '{"max_tokens":4000,"messages":[{"content":[{"text":"Add and multiply the number 2 and 3","cache_control":{"type":"ephemeral"},"type":"text"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant. CRITICAL: Always use both add and multiply at the same time ALWAYS.","cache_control":{"type":"ephemeral"},"type":"text"}],"tool_choice":{"disable_parallel_tool_use":false,"type":"auto"},"tools":[{"input_schema":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"},"name":"add","description":"Add two numbers"},{"input_schema":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"},"name":"multiply","description":"Multiply two numbers"}]}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.14.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"model":"claude-sonnet-4-20250514","id":"msg_01SznaDwXbs3XRxBLMr2X7T3","type":"message","role":"assistant","content":[{"type":"text","text":"I''ll add and multiply the numbers 2 and 3 for you."},{"type":"tool_use","id":"toolu_01R4MgGUzmDFt4FVjLhPom7k","name":"add","input":{"a":2,"b":3}},{"type":"tool_use","id":"toolu_011jyywbY4Fv6JjWSS6cpVYp","name":"multiply","input":{"a":2,"b":3}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":507,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":137,"service_tier":"standard"}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 2.974480333s
|
||||
- id: 1
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 1472
|
||||
host: ""
|
||||
body: '{"max_tokens":4000,"messages":[{"content":[{"text":"Add and multiply the number 2 and 3","type":"text"}],"role":"user"},{"content":[{"text":"I''ll add and multiply the numbers 2 and 3 for you.","type":"text"},{"id":"toolu_01R4MgGUzmDFt4FVjLhPom7k","input":{"a":2,"b":3},"name":"add","type":"tool_use"},{"id":"toolu_011jyywbY4Fv6JjWSS6cpVYp","input":{"a":2,"b":3},"name":"multiply","cache_control":{"type":"ephemeral"},"type":"tool_use"}],"role":"assistant"},{"content":[{"tool_use_id":"toolu_01R4MgGUzmDFt4FVjLhPom7k","content":[{"text":"5","type":"text"}],"type":"tool_result"},{"tool_use_id":"toolu_011jyywbY4Fv6JjWSS6cpVYp","cache_control":{"type":"ephemeral"},"content":[{"text":"6","type":"text"}],"type":"tool_result"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant. CRITICAL: Always use both add and multiply at the same time ALWAYS.","cache_control":{"type":"ephemeral"},"type":"text"}],"tool_choice":{"disable_parallel_tool_use":false,"type":"auto"},"tools":[{"input_schema":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"},"name":"add","description":"Add two numbers"},{"input_schema":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"},"name":"multiply","description":"Multiply two numbers"}]}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.14.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"model":"claude-sonnet-4-20250514","id":"msg_01CNT45ENKCAtApVdKnkwkNc","type":"message","role":"assistant","content":[{"type":"text","text":"The results are:\n- 2 + 3 = 5\n- 2 × 3 = 6"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":705,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":31,"service_tier":"standard"}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 3.377406042s
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 886
|
||||
host: ""
|
||||
body: '{"max_tokens":4000,"messages":[{"content":[{"text":"Add and multiply the number 2 and 3","cache_control":{"type":"ephemeral"},"type":"text"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant. Always use both add and multiply at the same time.","cache_control":{"type":"ephemeral"},"type":"text"}],"tool_choice":{"disable_parallel_tool_use":false,"type":"auto"},"tools":[{"input_schema":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"},"name":"add","description":"Add two numbers"},{"input_schema":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"},"name":"multiply","description":"Multiply two numbers"}],"stream":true}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.14.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
body: |+
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"model":"claude-sonnet-4-20250514","id":"msg_01Ps4SquTmAkLoZixSEW4SrN","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":502,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard"}} }
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"I'll add an"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"d multiply the numbers 2 and 3 for"} }
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" you."} }
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_014EzUJ1TCJvs3dR7mZZgoyd","name":"add","input":{}} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"a\":"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":" 2"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":", "} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"b\": 3}"} }
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":1 }
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"toolu_01GYivdvVTotPKuatZiCkQvv","name":"multiply","input":{}} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"a\": "} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"2"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":", \"b\": "} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"3}"} }
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":2 }
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":502,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":137} }
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop" }
|
||||
|
||||
headers:
|
||||
Content-Type:
|
||||
- text/event-stream; charset=utf-8
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 1.806945584s
|
||||
- id: 1
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 1469
|
||||
host: ""
|
||||
body: '{"max_tokens":4000,"messages":[{"content":[{"text":"Add and multiply the number 2 and 3","type":"text"}],"role":"user"},{"content":[{"text":"I''ll add and multiply the numbers 2 and 3 for you.","type":"text"},{"id":"toolu_014EzUJ1TCJvs3dR7mZZgoyd","input":{"a":2,"b":3},"name":"add","type":"tool_use"},{"id":"toolu_01GYivdvVTotPKuatZiCkQvv","input":{"a":2,"b":3},"name":"multiply","cache_control":{"type":"ephemeral"},"type":"tool_use"}],"role":"assistant"},{"content":[{"tool_use_id":"toolu_014EzUJ1TCJvs3dR7mZZgoyd","content":[{"text":"5","type":"text"}],"type":"tool_result"},{"tool_use_id":"toolu_01GYivdvVTotPKuatZiCkQvv","cache_control":{"type":"ephemeral"},"content":[{"text":"6","type":"text"}],"type":"tool_result"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant. Always use both add and multiply at the same time.","cache_control":{"type":"ephemeral"},"type":"text"}],"tool_choice":{"disable_parallel_tool_use":false,"type":"auto"},"tools":[{"input_schema":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"},"name":"add","description":"Add two numbers"},{"input_schema":{"properties":{"a":{"description":"first number","type":"integer"},"b":{"description":"second number","type":"integer"}},"required":["a","b"],"type":"object"},"name":"multiply","description":"Multiply two numbers"}],"stream":true}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.14.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
body: |+
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"model":"claude-sonnet-4-20250514","id":"msg_016tKmgfhWH1tfpDjeFE5JXn","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":700,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}} }
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"The"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" results are:\n- Adding"} }
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" 2 + 3 = 5\n-"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Multiplying 2 × "} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"3 = 6"} }
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0 }
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":700,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":35} }
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop" }
|
||||
|
||||
headers:
|
||||
Content-Type:
|
||||
- text/event-stream; charset=utf-8
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 1.850997125s
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 279
|
||||
host: ""
|
||||
body: '{"max_tokens":4000,"messages":[{"content":[{"text":"Say hi in Portuguese","cache_control":{"type":"ephemeral"},"type":"text"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant","cache_control":{"type":"ephemeral"},"type":"text"}]}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.14.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"model":"claude-sonnet-4-20250514","id":"msg_01SM76nbs7k7qZjGy2hihYc6","type":"message","role":"assistant","content":[{"type":"text","text":"Olá! (Oh-LAH)\n\nThis is the most common way to say \"hi\" in Portuguese. You could also say \"Oi!\" (OH-ee) which is more casual and friendly."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":16,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":49,"service_tier":"standard"}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 2.99537125s
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 293
|
||||
host: ""
|
||||
body: '{"max_tokens":4000,"messages":[{"content":[{"text":"Say hi in Portuguese","cache_control":{"type":"ephemeral"},"type":"text"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant","cache_control":{"type":"ephemeral"},"type":"text"}],"stream":true}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.14.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
body: |+
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"model":"claude-sonnet-4-20250514","id":"msg_01QBAGSzfj2ZoqStejnhgqXh","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":16,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard"}} }
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Olá!"}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"\n\n("} }
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"That's \"hello"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"\" in Portuguese."} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Other common"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" greetings include \""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Oi!\""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" for a more casual \""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi!\")"} }
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0 }
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":16,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":35} }
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop" }
|
||||
|
||||
headers:
|
||||
Content-Type:
|
||||
- text/event-stream; charset=utf-8
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 2.247809834s
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 570
|
||||
host: ""
|
||||
body: '{"max_tokens":4000,"messages":[{"content":[{"text":"What''s the weather in Florence,Italy?","cache_control":{"type":"ephemeral"},"type":"text"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant","cache_control":{"type":"ephemeral"},"type":"text"}],"tool_choice":{"disable_parallel_tool_use":false,"type":"auto"},"tools":[{"input_schema":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"},"name":"weather","description":"Get weather information for a location"}]}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.14.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"model":"claude-sonnet-4-20250514","id":"msg_01Royrn78rAtSCcgQCG9QqoG","type":"message","role":"assistant","content":[{"type":"text","text":"I''ll get the weather information for Florence, Italy for you."},{"type":"tool_use","id":"toolu_01TdBhfkiVbuyrCtsoQ2P4v8","name":"weather","input":{"location":"Florence, Italy"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":394,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":67,"service_tier":"standard"}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 2.230270958s
|
||||
- id: 1
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 981
|
||||
host: ""
|
||||
body: '{"max_tokens":4000,"messages":[{"content":[{"text":"What''s the weather in Florence,Italy?","type":"text"}],"role":"user"},{"content":[{"text":"I''ll get the weather information for Florence, Italy for you.","type":"text"},{"id":"toolu_01TdBhfkiVbuyrCtsoQ2P4v8","input":{"location":"Florence, Italy"},"name":"weather","cache_control":{"type":"ephemeral"},"type":"tool_use"}],"role":"assistant"},{"content":[{"tool_use_id":"toolu_01TdBhfkiVbuyrCtsoQ2P4v8","cache_control":{"type":"ephemeral"},"content":[{"text":"40 C","type":"text"}],"type":"tool_result"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant","cache_control":{"type":"ephemeral"},"type":"text"}],"tool_choice":{"disable_parallel_tool_use":false,"type":"auto"},"tools":[{"input_schema":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"},"name":"weather","description":"Get weather information for a location"}]}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.14.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"model":"claude-sonnet-4-20250514","id":"msg_01EiThKJDKp98nTMtjAWoXG6","type":"message","role":"assistant","content":[{"type":"text","text":"The current temperature in Florence, Italy is 40°C (104°F). That''s quite hot! Make sure to stay hydrated and seek shade or air conditioning if you''re planning to be outdoors."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":476,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":46,"service_tier":"standard"}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 2.465001083s
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 584
|
||||
host: ""
|
||||
body: '{"max_tokens":4000,"messages":[{"content":[{"text":"What''s the weather in Florence,Italy?","cache_control":{"type":"ephemeral"},"type":"text"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant","cache_control":{"type":"ephemeral"},"type":"text"}],"tool_choice":{"disable_parallel_tool_use":false,"type":"auto"},"tools":[{"input_schema":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"},"name":"weather","description":"Get weather information for a location"}],"stream":true}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.14.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
body: |+
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"model":"claude-sonnet-4-20250514","id":"msg_01HzMwmi82NUByB1sP1H4Rtw","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":394,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":8,"service_tier":"standard"}} }
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"I'll check the weather in Florence,"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Italy for you."} }
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0 }
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01TTGTdVydBd7icXtF1o89n7","name":"weather","input":{}} }
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"locati"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"on"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\": \"Floren"}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"ce, "} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"Italy\"}"} }
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":1 }
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":394,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":66} }
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop" }
|
||||
|
||||
headers:
|
||||
Content-Type:
|
||||
- text/event-stream; charset=utf-8
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 1.726456542s
|
||||
- id: 1
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 984
|
||||
host: ""
|
||||
body: '{"max_tokens":4000,"messages":[{"content":[{"text":"What''s the weather in Florence,Italy?","type":"text"}],"role":"user"},{"content":[{"text":"I''ll check the weather in Florence, Italy for you.","type":"text"},{"id":"toolu_01TTGTdVydBd7icXtF1o89n7","input":{"location":"Florence, Italy"},"name":"weather","cache_control":{"type":"ephemeral"},"type":"tool_use"}],"role":"assistant"},{"content":[{"tool_use_id":"toolu_01TTGTdVydBd7icXtF1o89n7","cache_control":{"type":"ephemeral"},"content":[{"text":"40 C","type":"text"}],"type":"tool_result"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant","cache_control":{"type":"ephemeral"},"type":"text"}],"tool_choice":{"disable_parallel_tool_use":false,"type":"auto"},"tools":[{"input_schema":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"},"name":"weather","description":"Get weather information for a location"}],"stream":true}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.14.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
body: |+
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"model":"claude-sonnet-4-20250514","id":"msg_01UX1WnAwYyv4VsX8Cpuzxcv","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":475,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard"}} }
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"The current"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" weather in Florence, Italy is 40"} }
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"°C (104°F)."} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" That's quite hot! It's a very"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" warm day in Florence"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"."} }
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0 }
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":475,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":34} }
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop"}
|
||||
|
||||
headers:
|
||||
Content-Type:
|
||||
- text/event-stream; charset=utf-8
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 2.045913625s
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 973
|
||||
host: ""
|
||||
body: '{"max_tokens":4000,"messages":[{"content":[{"text":"Generate information about ''The Lord of the Rings'' book by J.R.R. Tolkien, including genres like fantasy and adventure, and its publication year (1954).","type":"text"}],"role":"user"}],"model":"claude-sonnet-4-20250514","tool_choice":{"name":"Book","disable_parallel_tool_use":false,"type":"tool"},"tools":[{"input_schema":{"properties":{"author":{"properties":{"name":{"description":"Author''s name","type":"string"},"nationality":{"description":"Author''s nationality","type":"string"}},"required":["name","nationality"],"type":"object"},"genres":{"description":"List of genres","items":{"type":"string"},"type":"array"},"published_year":{"description":"Year the book was published","type":"integer"},"title":{"description":"The book title","type":"string"}},"required":["title","author","genres","published_year"],"type":"object"},"name":"Book","description":"A book with title, author, genres, and publication year"}]}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.14.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"model":"claude-sonnet-4-20250514","id":"msg_01UkSBWK7ReA1Jc9w3ZSKrNb","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01C9Z3xQvtkmqxD7fyJ4BHN5","name":"Book","input":{"title":"The Lord of the Rings","author":{"name":"J.R.R. Tolkien","nationality":"British"},"genres":["Fantasy","Adventure","Epic Fantasy","High Fantasy"],"published_year":1954}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":549,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":124,"service_tier":"standard"}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 3.668454583s
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 987
|
||||
host: ""
|
||||
body: '{"max_tokens":4000,"messages":[{"content":[{"text":"Generate information about ''The Lord of the Rings'' book by J.R.R. Tolkien, including genres like fantasy and adventure, and its publication year (1954).","type":"text"}],"role":"user"}],"model":"claude-sonnet-4-20250514","tool_choice":{"name":"Book","disable_parallel_tool_use":false,"type":"tool"},"tools":[{"input_schema":{"properties":{"author":{"properties":{"name":{"description":"Author''s name","type":"string"},"nationality":{"description":"Author''s nationality","type":"string"}},"required":["name","nationality"],"type":"object"},"genres":{"description":"List of genres","items":{"type":"string"},"type":"array"},"published_year":{"description":"Year the book was published","type":"integer"},"title":{"description":"The book title","type":"string"}},"required":["title","author","genres","published_year"],"type":"object"},"name":"Book","description":"A book with title, author, genres, and publication year"}],"stream":true}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.14.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
body: |+
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"model":"claude-sonnet-4-20250514","id":"msg_01USjYftFnxQLCtwU6p9mdju","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":549,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":16,"service_tier":"standard"}} }
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01GSfr2xJn2q4zqG46GXNdxt","name":"Book","input":{}} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""} }
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"ti"} }
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"tle\": \"T"} }
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"he Lord"} }
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":" of t"} }
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"he Ring"} }
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"s\""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":", \"author\""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":": {\"nam"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"e\""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":":\""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"J.R.R. To"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"lkien\",\"na"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"tionality\":\""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"Bri"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"tish\"}"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":", \"g"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"enre"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"s\": [\"Fanta"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"sy\",\"Adventu"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"re\",\"Epic Fa"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"nta"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"sy\",\"High "} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"Fantasy\"]"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":", \"pub"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"lished_"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"yea"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"r\":"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":" 1954}"} }
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0}
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":549,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":124} }
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop" }
|
||||
|
||||
headers:
|
||||
Content-Type:
|
||||
- text/event-stream; charset=utf-8
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 1.980122625s
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 637
|
||||
host: ""
|
||||
body: '{"max_tokens":4000,"messages":[{"content":[{"text":"Generate information about a person named Alice who is 30 years old and lives in Paris.","type":"text"}],"role":"user"}],"model":"claude-sonnet-4-20250514","tool_choice":{"name":"Person","disable_parallel_tool_use":false,"type":"tool"},"tools":[{"input_schema":{"properties":{"age":{"description":"The person''s age","type":"integer"},"city":{"description":"The city where the person lives","type":"string"},"name":{"description":"The person''s name","type":"string"}},"required":["name","age","city"],"type":"object"},"name":"Person","description":"A person with name, age, and city"}]}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.14.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"model":"claude-sonnet-4-20250514","id":"msg_01NeodXcmcw7q1AnbSJ8ShV4","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019sLuNYTpv7VTTJEuGWXzki","name":"Person","input":{"name":"Alice","age":30,"city":"Paris"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":454,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":67,"service_tier":"standard"}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 2.542812416s
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 651
|
||||
host: ""
|
||||
body: '{"max_tokens":4000,"messages":[{"content":[{"text":"Generate information about a person named Alice who is 30 years old and lives in Paris.","type":"text"}],"role":"user"}],"model":"claude-sonnet-4-20250514","tool_choice":{"name":"Person","disable_parallel_tool_use":false,"type":"tool"},"tools":[{"input_schema":{"properties":{"age":{"description":"The person''s age","type":"integer"},"city":{"description":"The city where the person lives","type":"string"},"name":{"description":"The person''s name","type":"string"}},"required":["name","age","city"],"type":"object"},"name":"Person","description":"A person with name, age, and city"}],"stream":true}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.14.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
body: |+
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"model":"claude-sonnet-4-20250514","id":"msg_01AihXNzoggoeqPeQuWPMYSo","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":454,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":24,"service_tier":"standard"}} }
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01Midg7qjzUz6pdkS8HcgRY7","name":"Person","input":{}} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""} }
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"n"} }
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"ame\":"} }
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":" \"Alic"} }
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"e\""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":", \"age\""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":": 30"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":", \"c"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"ity\": \"Pa"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"ris\"}"} }
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0 }
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":454,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":67} }
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop" }
|
||||
|
||||
headers:
|
||||
Content-Type:
|
||||
- text/event-stream; charset=utf-8
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 2.144504667s
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 562
|
||||
host: ""
|
||||
body: '{"max_tokens":4096,"messages":[{"content":[{"text":"What''s the weather in Florence, Italy?","type":"text"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant","type":"text"}],"thinking":{"budget_tokens":4000,"type":"enabled"},"tool_choice":{"disable_parallel_tool_use":false,"type":"auto"},"tools":[{"input_schema":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"},"name":"weather","description":"Get weather information for a location"}],"stream":true}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.10.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
body: |+
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"id":"msg_01WBeG2NaYQ8w99747S3n8MZ","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":423,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard"}} }
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"The user is asking for"}}
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" weather information for Florence, Italy. I have"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" access to a weather function that takes a \"location\" parameter. The"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" user has provided the location as \"Florence,"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" Italy\" which is clear and specific. I have"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" all the required parameters to make this function call."} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"EsQDCkYICBgCKkBr9+FTcKs+la0OlmRVB4HF/bTERKj+hr9pp9uxwdet2/P99iNZh/vwxm7Cd5wrZD0FnPkUz3DEqXv+ZoIL2fAoEgxOEzQepAptMFJiFJcaDM2L2wJzGec9170N1SIwHhl0DW0hXYbwHTRAR6w9biU0HwUh00oLghjeBusiv40ctsJB4tO/WyOR2f0iH7pZKqsC3UTE2b7nqQUmYP8dEDfnfNdbr24zEaWiZ2QMbwv9ZrwtUy0Wo/ot3n9JMpocHUSwtyKzB4GsMsgr/xtGEl9541AMSFhR1G8y3Ffn22e6pXV7dakwU/vxcBTvdwfBplrBxxJ1zT/ieuQUrdemKCRZS4QVmaVijUpYQMaQF7iCJJPKYI+wBhxT0y0yOoVqORKg2AuRmbkTpMfKZJ5xRBLAeydMhloeKfYswDDisWIChmJlclIro0Jr80OhpohTcQGayY4fjn0A/ir+9PYzLzjGitR3a+/lfHvuTmPC9AnWv3Sp87mddUuJ10jqO9QWXNTnAVdehLiN/heJVjOMkXCstWLVUycEgnfwXEqlNMMFuRAfOwTte0Ls9qHkl3Fzo8ompJiqxF879rSwcSEYAQ=="} }
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0 }
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_0171KFcBwn1Z64XzvQfz3sZS","name":"weather","input":{}} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"location"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\": \"F"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"lo"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"rence, I"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"taly"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"}"} }
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":1 }
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":423,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":119} }
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop" }
|
||||
|
||||
headers:
|
||||
Content-Type:
|
||||
- text/event-stream; charset=utf-8
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 1.294270083s
|
||||
- id: 1
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 1796
|
||||
host: ""
|
||||
body: '{"max_tokens":4096,"messages":[{"content":[{"text":"What''s the weather in Florence, Italy?","type":"text"}],"role":"user"},{"content":[{"signature":"EsQDCkYICBgCKkBr9+FTcKs+la0OlmRVB4HF/bTERKj+hr9pp9uxwdet2/P99iNZh/vwxm7Cd5wrZD0FnPkUz3DEqXv+ZoIL2fAoEgxOEzQepAptMFJiFJcaDM2L2wJzGec9170N1SIwHhl0DW0hXYbwHTRAR6w9biU0HwUh00oLghjeBusiv40ctsJB4tO/WyOR2f0iH7pZKqsC3UTE2b7nqQUmYP8dEDfnfNdbr24zEaWiZ2QMbwv9ZrwtUy0Wo/ot3n9JMpocHUSwtyKzB4GsMsgr/xtGEl9541AMSFhR1G8y3Ffn22e6pXV7dakwU/vxcBTvdwfBplrBxxJ1zT/ieuQUrdemKCRZS4QVmaVijUpYQMaQF7iCJJPKYI+wBhxT0y0yOoVqORKg2AuRmbkTpMfKZJ5xRBLAeydMhloeKfYswDDisWIChmJlclIro0Jr80OhpohTcQGayY4fjn0A/ir+9PYzLzjGitR3a+/lfHvuTmPC9AnWv3Sp87mddUuJ10jqO9QWXNTnAVdehLiN/heJVjOMkXCstWLVUycEgnfwXEqlNMMFuRAfOwTte0Ls9qHkl3Fzo8ompJiqxF879rSwcSEYAQ==","thinking":"The user is asking for weather information for Florence, Italy. I have access to a weather function that takes a \"location\" parameter. The user has provided the location as \"Florence, Italy\" which is clear and specific. I have all the required parameters to make this function call.","type":"thinking"},{"id":"toolu_0171KFcBwn1Z64XzvQfz3sZS","input":{"location":"Florence, Italy"},"name":"weather","type":"tool_use"}],"role":"assistant"},{"content":[{"tool_use_id":"toolu_0171KFcBwn1Z64XzvQfz3sZS","content":[{"text":"40 C","type":"text"}],"type":"tool_result"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant","type":"text"}],"thinking":{"budget_tokens":4000,"type":"enabled"},"tool_choice":{"disable_parallel_tool_use":false,"type":"auto"},"tools":[{"input_schema":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"},"name":"weather","description":"Get weather information for a location"}],"stream":true}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.10.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
body: |+
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"id":"msg_013robXNGqrQ66iDpnRyejiu","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":557,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard"}} }
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"The current"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" weather in Florence, Italy is 40°C (104°F)."}}
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" That's quite hot! It appears"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" to be a very warm day there"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"."} }
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0 }
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":557,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":35}}
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop" }
|
||||
|
||||
headers:
|
||||
Content-Type:
|
||||
- text/event-stream; charset=utf-8
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 1.164435458s
|
||||
63
internal/fantasy/providertests/testdata/TestAnthropicThinking/claude-sonnet-4/thinking.yaml
vendored
Normal file
63
internal/fantasy/providertests/testdata/TestAnthropicThinking/claude-sonnet-4/thinking.yaml
vendored
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 548
|
||||
host: ""
|
||||
body: '{"max_tokens":4096,"messages":[{"content":[{"text":"What''s the weather in Florence, Italy?","type":"text"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant","type":"text"}],"thinking":{"budget_tokens":4000,"type":"enabled"},"tool_choice":{"disable_parallel_tool_use":false,"type":"auto"},"tools":[{"input_schema":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"},"name":"weather","description":"Get weather information for a location"}]}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.10.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"id":"msg_0161zGUJcy7n6Gtc3W2yEfEi","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"thinking","thinking":"The user is asking for weather information for Florence, Italy. I have access to a weather function that takes a location parameter. The user has provided the location as \"Florence, Italy\" which is specific enough for the weather function.\n\nI should call the weather function with the location parameter set to \"Florence, Italy\".","signature":"EvIDCkYICBgCKkB3OXkd+Z/weJsi/KkBdtuRG6KOJp06/IzU7Jk57DreAI4FgyT9Xqef8INOF0ENXD//orE8oncMe+M72OyRG7FQEgxPaEO4DRV1o5sFWXAaDNTMb6U1NI1BcmYAPCIwzkufbx9sEOQQxPyBOcNFCs/SugbLyRr2IlXwdFmYoDeaOm1DkRFknTct6f6A6EzxKtkCVGlLUEl4LBJeAuluxJbq3PlzewMXa7EEvsi+M4T71uUci/d0RIK2Z5NxNpgTbVqM7shsDImrqR/0gJ/6BxwEptGq1G626mA5DgyfjR0QJ0etDk8kT7iJ33H6BRMGA8DnehmhOUYGTiajWdjgD3/H0S8mVT06swDddMKynOg4sRlr8PvQBDcyDlHTVQUo5SmrOPm8JsOtLfN8nDKTdhU0NofnVVNklOKblM83YuT97HhM269IQEf6rbqKknBZSvKVJOoHkOwTCn60uQj7b1wpVTjI4KRCVnlhRBpVrVyTmveM5kZXNdL/6sHUHboPCUYPbU24aYf/BTPswY2NsbdtpIRS3uHnSyIZ0gOMZA/5pWQbNJYjL5iksP8waiwCgOPkadqo02yQvqDY6XCfVd9LkRdaUQIZOt9IaSDE+9mvGnH025qCd4/sGBy+QRd9T/uNAFmkjeG9wL4oGAE="},{"type":"tool_use","id":"toolu_018GcaTNHipGG9yeZ3srYF9d","name":"weather","input":{"location":"Florence, Italy"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":423,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":126,"service_tier":"standard"}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 3.787719958s
|
||||
- id: 1
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 1891
|
||||
host: ""
|
||||
body: '{"max_tokens":4096,"messages":[{"content":[{"text":"What''s the weather in Florence, Italy?","type":"text"}],"role":"user"},{"content":[{"signature":"EvIDCkYICBgCKkB3OXkd+Z/weJsi/KkBdtuRG6KOJp06/IzU7Jk57DreAI4FgyT9Xqef8INOF0ENXD//orE8oncMe+M72OyRG7FQEgxPaEO4DRV1o5sFWXAaDNTMb6U1NI1BcmYAPCIwzkufbx9sEOQQxPyBOcNFCs/SugbLyRr2IlXwdFmYoDeaOm1DkRFknTct6f6A6EzxKtkCVGlLUEl4LBJeAuluxJbq3PlzewMXa7EEvsi+M4T71uUci/d0RIK2Z5NxNpgTbVqM7shsDImrqR/0gJ/6BxwEptGq1G626mA5DgyfjR0QJ0etDk8kT7iJ33H6BRMGA8DnehmhOUYGTiajWdjgD3/H0S8mVT06swDddMKynOg4sRlr8PvQBDcyDlHTVQUo5SmrOPm8JsOtLfN8nDKTdhU0NofnVVNklOKblM83YuT97HhM269IQEf6rbqKknBZSvKVJOoHkOwTCn60uQj7b1wpVTjI4KRCVnlhRBpVrVyTmveM5kZXNdL/6sHUHboPCUYPbU24aYf/BTPswY2NsbdtpIRS3uHnSyIZ0gOMZA/5pWQbNJYjL5iksP8waiwCgOPkadqo02yQvqDY6XCfVd9LkRdaUQIZOt9IaSDE+9mvGnH025qCd4/sGBy+QRd9T/uNAFmkjeG9wL4oGAE=","thinking":"The user is asking for weather information for Florence, Italy. I have access to a weather function that takes a location parameter. The user has provided the location as \"Florence, Italy\" which is specific enough for the weather function.\n\nI should call the weather function with the location parameter set to \"Florence, Italy\".","type":"thinking"},{"id":"toolu_018GcaTNHipGG9yeZ3srYF9d","input":{"location":"Florence, Italy"},"name":"weather","type":"tool_use"}],"role":"assistant"},{"content":[{"tool_use_id":"toolu_018GcaTNHipGG9yeZ3srYF9d","content":[{"text":"40 C","type":"text"}],"type":"tool_result"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant","type":"text"}],"thinking":{"budget_tokens":4000,"type":"enabled"},"tool_choice":{"disable_parallel_tool_use":false,"type":"auto"},"tools":[{"input_schema":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"},"name":"weather","description":"Get weather information for a location"}]}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.10.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"id":"msg_01EpG6sx9WamuPv9k6S2aHDB","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"The current weather in Florence, Italy is 40°C (104°F). That''s quite hot! Make sure to stay hydrated and seek shade if you''re planning to be outdoors."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":564,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":43,"service_tier":"standard"}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 2.560516875s
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 636
|
||||
host: ""
|
||||
body: '{"max_tokens":4096,"messages":[{"content":[{"text":"What''s the weather in Florence, Italy?","cache_control":{"type":"ephemeral"},"type":"text"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant","cache_control":{"type":"ephemeral"},"type":"text"}],"thinking":{"budget_tokens":4000,"type":"enabled"},"tool_choice":{"disable_parallel_tool_use":false,"type":"auto"},"tools":[{"input_schema":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"},"name":"weather","description":"Get weather information for a location"}],"stream":true}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.14.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
body: |+
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"model":"claude-sonnet-4-20250514","id":"msg_018QLVWxHwCDcmbxmut1XibB","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":423,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard"}} }
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"The user is asking for"} }
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" weather information for Florence, Italy. I have"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" access to a weather"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" function that requires"}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" a location parameter. The"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" user has provided the location as \"Florence,"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" Italy\". I have"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" all the required parameters to make this function call"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"."} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"EqkDCkYICBgCKkDMkVtuKFhL43wYuAZr0LxTAfH7CxEgsPiJdcz/3isjhK8aSzupMCdAAzBvP0wbo0CoAK+d6U8my50fwC6qbKjQEgzeVwbRwVSpMC7a7eIaDDGi/DIlGKGpdo1yXSIwn6eQw8pvNZH3N0x+TKc6d8UWJ85+y+le/fWhIIv/Q/2FnNSZDEbSvLQI6thi94QwKpACRTY5D6hm8bVJ5BIHMyYBLEaxYPjsNyQ9Idf+pkFHY2TKEEvFoqo9liA9RGrb4KOaaJ3n0D6Bqclr69UzTmGs1vvrrKApGO7SleM3V13PG78eKt3QbFu7VjndhYALY2Cf4jDoW6yEHZ8fVvp61kGhyiuxQH8pPqCR8GdkSLOI8O76NHWRt4Zj1MO7LCTaqJNKJdvOc7xKSnQGNjB1Jz3OS+cclNqAhJgJn278Vc0HzIkcX6NdxSM3XjEXMRo7l1oBV04UCtWZD+REpPAt3G1Wz8MJ7mco3F+0tpGnv5nlN5J0+boOZ2o16NASQ7MBPa3a1+F4zgqiVO2TnyxLhy38zn4dfAzoJ1GKedr3UDXBMeMYAQ=="} }
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_012Y7zdiwQJuNXf7QjpkJtC6","name":"weather","input":{}} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"location"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\": "} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"Florence,"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":" Italy\"}"} }
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":1 }
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":423,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":111} }
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop" }
|
||||
|
||||
headers:
|
||||
Content-Type:
|
||||
- text/event-stream; charset=utf-8
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 1.784653959s
|
||||
- id: 1
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 1842
|
||||
host: ""
|
||||
body: '{"max_tokens":4096,"messages":[{"content":[{"text":"What''s the weather in Florence, Italy?","type":"text"}],"role":"user"},{"content":[{"signature":"EqkDCkYICBgCKkDMkVtuKFhL43wYuAZr0LxTAfH7CxEgsPiJdcz/3isjhK8aSzupMCdAAzBvP0wbo0CoAK+d6U8my50fwC6qbKjQEgzeVwbRwVSpMC7a7eIaDDGi/DIlGKGpdo1yXSIwn6eQw8pvNZH3N0x+TKc6d8UWJ85+y+le/fWhIIv/Q/2FnNSZDEbSvLQI6thi94QwKpACRTY5D6hm8bVJ5BIHMyYBLEaxYPjsNyQ9Idf+pkFHY2TKEEvFoqo9liA9RGrb4KOaaJ3n0D6Bqclr69UzTmGs1vvrrKApGO7SleM3V13PG78eKt3QbFu7VjndhYALY2Cf4jDoW6yEHZ8fVvp61kGhyiuxQH8pPqCR8GdkSLOI8O76NHWRt4Zj1MO7LCTaqJNKJdvOc7xKSnQGNjB1Jz3OS+cclNqAhJgJn278Vc0HzIkcX6NdxSM3XjEXMRo7l1oBV04UCtWZD+REpPAt3G1Wz8MJ7mco3F+0tpGnv5nlN5J0+boOZ2o16NASQ7MBPa3a1+F4zgqiVO2TnyxLhy38zn4dfAzoJ1GKedr3UDXBMeMYAQ==","thinking":"The user is asking for weather information for Florence, Italy. I have access to a weather function that requires a location parameter. The user has provided the location as \"Florence, Italy\". I have all the required parameters to make this function call.","type":"thinking"},{"id":"toolu_012Y7zdiwQJuNXf7QjpkJtC6","input":{"location":"Florence, Italy"},"name":"weather","cache_control":{"type":"ephemeral"},"type":"tool_use"}],"role":"assistant"},{"content":[{"tool_use_id":"toolu_012Y7zdiwQJuNXf7QjpkJtC6","cache_control":{"type":"ephemeral"},"content":[{"text":"40 C","type":"text"}],"type":"tool_result"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant","cache_control":{"type":"ephemeral"},"type":"text"}],"thinking":{"budget_tokens":4000,"type":"enabled"},"tool_choice":{"disable_parallel_tool_use":false,"type":"auto"},"tools":[{"input_schema":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"},"name":"weather","description":"Get weather information for a location"}],"stream":true}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.14.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
body: |+
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"model":"claude-sonnet-4-20250514","id":"msg_01RtDAbGZhKwuFUM9xy3bE72","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":549,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}} }
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"The"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" current weather in Florence, Italy is "} }
|
||||
|
||||
event: ping
|
||||
data: {"type": "ping"}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"40°C (104°F)."} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" That's quite hot! It"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"'s a very warm day in"} }
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Florence."} }
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0}
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":549,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":34} }
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop"}
|
||||
|
||||
headers:
|
||||
Content-Type:
|
||||
- text/event-stream; charset=utf-8
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 2.0182445s
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 622
|
||||
host: ""
|
||||
body: '{"max_tokens":4096,"messages":[{"content":[{"text":"What''s the weather in Florence, Italy?","cache_control":{"type":"ephemeral"},"type":"text"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant","cache_control":{"type":"ephemeral"},"type":"text"}],"thinking":{"budget_tokens":4000,"type":"enabled"},"tool_choice":{"disable_parallel_tool_use":false,"type":"auto"},"tools":[{"input_schema":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"},"name":"weather","description":"Get weather information for a location"}]}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.14.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"model":"claude-sonnet-4-20250514","id":"msg_019nCdrqNy56N4FPDPkMaz7B","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user is asking for weather information for Florence, Italy. I have a weather function available that takes a location parameter. The user has provided \"Florence, Italy\" as the location. I have all the required parameters to make the function call.","signature":"EqQDCkYICBgCKkD+6F3ZCJ3CoiNs91jnjkz/6xYefJjve+nbgY/runWHxGeBEg21iA6vjJVq7OceBDUlc+TFtcdi0WkUTiiBe0S5EgxwzS43u6Xgdl6soBAaDKzHQ0Z1Vrcz7LHSUCIwU6Qn02eS0AhhP5SpNdLRzSPWLN3oHDC3vhxs4wiNY4TkWpXa9nyIRhwUKNd5ud+uKosCJgjHGR/uLuODWUEGlJbrxAhr3xrQbciN/sDfV4tqfPOj+t5tm96khBOGNXXIO/SE3If0wlexbIGis17tQxnH6z0MRB7SiRzS6CjWzMK361uwv2pgUgy9lHKsbARcjtFCaCCrFpFLPO0lySJK0D60RmPO6DmFfkamYAcCT1870iZrI+RB0Hu7xbWnoZ3VURmxBv+VxX7cRll6zfcgOhTVVtVFdgghuuHujtyPMMau3HiZUJVv4juzrSNCIS15LP2RfCFGJ6Xes6hoWXeGcVQOeJmbJ1s3E2prELzM31wkeEE26ftwfF7VF4yCt62ocEuc07zsycVKRRZEDBe9CEb9Ny+HW4dbrEX/DMvPGAE="},{"type":"tool_use","id":"toolu_014gjpHAaavY4696WA2WGDLS","name":"weather","input":{"location":"Florence, Italy"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":423,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":111,"service_tier":"standard"}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 3.529551375s
|
||||
- id: 1
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 1816
|
||||
host: ""
|
||||
body: '{"max_tokens":4096,"messages":[{"content":[{"text":"What''s the weather in Florence, Italy?","type":"text"}],"role":"user"},{"content":[{"signature":"EqQDCkYICBgCKkD+6F3ZCJ3CoiNs91jnjkz/6xYefJjve+nbgY/runWHxGeBEg21iA6vjJVq7OceBDUlc+TFtcdi0WkUTiiBe0S5EgxwzS43u6Xgdl6soBAaDKzHQ0Z1Vrcz7LHSUCIwU6Qn02eS0AhhP5SpNdLRzSPWLN3oHDC3vhxs4wiNY4TkWpXa9nyIRhwUKNd5ud+uKosCJgjHGR/uLuODWUEGlJbrxAhr3xrQbciN/sDfV4tqfPOj+t5tm96khBOGNXXIO/SE3If0wlexbIGis17tQxnH6z0MRB7SiRzS6CjWzMK361uwv2pgUgy9lHKsbARcjtFCaCCrFpFLPO0lySJK0D60RmPO6DmFfkamYAcCT1870iZrI+RB0Hu7xbWnoZ3VURmxBv+VxX7cRll6zfcgOhTVVtVFdgghuuHujtyPMMau3HiZUJVv4juzrSNCIS15LP2RfCFGJ6Xes6hoWXeGcVQOeJmbJ1s3E2prELzM31wkeEE26ftwfF7VF4yCt62ocEuc07zsycVKRRZEDBe9CEb9Ny+HW4dbrEX/DMvPGAE=","thinking":"The user is asking for weather information for Florence, Italy. I have a weather function available that takes a location parameter. The user has provided \"Florence, Italy\" as the location. I have all the required parameters to make the function call.","type":"thinking"},{"id":"toolu_014gjpHAaavY4696WA2WGDLS","input":{"location":"Florence, Italy"},"name":"weather","cache_control":{"type":"ephemeral"},"type":"tool_use"}],"role":"assistant"},{"content":[{"tool_use_id":"toolu_014gjpHAaavY4696WA2WGDLS","cache_control":{"type":"ephemeral"},"content":[{"text":"40 C","type":"text"}],"type":"tool_result"}],"role":"user"}],"model":"claude-sonnet-4-20250514","system":[{"text":"You are a helpful assistant","cache_control":{"type":"ephemeral"},"type":"text"}],"thinking":{"budget_tokens":4000,"type":"enabled"},"tool_choice":{"disable_parallel_tool_use":false,"type":"auto"},"tools":[{"input_schema":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"},"name":"weather","description":"Get weather information for a location"}]}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- Anthropic/Go 1.14.0
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"model":"claude-sonnet-4-20250514","id":"msg_011vbhawoMAwZzuGJEzTmfaK","type":"message","role":"assistant","content":[{"type":"text","text":"The current weather in Florence, Italy is 40°C (104°F). That''s quite hot! It''s a very warm day in Florence."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":549,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":34,"service_tier":"standard"}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 2.484467584s
|
||||
33
internal/fantasy/providertests/testdata/TestAzureCommon/azure-gpt-5-mini/simple.yaml
vendored
Normal file
33
internal/fantasy/providertests/testdata/TestAzureCommon/azure-gpt-5-mini/simple.yaml
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 171
|
||||
host: ""
|
||||
body: '{"messages":[{"content":"You are a helpful assistant","role":"system"},{"content":"Say hi in Portuguese","role":"user"}],"model":"gpt-5-mini","max_completion_tokens":4000}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- OpenAI/Go 2.7.1
|
||||
url: https://fantasy-playground-resource.openai.azure.com/openai/v1/chat/completions
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: 1205
|
||||
body: |
|
||||
{"choices":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"protected_material_code":{"filtered":false,"detected":false},"protected_material_text":{"filtered":false,"detected":false},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"finish_reason":"stop","index":0,"logprobs":null,"message":{"annotations":[],"content":"Olá!","refusal":null,"role":"assistant"}}],"created":1761842954,"id":"chatcmpl-CWQ7mNYuvWWQimXnUMuiLAxVCoNRW","model":"gpt-5-mini-2025-08-07","object":"chat.completion","prompt_filter_results":[{"prompt_index":0,"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"jailbreak":{"filtered":false,"detected":false},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}}}],"system_fingerprint":null,"usage":{"completion_tokens":76,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":64,"rejected_prediction_tokens":0},"prompt_tokens":19,"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0},"total_tokens":95}}
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 2.767307375s
|
||||
62
internal/fantasy/providertests/testdata/TestAzureCommon/azure-gpt-5-mini/simple_streaming.yaml
vendored
Normal file
62
internal/fantasy/providertests/testdata/TestAzureCommon/azure-gpt-5-mini/simple_streaming.yaml
vendored
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 225
|
||||
host: ""
|
||||
body: '{"messages":[{"content":"You are a helpful assistant","role":"system"},{"content":"Say hi in Portuguese","role":"user"}],"model":"gpt-5-mini","max_completion_tokens":4000,"stream_options":{"include_usage":true},"stream":true}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- OpenAI/Go 2.7.1
|
||||
url: https://fantasy-playground-resource.openai.azure.com/openai/v1/chat/completions
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
body: |+
|
||||
data: {"choices":[],"created":0,"id":"","model":"","object":"","prompt_filter_results":[{"prompt_index":0,"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"jailbreak":{"filtered":false,"detected":false},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}}}]}
|
||||
|
||||
data: {"choices":[{"content_filter_results":{},"delta":{"content":"","refusal":null,"role":"assistant"},"finish_reason":null,"index":0,"logprobs":null}],"created":1761842957,"id":"chatcmpl-CWQ7puA20Xq1q7lREx3It7GAIshNJ","model":"gpt-5-mini-2025-08-07","obfuscation":"zV0z0Nhm4vBJPe","object":"chat.completion.chunk","system_fingerprint":null,"usage":null}
|
||||
|
||||
data: {"choices":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":"Olá"},"finish_reason":null,"index":0,"logprobs":null}],"created":1761842957,"id":"chatcmpl-CWQ7puA20Xq1q7lREx3It7GAIshNJ","model":"gpt-5-mini-2025-08-07","obfuscation":"bAjQTaqR0x0H0","object":"chat.completion.chunk","system_fingerprint":null,"usage":null}
|
||||
|
||||
data: {"choices":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":"!"},"finish_reason":null,"index":0,"logprobs":null}],"created":1761842957,"id":"chatcmpl-CWQ7puA20Xq1q7lREx3It7GAIshNJ","model":"gpt-5-mini-2025-08-07","obfuscation":"5SfhBKVU1ZExC0w","object":"chat.completion.chunk","system_fingerprint":null,"usage":null}
|
||||
|
||||
data: {"choices":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":" ("},"finish_reason":null,"index":0,"logprobs":null}],"created":1761842957,"id":"chatcmpl-CWQ7puA20Xq1q7lREx3It7GAIshNJ","model":"gpt-5-mini-2025-08-07","obfuscation":"WkSeDVRMxOyjtP","object":"chat.completion.chunk","system_fingerprint":null,"usage":null}
|
||||
|
||||
data: {"choices":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":"tamb"},"finish_reason":null,"index":0,"logprobs":null}],"created":1761842957,"id":"chatcmpl-CWQ7puA20Xq1q7lREx3It7GAIshNJ","model":"gpt-5-mini-2025-08-07","obfuscation":"pNiSihK5iZcs","object":"chat.completion.chunk","system_fingerprint":null,"usage":null}
|
||||
|
||||
data: {"choices":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":"ém"},"finish_reason":null,"index":0,"logprobs":null}],"created":1761842957,"id":"chatcmpl-CWQ7puA20Xq1q7lREx3It7GAIshNJ","model":"gpt-5-mini-2025-08-07","obfuscation":"xECBxJwCJ42Ktg","object":"chat.completion.chunk","system_fingerprint":null,"usage":null}
|
||||
|
||||
data: {"choices":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":" pode"},"finish_reason":null,"index":0,"logprobs":null}],"created":1761842957,"id":"chatcmpl-CWQ7puA20Xq1q7lREx3It7GAIshNJ","model":"gpt-5-mini-2025-08-07","obfuscation":"j5vLZXmCrsl","object":"chat.completion.chunk","system_fingerprint":null,"usage":null}
|
||||
|
||||
data: {"choices":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":" dizer"},"finish_reason":null,"index":0,"logprobs":null}],"created":1761842957,"id":"chatcmpl-CWQ7puA20Xq1q7lREx3It7GAIshNJ","model":"gpt-5-mini-2025-08-07","obfuscation":"f6uQzkBbn5","object":"chat.completion.chunk","system_fingerprint":null,"usage":null}
|
||||
|
||||
data: {"choices":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":" \""},"finish_reason":null,"index":0,"logprobs":null}],"created":1761842957,"id":"chatcmpl-CWQ7puA20Xq1q7lREx3It7GAIshNJ","model":"gpt-5-mini-2025-08-07","obfuscation":"4OGZk82dzZvaF","object":"chat.completion.chunk","system_fingerprint":null,"usage":null}
|
||||
|
||||
data: {"choices":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":"Oi"},"finish_reason":null,"index":0,"logprobs":null}],"created":1761842957,"id":"chatcmpl-CWQ7puA20Xq1q7lREx3It7GAIshNJ","model":"gpt-5-mini-2025-08-07","obfuscation":"aY4hMT7w5iE0gy","object":"chat.completion.chunk","system_fingerprint":null,"usage":null}
|
||||
|
||||
data: {"choices":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":"!\")"},"finish_reason":null,"index":0,"logprobs":null}],"created":1761842957,"id":"chatcmpl-CWQ7puA20Xq1q7lREx3It7GAIshNJ","model":"gpt-5-mini-2025-08-07","obfuscation":"bX0oyMMZTDqs","object":"chat.completion.chunk","system_fingerprint":null,"usage":null}
|
||||
|
||||
data: {"choices":[{"content_filter_results":{},"delta":{},"finish_reason":"stop","index":0,"logprobs":null}],"created":1761842957,"id":"chatcmpl-CWQ7puA20Xq1q7lREx3It7GAIshNJ","model":"gpt-5-mini-2025-08-07","obfuscation":"IkpictBD1v","object":"chat.completion.chunk","system_fingerprint":null,"usage":null}
|
||||
|
||||
data: {"choices":[],"created":1761842957,"id":"chatcmpl-CWQ7puA20Xq1q7lREx3It7GAIshNJ","model":"gpt-5-mini-2025-08-07","obfuscation":"MQpC96hAsnU","object":"chat.completion.chunk","system_fingerprint":null,"usage":{"completion_tokens":148,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":128,"rejected_prediction_tokens":0},"prompt_tokens":19,"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0},"total_tokens":167}}
|
||||
|
||||
data: [DONE]
|
||||
|
||||
headers:
|
||||
Content-Type:
|
||||
- text/event-stream; charset=utf-8
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 2.602852625s
|
||||
63
internal/fantasy/providertests/testdata/TestAzureCommon/azure-gpt-5-mini/tool.yaml
vendored
Normal file
63
internal/fantasy/providertests/testdata/TestAzureCommon/azure-gpt-5-mini/tool.yaml
vendored
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 463
|
||||
host: ""
|
||||
body: '{"messages":[{"content":"You are a helpful assistant","role":"system"},{"content":"What''s the weather in Florence,Italy?","role":"user"}],"model":"gpt-5-mini","max_completion_tokens":4000,"tool_choice":"auto","tools":[{"function":{"name":"weather","strict":false,"description":"Get weather information for a location","parameters":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"}},"type":"function"}]}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- OpenAI/Go 2.7.1
|
||||
url: https://fantasy-playground-resource.openai.azure.com/openai/v1/chat/completions
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: 1050
|
||||
body: |
|
||||
{"choices":[{"content_filter_results":{},"finish_reason":"tool_calls","index":0,"logprobs":null,"message":{"annotations":[],"content":null,"refusal":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{\"location\":\"Florence, Italy\"}","name":"weather"},"id":"call_Qfrepi6GMFE2a9QE9JiXptZt","type":"function"}]}}],"created":1761842959,"id":"chatcmpl-CWQ7rrdJOXqm8pE5y29e3exiAHJe5","model":"gpt-5-mini-2025-08-07","object":"chat.completion","prompt_filter_results":[{"prompt_index":0,"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"jailbreak":{"filtered":false,"detected":false},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}}}],"system_fingerprint":null,"usage":{"completion_tokens":26,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":0,"rejected_prediction_tokens":0},"prompt_tokens":145,"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0},"total_tokens":171}}
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 2.042717709s
|
||||
- id: 1
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 715
|
||||
host: ""
|
||||
body: '{"messages":[{"content":"You are a helpful assistant","role":"system"},{"content":"What''s the weather in Florence,Italy?","role":"user"},{"tool_calls":[{"id":"call_Qfrepi6GMFE2a9QE9JiXptZt","function":{"arguments":"{\"location\":\"Florence, Italy\"}","name":"weather"},"type":"function"}],"role":"assistant"},{"content":"40 C","tool_call_id":"call_Qfrepi6GMFE2a9QE9JiXptZt","role":"tool"}],"model":"gpt-5-mini","max_completion_tokens":4000,"tool_choice":"auto","tools":[{"function":{"name":"weather","strict":false,"description":"Get weather information for a location","parameters":{"properties":{"location":{"description":"the city","type":"string"}},"required":["location"],"type":"object"}},"type":"function"}]}'
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
Content-Type:
|
||||
- application/json
|
||||
User-Agent:
|
||||
- OpenAI/Go 2.7.1
|
||||
url: https://fantasy-playground-resource.openai.azure.com/openai/v1/chat/completions
|
||||
method: POST
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: 1434
|
||||
body: |
|
||||
{"choices":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"protected_material_text":{"filtered":false,"detected":false},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"finish_reason":"stop","index":0,"logprobs":null,"message":{"annotations":[],"content":"Current temperature in Florence, Italy: 40 °C (104 °F).\n\nThat's very hot — consider staying hydrated, avoiding prolonged sun exposure or strenuous outdoor activity during midday, and checking local heat warnings. Would you like an hourly forecast, a 7-day outlook, or any other details?","refusal":null,"role":"assistant"}}],"created":1761842961,"id":"chatcmpl-CWQ7t0TV6CZZN7q9HDxlkG8FMHbTN","model":"gpt-5-mini-2025-08-07","object":"chat.completion","prompt_filter_results":[{"prompt_index":0,"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"jailbreak":{"filtered":false,"detected":false},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}}}],"system_fingerprint":null,"usage":{"completion_tokens":261,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":192,"rejected_prediction_tokens":0},"prompt_tokens":176,"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0},"total_tokens":437}}
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 6.188168959s
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue