diff options
| -rw-r--r-- | go.mod | 36 | ||||
| -rw-r--r-- | go.sum | 93 | ||||
| -rw-r--r-- | internal/apicircuit/apicircuit.go | 92 | ||||
| -rw-r--r-- | internal/apicircuit/apicircuit_test.go | 40 | ||||
| -rw-r--r-- | internal/config/config.go | 327 | ||||
| -rw-r--r-- | internal/config/config_test.go | 148 | ||||
| -rw-r--r-- | internal/config/home.go | 22 | ||||
| -rw-r--r-- | internal/httpctx/httpctx.go | 79 | ||||
| -rw-r--r-- | internal/httpctx/httpctx_test.go | 81 | ||||
| -rw-r--r-- | internal/provider/provider.go | 72 | ||||
| -rw-r--r-- | internal/provider/provider_test.go | 22 | ||||
| -rw-r--r-- | internal/vocab/reader.go | 111 | ||||
| -rw-r--r-- | internal/vocab/reader_test.go | 124 |
13 files changed, 1247 insertions, 0 deletions
@@ -4,10 +4,46 @@ go 1.25.0 require ( github.com/magefile/mage v1.15.0 + github.com/sony/gobreaker v1.0.0 github.com/spf13/cobra v1.9.1 + github.com/spf13/viper v1.20.1 + google.golang.org/genai v1.52.1 ) require ( + cloud.google.com/go v0.116.0 // indirect + cloud.google.com/go/auth v0.13.0 // indirect + cloud.google.com/go/compute/metadata v0.6.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/s2a-go v0.1.8 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect + github.com/googleapis/gax-go/v2 v2.14.1 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/sagikazarmark/locafero v0.7.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.12.0 // indirect + github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect + go.opentelemetry.io/otel v1.29.0 // indirect + go.opentelemetry.io/otel/metric v1.29.0 // indirect + go.opentelemetry.io/otel/trace v1.29.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect + google.golang.org/grpc v1.67.3 // indirect + google.golang.org/protobuf v1.36.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) @@ -1,12 +1,105 @@ +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.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs= +cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q= +cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= +cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/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.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= +github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= +github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= +github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= +github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= +github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= +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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +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/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +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.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= +github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= +github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +google.golang.org/genai v1.52.1 h1:dYoljKtLDXMiBdVaClSJ/ZPwZ7j1N0lGjMhwOKOQUlk= +google.golang.org/genai v1.52.1/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 h1:TqExAhdPaB60Ux47Cn0oLV07rGnxZzIsaRhQaqS666A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8/go.mod h1:lcTa1sDdWEIHMWlITnIczmw5w60CF9ffkb8Z+DVmmjA= +google.golang.org/grpc v1.67.3 h1:OgPcDAFKHnH8X3O4WcO4XUc8GRDeKsKReqbQtiCj7N8= +google.golang.org/grpc v1.67.3/go.mod h1:YGaHCc6Oap+FzBJTZLBzkGSYt/cvGPFTPxkn7QfSU8s= +google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= +google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/apicircuit/apicircuit.go b/internal/apicircuit/apicircuit.go new file mode 100644 index 0000000..05081e5 --- /dev/null +++ b/internal/apicircuit/apicircuit.go @@ -0,0 +1,92 @@ +// Package apicircuit wraps outbound Gemini API calls with sony/gobreaker +// circuit breakers so repeated failures do not pile up unbounded work. +package apicircuit + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/sony/gobreaker" +) + +const ( + // breakerInterval clears rolling failure counts in the closed state so stale + // errors do not keep the breaker sensitive forever. + breakerInterval = 2 * time.Minute + // breakerOpenTimeout is how long the breaker stays open before trying half-open. + breakerOpenTimeout = 45 * time.Second + // breakerMaxHalfOpenRequests limits trial traffic while recovering. + breakerMaxHalfOpenRequests = 3 + // breakerTripAfterConsecutiveFailures opens the circuit after this many + // consecutive failed requests in the closed state. + breakerTripAfterConsecutiveFailures uint32 = 5 +) + +var ( + geminiTTSOnce sync.Once + geminiTTSBreaker *gobreaker.CircuitBreaker + geminiImageOnce sync.Once + geminiImageBreaker *gobreaker.CircuitBreaker +) + +// isSuccessful counts only real API outcomes: nil is success; context.Canceled is +// treated as success so user abort does not trip the breaker. Timeouts and +// remote errors still count as failures. +func isSuccessful(err error) bool { + if err == nil { + return true + } + + return errors.Is(err, context.Canceled) +} + +func readyToTrip(counts gobreaker.Counts) bool { + return counts.ConsecutiveFailures >= breakerTripAfterConsecutiveFailures +} + +func newBreaker(name string) *gobreaker.CircuitBreaker { + return gobreaker.NewCircuitBreaker(gobreaker.Settings{ + Name: name, + MaxRequests: breakerMaxHalfOpenRequests, + Interval: breakerInterval, + Timeout: breakerOpenTimeout, + ReadyToTrip: readyToTrip, + IsSuccessful: isSuccessful, + }) +} + +func geminiBreaker(name string, slot **gobreaker.CircuitBreaker, once *sync.Once) *gobreaker.CircuitBreaker { + once.Do(func() { + *slot = newBreaker(name) + }) + + return *slot +} + +func runValue[T any](cb *gobreaker.CircuitBreaker, fn func() (T, error)) (T, error) { + var zero T + + v, err := cb.Execute(func() (interface{}, error) { + return fn() + }) + if err != nil { + return zero, err + } + if v == nil { + return zero, nil + } + + return v.(T), nil +} + +// GeminiTTS runs one Gemini TTS GenerateContent call through its circuit breaker. +func GeminiTTS[T any](fn func() (T, error)) (T, error) { + return runValue(geminiBreaker("gemini-tts", &geminiTTSBreaker, &geminiTTSOnce), fn) +} + +// GeminiImage runs one Gemini image-generation call through its circuit breaker. +func GeminiImage[T any](fn func() (T, error)) (T, error) { + return runValue(geminiBreaker("gemini-image", &geminiImageBreaker, &geminiImageOnce), fn) +} diff --git a/internal/apicircuit/apicircuit_test.go b/internal/apicircuit/apicircuit_test.go new file mode 100644 index 0000000..842a0f2 --- /dev/null +++ b/internal/apicircuit/apicircuit_test.go @@ -0,0 +1,40 @@ +package apicircuit + +import ( + "context" + "errors" + "testing" +) + +func TestGeminiTTS_Success(t *testing.T) { + t.Parallel() + + v, err := GeminiTTS(func() (string, error) { + return "ok", nil + }) + if err != nil || v != "ok" { + t.Fatalf("GeminiTTS() = %q, %v; want ok, nil", v, err) + } +} + +func TestGeminiImage_Success(t *testing.T) { + t.Parallel() + + v, err := GeminiImage(func() (int, error) { + return 42, nil + }) + if err != nil || v != 42 { + t.Fatalf("GeminiImage() = %d, %v; want 42, nil", v, err) + } +} + +func TestIsSuccessful_ContextCanceled(t *testing.T) { + t.Parallel() + + if !isSuccessful(context.Canceled) { + t.Fatal("context.Canceled should not count as breaker failure") + } + if isSuccessful(errors.New("api error")) { + t.Fatal("arbitrary errors must count as failure") + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..9267ff0 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,327 @@ +// Package config provides application configuration loading and prompt template +// helpers for ComicForge. +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/spf13/viper" + + "codeberg.org/snonux/comicforge/internal/provider" +) + +const ( + // DefaultPromptsDir is the fallback directory for Go template prompt files. + DefaultPromptsDir = "./prompts" +) + +// Config holds ComicForge settings loaded from YAML, environment variables, and defaults. +type Config struct { + Provider ProviderConfig `mapstructure:"provider" yaml:"provider"` + API APIConfig `mapstructure:"api" yaml:"api"` + Models ModelConfig `mapstructure:"models" yaml:"models"` + Comic ComicConfig `mapstructure:"comic" yaml:"comic"` + Language LanguageConfig `mapstructure:"language" yaml:"language"` + Story StoryConfig `mapstructure:"story" yaml:"story"` + Styles StyleConfig `mapstructure:"styles" yaml:"styles"` + Narration NarrationConfig `mapstructure:"narration" yaml:"narration"` + + PromptsDir string `mapstructure:"prompts_dir" yaml:"prompts_dir"` +} + +var ( + _ provider.TextConfig = (*Config)(nil) + _ provider.ImageConfig = (*Config)(nil) + _ provider.TTSConfig = (*Config)(nil) +) + +// ProviderConfig stores the selected provider name for each capability. +type ProviderConfig struct { + Text string `mapstructure:"text" yaml:"text"` + Image string `mapstructure:"image" yaml:"image"` + TTS string `mapstructure:"tts" yaml:"tts"` +} + +// APIConfig stores API keys and related secrets. +type APIConfig struct { + GoogleAPIKey string `mapstructure:"google_api_key" yaml:"google_api_key"` +} + +// ModelConfig stores the model IDs used by each capability. +type ModelConfig struct { + Text string `mapstructure:"text" yaml:"text"` + Image string `mapstructure:"image" yaml:"image"` + ImageText string `mapstructure:"image_text" yaml:"image_text"` + TTS string `mapstructure:"tts" yaml:"tts"` +} + +// ComicConfig stores comic generation knobs. +type ComicConfig struct { + StoryPages int `mapstructure:"story_pages" yaml:"story_pages"` + GalleryPages int `mapstructure:"gallery_pages" yaml:"gallery_pages"` + PanelsPerPage int `mapstructure:"panels_per_page" yaml:"panels_per_page"` + AspectRatio string `mapstructure:"aspect_ratio" yaml:"aspect_ratio"` + PromptMaxChars int `mapstructure:"prompt_max_chars" yaml:"prompt_max_chars"` + PageMaxRetries int `mapstructure:"page_max_retries" yaml:"page_max_retries"` + PageRetryBaseSeconds int `mapstructure:"page_retry_base_seconds" yaml:"page_retry_base_seconds"` +} + +// LanguageConfig stores language and script labels used by prompts. +type LanguageConfig struct { + Input string `mapstructure:"input" yaml:"input"` + Output string `mapstructure:"output" yaml:"output"` + Story string `mapstructure:"story_language" yaml:"story_language"` + Script string `mapstructure:"script" yaml:"script"` +} + +// StoryConfig stores story prompt knobs. +type StoryConfig struct { + Genres []string `mapstructure:"genres" yaml:"genres"` + RealisticWeight float64 `mapstructure:"realistic_weight" yaml:"realistic_weight"` +} + +// StyleConfig stores prompt style pools. +type StyleConfig struct { + Comic []string `mapstructure:"comic" yaml:"comic"` + Realistic []string `mapstructure:"realistic" yaml:"realistic"` +} + +// NarrationConfig stores narration prompt knobs. +type NarrationConfig struct { + Voices []string `mapstructure:"voices" yaml:"voices"` + ChunkWords int `mapstructure:"chunk_words" yaml:"chunk_words"` +} + +// DefaultConfig returns a configuration populated with the initial Gemini-first defaults. +func DefaultConfig() *Config { + return &Config{ + Provider: ProviderConfig{ + Text: provider.Gemini, + Image: provider.Gemini, + TTS: provider.Gemini, + }, + API: APIConfig{}, + Models: ModelConfig{ + Text: "gemini-2.5-flash", + Image: "gemini-3.1-flash-image-preview", + ImageText: "gemini-2.5-flash", + TTS: "gemini-2.5-flash-preview-tts", + }, + Comic: ComicConfig{ + StoryPages: 5, + GalleryPages: 5, + PanelsPerPage: 4, + AspectRatio: "16:9", + PromptMaxChars: 900, + PageMaxRetries: 5, + PageRetryBaseSeconds: 15, + }, + Language: LanguageConfig{ + Input: "Vocabulary", + Output: "Story", + Story: "Story", + Script: "Latin", + }, + Story: StoryConfig{ + Genres: []string{ + "a warm slice-of-life story", + "a heartfelt family drama", + "an exciting science-fiction adventure", + }, + RealisticWeight: 0.4, + }, + Styles: StyleConfig{ + Comic: []string{ + "classic comic book with bold ink outlines", + "graphic novel with dramatic shadows", + }, + Realistic: []string{ + "ultra-realistic DSLR photography, cinematic 35mm lens", + "cinematic realism with natural light", + }, + }, + Narration: NarrationConfig{ + Voices: []string{ + "Charon", + "Fenrir", + }, + ChunkWords: 100, + }, + PromptsDir: DefaultPromptsDir, + } +} + +// Load reads configuration from YAML, environment variables, and defaults. +func Load(configPath string) (*Config, error) { + cfg := DefaultConfig() + + v := viper.New() + v.SetEnvPrefix("COMICFORGE") + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + v.AutomaticEnv() + v.SetConfigType("yaml") + setDefaults(v, cfg) + + if configPath != "" { + v.SetConfigFile(configPath) + } else { + if homeDir, err := HomeDir(); err == nil { + v.AddConfigPath(filepath.Join(homeDir, ".config", "comicforge")) + v.AddConfigPath(homeDir) + } + v.AddConfigPath(".") + v.SetConfigName("config") + } + + if err := v.ReadInConfig(); err != nil { + var notFound viper.ConfigFileNotFoundError + if configPath != "" || !errors.As(err, ¬Found) { + return nil, fmt.Errorf("read config: %w", err) + } + } + + if err := v.Unmarshal(cfg); err != nil { + return nil, fmt.Errorf("decode config: %w", err) + } + + cfg.normalize() + if err := cfg.validate(); err != nil { + return nil, err + } + return cfg, nil +} + +// TextProviderName returns the configured text provider name. +func (c *Config) TextProviderName() string { + return provider.NormalizeName(c.Provider.Text) +} + +// ImageProviderName returns the configured image provider name. +func (c *Config) ImageProviderName() string { + return provider.NormalizeName(c.Provider.Image) +} + +// TTSProviderName returns the configured TTS provider name. +func (c *Config) TTSProviderName() string { + return provider.NormalizeName(c.Provider.TTS) +} + +// PromptDir returns the configured prompts directory or the default fallback. +func (c *Config) PromptDir() string { + if c == nil || strings.TrimSpace(c.PromptsDir) == "" { + return DefaultPromptsDir + } + return strings.TrimSpace(c.PromptsDir) +} + +// PromptPath joins the configured prompts directory with the requested template file. +func (c *Config) PromptPath(name string) string { + return filepath.Join(c.PromptDir(), name) +} + +// LoadPromptTemplate parses a Go text/template prompt file from the configured prompts directory. +func (c *Config) LoadPromptTemplate(name string) (*template.Template, error) { + path := c.PromptPath(name) + content, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read prompt %q: %w", path, err) + } + + tmpl, err := template.New(filepath.Base(path)).Option("missingkey=error").Parse(string(content)) + if err != nil { + return nil, fmt.Errorf("parse prompt %q: %w", path, err) + } + + return tmpl, nil +} + +// RenderPrompt executes a prompt template with the provided data. +func (c *Config) RenderPrompt(name string, data any) (string, error) { + tmpl, err := c.LoadPromptTemplate(name) + if err != nil { + return "", err + } + + var builder strings.Builder + if err := tmpl.Execute(&builder, data); err != nil { + return "", fmt.Errorf("render prompt %q: %w", name, err) + } + + return builder.String(), nil +} + +func (c *Config) normalize() { + c.Provider.Text = provider.NormalizeName(c.Provider.Text) + c.Provider.Image = provider.NormalizeName(c.Provider.Image) + c.Provider.TTS = provider.NormalizeName(c.Provider.TTS) + + if c.Provider.Text == "" { + c.Provider.Text = provider.Gemini + } + if c.Provider.Image == "" { + c.Provider.Image = provider.Gemini + } + if c.Provider.TTS == "" { + c.Provider.TTS = provider.Gemini + } + + if c.PromptsDir == "" { + c.PromptsDir = DefaultPromptsDir + } +} + +func (c *Config) validate() error { + if !provider.IsKnownName(c.Provider.Text) { + return fmt.Errorf("unknown text provider: %s", c.Provider.Text) + } + if !provider.IsKnownName(c.Provider.Image) { + return fmt.Errorf("unknown image provider: %s", c.Provider.Image) + } + if !provider.IsKnownName(c.Provider.TTS) { + return fmt.Errorf("unknown TTS provider: %s", c.Provider.TTS) + } + + return nil +} + +func setDefaults(v *viper.Viper, cfg *Config) { + v.SetDefault("provider.text", cfg.Provider.Text) + v.SetDefault("provider.image", cfg.Provider.Image) + v.SetDefault("provider.tts", cfg.Provider.TTS) + + v.SetDefault("api.google_api_key", cfg.API.GoogleAPIKey) + + v.SetDefault("models.text", cfg.Models.Text) + v.SetDefault("models.image", cfg.Models.Image) + v.SetDefault("models.image_text", cfg.Models.ImageText) + v.SetDefault("models.tts", cfg.Models.TTS) + + v.SetDefault("comic.story_pages", cfg.Comic.StoryPages) + v.SetDefault("comic.gallery_pages", cfg.Comic.GalleryPages) + v.SetDefault("comic.panels_per_page", cfg.Comic.PanelsPerPage) + v.SetDefault("comic.aspect_ratio", cfg.Comic.AspectRatio) + v.SetDefault("comic.prompt_max_chars", cfg.Comic.PromptMaxChars) + v.SetDefault("comic.page_max_retries", cfg.Comic.PageMaxRetries) + v.SetDefault("comic.page_retry_base_seconds", cfg.Comic.PageRetryBaseSeconds) + + v.SetDefault("language.input", cfg.Language.Input) + v.SetDefault("language.output", cfg.Language.Output) + v.SetDefault("language.story_language", cfg.Language.Story) + v.SetDefault("language.script", cfg.Language.Script) + + v.SetDefault("story.genres", cfg.Story.Genres) + v.SetDefault("story.realistic_weight", cfg.Story.RealisticWeight) + + v.SetDefault("styles.comic", cfg.Styles.Comic) + v.SetDefault("styles.realistic", cfg.Styles.Realistic) + + v.SetDefault("narration.voices", cfg.Narration.Voices) + v.SetDefault("narration.chunk_words", cfg.Narration.ChunkWords) + + v.SetDefault("prompts_dir", cfg.PromptsDir) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..d0a4efe --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,148 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "codeberg.org/snonux/comicforge/internal/provider" +) + +func TestLoadReturnsDefaultsWhenConfigMissing(t *testing.T) { + t.Parallel() + + cfg, err := Load("") + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if got, want := cfg.Provider.Text, provider.Gemini; got != want { + t.Fatalf("Provider.Text = %q, want %q", got, want) + } + if got, want := cfg.PromptsDir, DefaultPromptsDir; got != want { + t.Fatalf("PromptsDir = %q, want %q", got, want) + } +} + +func TestLoadReadsFileAndEnvOverrides(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + if err := os.WriteFile(configPath, []byte(strings.TrimSpace(` +provider: + text: openai + image: openai + tts: openai +prompts_dir: ./custom-prompts +comic: + story_pages: 7 +`)), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + t.Setenv("COMICFORGE_PROVIDER_TEXT", "gemini") + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if got, want := cfg.Provider.Text, provider.Gemini; got != want { + t.Fatalf("Provider.Text = %q, want %q", got, want) + } + if got, want := cfg.Provider.Image, provider.OpenAI; got != want { + t.Fatalf("Provider.Image = %q, want %q", got, want) + } + if got, want := cfg.Comic.StoryPages, 7; got != want { + t.Fatalf("Comic.StoryPages = %d, want %d", got, want) + } + if got, want := cfg.PromptsDir, "./custom-prompts"; got != want { + t.Fatalf("PromptsDir = %q, want %q", got, want) + } +} + +func TestRenderPrompt(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + cfg := DefaultConfig() + cfg.PromptsDir = tmpDir + + if err := os.WriteFile(filepath.Join(tmpDir, "story.md"), []byte("{{.Word}} -> {{.Translation}}"), 0o644); err != nil { + t.Fatalf("write prompt: %v", err) + } + + got, err := cfg.RenderPrompt("story.md", map[string]string{ + "Word": "ябълка", + "Translation": "apple", + }) + if err != nil { + t.Fatalf("RenderPrompt() error = %v", err) + } + if got != "ябълка -> apple" { + t.Fatalf("RenderPrompt() = %q, want %q", got, "ябълка -> apple") + } +} + +func TestRenderPromptMissingKeyReturnsError(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + cfg := DefaultConfig() + cfg.PromptsDir = tmpDir + + if err := os.WriteFile(filepath.Join(tmpDir, "story.md"), []byte("{{.Word}} -> {{.Translation}}"), 0o644); err != nil { + t.Fatalf("write prompt: %v", err) + } + + _, err := cfg.RenderPrompt("story.md", map[string]string{"Word": "ябълка"}) + if err == nil { + t.Fatal("RenderPrompt() error = nil, want error") + } + if !strings.Contains(err.Error(), "render prompt") { + t.Fatalf("RenderPrompt() error = %v, want wrapped render error", err) + } +} + +func TestLoadRejectsUnknownProvider(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + if err := os.WriteFile(configPath, []byte(strings.TrimSpace(` +provider: + text: mystery +`)), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + _, err := Load(configPath) + if err == nil { + t.Fatal("Load() error = nil, want error") + } + if !strings.Contains(err.Error(), "unknown text provider") { + t.Fatalf("Load() error = %v, want unknown provider error", err) + } +} + +func TestHomeDirReturnsFallbackWhenResolutionFails(t *testing.T) { + t.Parallel() + + oldUserHomeDir := userHomeDir + t.Cleanup(func() { + userHomeDir = oldUserHomeDir + }) + + userHomeDir = func() (string, error) { + return "", errors.New("boom") + } + + homeDir, err := HomeDir() + if err == nil { + t.Fatal("HomeDir() error = nil, want error") + } + if homeDir != "." { + t.Fatalf("HomeDir() homeDir = %q, want %q", homeDir, ".") + } +} diff --git a/internal/config/home.go b/internal/config/home.go new file mode 100644 index 0000000..8089b6e --- /dev/null +++ b/internal/config/home.go @@ -0,0 +1,22 @@ +package config + +import ( + "fmt" + "os" +) + +var userHomeDir = os.UserHomeDir + +// HomeDir returns the user's home directory. +// +// It falls back to "." when the home directory cannot be resolved so callers +// can still build a safe relative path instead of joining against an empty +// string. +func HomeDir() (string, error) { + homeDir, err := userHomeDir() + if err != nil { + return ".", fmt.Errorf("resolve home directory: %w", err) + } + + return homeDir, nil +} diff --git a/internal/httpctx/httpctx.go b/internal/httpctx/httpctx.go new file mode 100644 index 0000000..3f717f4 --- /dev/null +++ b/internal/httpctx/httpctx.go @@ -0,0 +1,79 @@ +// Package httpctx provides HTTP client defaults and context helpers for outbound +// Gemini API calls and remote asset downloads. +package httpctx + +import ( + "context" + "fmt" + "net/http" + "time" + + "google.golang.org/genai" +) + +const ( + // GenAIHTTPTimeout bounds each Google GenAI SDK HTTP request. + GenAIHTTPTimeout = 30 * time.Minute + + // ImageDownloadTimeout limits fetches of remote image URLs. + ImageDownloadTimeout = 60 * time.Second + + // OperationTimeoutDefault caps a full high-level operation when the caller did not set a deadline. + OperationTimeoutDefault = 15 * time.Minute + + // ListModelsTimeout bounds model-listing CLI calls. + ListModelsTimeout = 3 * time.Minute + + // StoryPageImageTimeout bounds a single comic page image pipeline when no parent deadline exists. + StoryPageImageTimeout = 25 * time.Minute + + // VeoCLIPerVideoTimeout bounds one gallery-to-video run when the CLI passes Background. + VeoCLIPerVideoTimeout = 25 * time.Minute + + // SingleWordProcessTimeout caps a single vocabulary processing operation when the caller uses Background. + SingleWordProcessTimeout = 10 * time.Minute +) + +// GenAIHTTPClient returns an http.Client for google.golang.org/genai. +func GenAIHTTPClient() *http.Client { + return &http.Client{Timeout: GenAIHTTPTimeout} +} + +// ImageDownloadHTTPClient returns a client for generic image URL downloads. +func ImageDownloadHTTPClient() *http.Client { + return &http.Client{Timeout: ImageDownloadTimeout} +} + +// NewGenAIClient wraps genai.NewClient, setting HTTPClient when the config does +// not supply one so outbound requests never rely on an unbounded default. +func NewGenAIClient(ctx context.Context, cfg *genai.ClientConfig) (*genai.Client, error) { + if cfg == nil { + cfg = &genai.ClientConfig{} + } + + merged := *cfg + if merged.HTTPClient == nil { + merged.HTTPClient = GenAIHTTPClient() + } + + client, err := genai.NewClient(ctx, &merged) + if err != nil { + return nil, fmt.Errorf("create genai client: %w", err) + } + + return client, nil +} + +// WithTimeoutUnlessSet returns a child context with timeout d when ctx has no +// deadline. If ctx already has a deadline, it returns ctx and a no-op cancel. +func WithTimeoutUnlessSet(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) { + if ctx == nil { + ctx = context.Background() + } + + if _, ok := ctx.Deadline(); ok { + return ctx, func() {} + } + + return context.WithTimeout(ctx, d) +} diff --git a/internal/httpctx/httpctx_test.go b/internal/httpctx/httpctx_test.go new file mode 100644 index 0000000..ffad68c --- /dev/null +++ b/internal/httpctx/httpctx_test.go @@ -0,0 +1,81 @@ +package httpctx + +import ( + "context" + "net/http" + "testing" + "time" + + "google.golang.org/genai" +) + +func TestWithTimeoutUnlessSet_AlreadyHasDeadline(t *testing.T) { + t.Parallel() + + parent, cancel := context.WithTimeout(context.Background(), time.Hour |
