summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-19 21:58:20 +0300
committerPaul Buetow <paul@buetow.org>2026-04-19 21:58:20 +0300
commitbaaa2a95b323296992bcce9c8cdc789c1c52d917 (patch)
treedbb2f8a231c1827cb571a1733abd5f7c66d31f4f
parenta87e799634280e2b52a5fcacafc44cb28a0d288e (diff)
u4: add core infrastructure scaffolding
-rw-r--r--go.mod36
-rw-r--r--go.sum93
-rw-r--r--internal/apicircuit/apicircuit.go92
-rw-r--r--internal/apicircuit/apicircuit_test.go40
-rw-r--r--internal/config/config.go327
-rw-r--r--internal/config/config_test.go148
-rw-r--r--internal/config/home.go22
-rw-r--r--internal/httpctx/httpctx.go79
-rw-r--r--internal/httpctx/httpctx_test.go81
-rw-r--r--internal/provider/provider.go72
-rw-r--r--internal/provider/provider_test.go22
-rw-r--r--internal/vocab/reader.go111
-rw-r--r--internal/vocab/reader_test.go124
13 files changed, 1247 insertions, 0 deletions
diff --git a/go.mod b/go.mod
index 59de80c..3e106dc 100644
--- a/go.mod
+++ b/go.mod
@@ -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
)
diff --git a/go.sum b/go.sum
index e6f25e1..28febcd 100644
--- a/go.sum
+++ b/go.sum
@@ -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, &notFound) {
+ 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 *testi