codexのOAuthを使う実装

This commit is contained in:
Keisuke Hirata 2026-04-20 23:13:52 +09:00
parent 24ade197d1
commit 6c6eb0dcb6
16 changed files with 1652 additions and 69 deletions

View File

@ -2,6 +2,10 @@
---
Gitは基本的にすべてユーザーが操作している。書き込みが必要な操作は明示的に許可されない限り行わないこと
---
`TODO.md`、`tickets/`はgitで管理されていて、時系列の管理はgitを参照して把握すること。
### TODO.md

View File

@ -2,6 +2,10 @@
---
Gitは基本的にすべてユーザーが操作している。書き込みが必要な操作は明示的に許可されない限り行わないこと
---
`TODO.md`、`tickets/`はgitで管理されていて、時系列の管理はgitを参照して把握すること。
### TODO.md

557
Cargo.lock generated
View File

@ -82,6 +82,16 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "assert-json-diff"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "async-trait"
version = "0.1.89"
@ -114,6 +124,28 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]]
name = "base64"
version = "0.22.1"
@ -210,9 +242,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
name = "cfg-if"
version = "1.0.4"
@ -234,6 +274,7 @@ dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
@ -278,12 +319,31 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "cmake"
version = "0.1.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
dependencies = [
"cc",
]
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
dependencies = [
"bytes",
"memchr",
]
[[package]]
name = "compact_str"
version = "0.9.0"
@ -313,6 +373,16 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@ -487,6 +557,24 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "deadpool"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
dependencies = [
"deadpool-runtime",
"lazy_static",
"num_cpus",
"tokio",
]
[[package]]
name = "deadpool-runtime"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
[[package]]
name = "deltae"
version = "0.3.2"
@ -571,6 +659,12 @@ version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "dyn-clone"
version = "1.0.20"
@ -745,6 +839,12 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures"
version = "0.3.32"
@ -850,8 +950,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@ -861,9 +963,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@ -980,6 +1084,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex"
version = "0.4.3"
@ -1025,6 +1135,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.10"
@ -1048,6 +1164,7 @@ dependencies = [
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
@ -1105,9 +1222,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@ -1355,6 +1474,60 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jni"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys 0.3.1",
"log",
"thiserror 1.0.69",
"walkdir",
"windows-sys 0.45.0",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
dependencies = [
"jni-sys 0.4.1",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn 2.0.117",
]
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.94"
@ -1503,6 +1676,12 @@ dependencies = [
"hashbrown 0.16.1",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac_address"
version = "1.1.8"
@ -1572,6 +1751,12 @@ dependencies = [
"autocfg",
]
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minijinja"
version = "2.19.0"
@ -1675,6 +1860,16 @@ dependencies = [
"autocfg",
]
[[package]]
name = "num_cpus"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
dependencies = [
"hermit-abi",
"libc",
]
[[package]]
name = "num_threads"
version = "0.1.7"
@ -1854,7 +2049,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand",
"rand 0.8.5",
]
[[package]]
@ -1947,6 +2142,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
@ -1979,12 +2183,77 @@ dependencies = [
name = "provider"
version = "0.1.0"
dependencies = [
"async-trait",
"base64",
"chrono",
"llm-worker",
"manifest",
"reqwest",
"serde",
"serde_json",
"serial_test",
"tempfile",
"thiserror 2.0.18",
"tokio",
"toml",
"tracing",
"wiremock",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"aws-lc-rs",
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.4",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.59.0",
]
[[package]]
@ -2014,7 +2283,27 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"rand_core",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
@ -2023,6 +2312,15 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "ratatui"
version = "0.30.0"
@ -2195,6 +2493,7 @@ checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
dependencies = [
"base64",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
@ -2207,15 +2506,20 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@ -2241,6 +2545,12 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustc_version"
version = "0.4.1"
@ -2282,6 +2592,7 @@ version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"aws-lc-rs",
"once_cell",
"rustls-pki-types",
"rustls-webpki",
@ -2289,21 +2600,62 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
@ -2392,7 +2744,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.11.0",
"core-foundation",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
@ -2713,6 +3065,27 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "target-triple"
version = "1.0.0"
@ -2895,10 +3268,25 @@ dependencies = [
]
[[package]]
name = "tokio"
version = "1.51.1"
name = "tinyvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.52.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
dependencies = [
"bytes",
"libc",
@ -3428,6 +3816,25 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "wezterm-bidi"
version = "0.2.3"
@ -3572,6 +3979,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.4.1"
@ -3590,13 +4008,22 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
"windows-targets 0.52.6",
]
[[package]]
@ -3605,7 +4032,7 @@ version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
"windows-targets 0.52.6",
]
[[package]]
@ -3617,34 +4044,67 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@ -3657,24 +4117,48 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
@ -3687,6 +4171,29 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
[[package]]
name = "wiremock"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
dependencies = [
"assert-json-diff",
"base64",
"deadpool",
"futures",
"http",
"http-body-util",
"hyper",
"hyper-util",
"log",
"once_cell",
"regex",
"serde",
"serde_json",
"tokio",
"url",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
@ -3804,6 +4311,26 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "zerofrom"
version = "0.1.7"

View File

@ -2,8 +2,6 @@
- [ ] ツール設計
- [ ] Bash ツール (Permission 層と統合) → [tickets/bash-tool.md](tickets/bash-tool.md)
- [ ] Protocol の設計 → [tickets/protocol-design.md](tickets/protocol-design.md)
- [ ] LLM プロバイダ統合
- [ ] Codex OAuth 認証の流用 → [tickets/llm-auth-codex-oauth.md](tickets/llm-auth-codex-oauth.md)
- [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md)
- [ ] Pod オーケストレーション
- [ ] 動的 Scope 変更 → [tickets/dynamic-scope.md](tickets/dynamic-scope.md)

View File

@ -1,4 +1,4 @@
//! `Scheme` 実装と通信層が要求する認証要件
//! `Scheme` 実装と通信層が要求する認証要件、および動的認証プロバイダ
//!
//! マニフェスト側の型(`ModelConfig` / `SchemeKind` / `AuthRef`)は
//! `crates/manifest` に置き、llm-worker はそれを知らずに済む。
@ -6,6 +6,15 @@
//! 期待するか」のランタイム記述で、manifest 側の `AuthRef` との
//! 照合(`AuthRef → ResolvedAuth` 変換の適否)は `crates/provider`
//! で行う。
//!
//! Codex OAuth のようにリクエスト毎にトークンが変わり得る認証は
//! [`AuthProvider`] trait を `crates/provider` 側で実装し、
//! [`super::transport::ResolvedAuth::Custom`] 経由で transport に渡す。
use async_trait::async_trait;
use reqwest::header::{HeaderName, HeaderValue};
use super::error::ClientError;
/// `Scheme::required_auth()` が返す認証要件。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -21,3 +30,19 @@ pub enum AuthRequirement {
/// 複合ヘッダCodex OAuth 等、`crates/provider` 側で解決)
Custom,
}
/// リクエスト毎に認証ヘッダを動的に組み立てるプロバイダ。
///
/// Codex OAuth のように access_token が refresh で更新されたり、
/// `ChatGPT-Account-Id` / `X-OpenAI-Fedramp` のような複数ヘッダを
/// 同時に注入する必要があるケースで使う。実体は `crates/provider`
/// 側に置き、llm-worker は trait を知るだけ。
///
/// 返したヘッダはそのまま `HeaderMap` に挿入される。`Authorization`
/// 含む scheme 既定の認証ヘッダは送出されないので、必要なら
/// 実装側でセットすること。
#[async_trait]
pub trait AuthProvider: Send + Sync + std::fmt::Debug {
/// 1 リクエスト分の認証ヘッダを返す。refresh が必要なら内部で行う。
async fn headers(&self) -> Result<Vec<(HeaderName, HeaderValue)>, ClientError>;
}

View File

@ -3,6 +3,11 @@
//! モデル family 判定は `scheme/openai_chat/capability.rs::classify` を
//! 共有する。Responses 側は `ReasoningSupport::Effort` 固定で、prompt
//! caching はサーバ側自動(`CacheStrategy::Auto`)。
//!
//! `gpt-5-codex` は `gpt-5` prefix 経由で Reasoning 扱いされるが、
//! `codex-mini-latest` 等 `codex-` prefix のモデルは ChatGPT backend
//! 経由CodexOAuthでしか使えないため、このテーブルでだけ Reasoning
//! にフォールバックする。
use crate::llm_client::capability::{
CacheStrategy, ModelCapability, ReasoningSupport, StructuredOutput, ToolCallingSupport,
@ -10,7 +15,14 @@ use crate::llm_client::capability::{
use crate::llm_client::scheme::openai_chat::capability::{OpenAiFamily, classify};
pub(crate) fn lookup(model_id: &str) -> Option<ModelCapability> {
classify(model_id).map(|family| match family {
let family = classify(model_id).or_else(|| {
if model_id.starts_with("codex-") {
Some(OpenAiFamily::Reasoning)
} else {
None
}
})?;
Some(match family {
OpenAiFamily::Reasoning => ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
structured_output: StructuredOutput::JsonSchema,
@ -35,6 +47,30 @@ pub(crate) fn lookup(model_id: &str) -> Option<ModelCapability> {
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gpt_5_codex_is_reasoning() {
// `gpt-5` prefix で classify される
let cap = lookup("gpt-5-codex").unwrap();
assert!(cap.reasoning.is_some());
}
#[test]
fn codex_mini_latest_is_reasoning() {
// ChatGPT backend 専用モデル。`codex-` prefix で Reasoning にフォールバック
let cap = lookup("codex-mini-latest").unwrap();
assert!(cap.reasoning.is_some());
}
#[test]
fn unknown_model_returns_none() {
assert!(lookup("foo-bar-3000").is_none());
}
}
pub(crate) fn default_capability() -> ModelCapability {
ModelCapability {
tool_calling: ToolCallingSupport::Parallel,

View File

@ -5,13 +5,14 @@
//! scheme 固有の差分は [`Scheme`] trait 実装に委譲する。
use std::pin::Pin;
use std::sync::Arc;
use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::{Stream, StreamExt, TryStreamExt};
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
use super::auth::AuthRequirement;
use super::auth::{AuthProvider, AuthRequirement};
use super::capability::ModelCapability;
use super::client::{ConfigWarning, LlmClient};
use super::error::ClientError;
@ -21,24 +22,28 @@ use super::types::{Request, RequestConfig};
/// `AuthRef` を解決したランタイム表現。`crates/provider` が構築する。
///
/// `AuthRef::ApiKey` → 読み取った文字列、`AuthRef::None` → `None`。
/// `CodexOAuth` 等、動的に更新される認証は別途 `Custom` バリアントを
/// 追加する余地を残す(本チケットでは未実装)。
/// - `None`: 認証ヘッダを送らないOllama 等の opt-out
/// - `ApiKey`: 静的な API key 文字列
/// - `Custom`: リクエスト毎に動的にヘッダを組み立てるCodex OAuth 等)
#[derive(Debug, Clone)]
pub enum ResolvedAuth {
None,
ApiKey(String),
Custom(Arc<dyn AuthProvider>),
}
impl ResolvedAuth {
/// 認証要件と実際の解決値が噛み合うか検査する。構築時検証用。
///
/// `ResolvedAuth::None` は認証を付けないという宣言なので、どの
/// - `ResolvedAuth::None` は認証を付けない宣言なので、どの
/// `AuthRequirement` でも受け入れるOllama の Anthropic scheme
/// 流用は `required_auth = XApiKey` だが認証ヘッダなしで動く)。
/// 流用は `required_auth = XApiKey` だが認証ヘッダなしで動く)
/// - `ResolvedAuth::Custom` は「ヘッダ組立を全部こちらで行う」
/// 宣言なので、scheme が要求する形式によらず受け入れる
pub fn matches(&self, req: AuthRequirement) -> bool {
match (self, req) {
(Self::None, _) => true,
(Self::Custom(_), _) => true,
(
Self::ApiKey(_),
AuthRequirement::Bearer | AuthRequirement::XApiKey | AuthRequirement::QueryParam { .. },
@ -100,29 +105,36 @@ impl<S: Scheme> HttpTransport<S> {
}
}
fn build_headers(&self) -> Result<HeaderMap, ClientError> {
async fn build_headers(&self) -> Result<HeaderMap, ClientError> {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
match (self.scheme.required_auth(), &self.auth) {
(AuthRequirement::None, _) | (_, ResolvedAuth::None) => {}
(AuthRequirement::Bearer, ResolvedAuth::ApiKey(key)) => {
match (&self.auth, self.scheme.required_auth()) {
(ResolvedAuth::None, _) | (_, AuthRequirement::None) => {}
(ResolvedAuth::Custom(provider), _) => {
for (name, mut value) in provider.headers().await? {
value.set_sensitive(true);
headers.insert(name, value);
}
}
(ResolvedAuth::ApiKey(key), AuthRequirement::Bearer) => {
let mut val = HeaderValue::from_str(&format!("Bearer {key}"))
.map_err(|e| ClientError::Config(format!("invalid api key: {e}")))?;
val.set_sensitive(true);
headers.insert("Authorization", val);
}
(AuthRequirement::XApiKey, ResolvedAuth::ApiKey(key)) => {
(ResolvedAuth::ApiKey(key), AuthRequirement::XApiKey) => {
let mut val = HeaderValue::from_str(key.as_str())
.map_err(|e| ClientError::Config(format!("invalid api key: {e}")))?;
val.set_sensitive(true);
headers.insert("x-api-key", val);
}
(AuthRequirement::QueryParam { .. }, _) => {
(_, AuthRequirement::QueryParam { .. }) => {
// クエリパラメータは `build_url` で付与済み
}
(AuthRequirement::Custom, _) => {
// 今チケットでは Custom は使わない。Codex OAuth で追加予定
(ResolvedAuth::ApiKey(_), AuthRequirement::Custom) => {
// scheme が Custom を要求する組合せに ApiKey は流れてこない想定
// `matches()` で弾かれる)。安全側で何もしない
}
}
@ -164,7 +176,7 @@ impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
let url = self.build_url();
let headers = self.build_headers()?;
let headers = self.build_headers().await?;
let body = self
.scheme
.build_request_body(&self.model_id, &request, &self.capability);

View File

@ -5,11 +5,20 @@ edition.workspace = true
license.workspace = true
[dependencies]
async-trait = "0.1.89"
base64 = "0.22.1"
chrono = { version = "0.4.44", default-features = false, features = ["serde", "clock"] }
llm-worker = { version = "0.2.1", path = "../llm-worker" }
manifest = { version = "0.1.0", path = "../manifest" }
reqwest = { version = "0.13.2", features = ["json", "native-tls"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
thiserror = "2.0"
tokio = { version = "1.52.1", features = ["sync", "fs", "rt"] }
tracing = "0.1.44"
[dev-dependencies]
serial_test = "3.4.0"
tempfile = "3.27.0"
toml = "1.1.2"
wiremock = "0.6.5"

View File

@ -0,0 +1,265 @@
//! `~/.codex/auth.json` の読み書き。
//!
//! Codex CLI と schema を共有するが、insomnia は知らないフィールドを
//! 失わないようファイル全体を `serde_json::Value` で保持し、必要箇所
//! のみアクセスする。書込は `mode 0o600` を再設定Codex CLI 同様)、
//! ファイルロックは取らないmanager 側で guarded reload
use std::fs::OpenOptions;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
use chrono::{DateTime, Utc};
use serde_json::Value;
use super::error::CodexAuthError;
/// auth.json から取り出した使い回す情報のスナップショット。
#[derive(Debug, Clone)]
pub struct AuthSnapshot {
pub access_token: String,
pub refresh_token: String,
pub account_id: String,
pub id_token: String,
pub last_refresh: Option<DateTime<Utc>>,
/// 書き戻し時に他のフィールドを失わないため、ファイル全体を保持する。
pub raw: Value,
}
impl AuthSnapshot {
/// `Value` から必要フィールドを抽出。欠落・型不一致は `MalformedAuthJson`。
pub fn from_value(raw: Value) -> Result<Self, CodexAuthError> {
let tokens = raw
.get("tokens")
.ok_or_else(|| CodexAuthError::MalformedAuthJson("missing 'tokens'".into()))?;
let access_token = tokens
.get("access_token")
.and_then(Value::as_str)
.ok_or_else(|| CodexAuthError::MalformedAuthJson("missing tokens.access_token".into()))?
.to_string();
let refresh_token = tokens
.get("refresh_token")
.and_then(Value::as_str)
.ok_or_else(|| CodexAuthError::MalformedAuthJson("missing tokens.refresh_token".into()))?
.to_string();
let id_token = tokens
.get("id_token")
.and_then(Value::as_str)
.ok_or_else(|| CodexAuthError::MalformedAuthJson("missing tokens.id_token".into()))?
.to_string();
// account_id は tokens.account_id を優先、無ければ id_token JWT 由来
let account_id = tokens
.get("account_id")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| {
super::jwt::parse_chatgpt_claims(&id_token).and_then(|c| c.account_id)
})
.ok_or_else(|| {
CodexAuthError::MalformedAuthJson(
"missing account_id in both tokens and id_token claims".into(),
)
})?;
let last_refresh = raw
.get("last_refresh")
.and_then(Value::as_str)
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc));
Ok(Self {
access_token,
refresh_token,
account_id,
id_token,
last_refresh,
raw,
})
}
}
/// auth.json を読む。存在しなければ `NotLoggedIn`。
pub async fn load(path: &Path) -> Result<AuthSnapshot, CodexAuthError> {
let bytes = match tokio::fs::read(path).await {
Ok(b) => b,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Err(CodexAuthError::NotLoggedIn(path.to_path_buf()));
}
Err(err) => {
return Err(CodexAuthError::Io(format!(
"failed to read {}: {err}",
path.display()
)));
}
};
let raw: Value = serde_json::from_slice(&bytes)
.map_err(|e| CodexAuthError::MalformedAuthJson(format!("json parse: {e}")))?;
AuthSnapshot::from_value(raw)
}
/// 既存ファイルを再読込し、`tokens.{id_token,access_token,refresh_token}` と
/// `last_refresh` を更新して書き戻す。Codex CLI の `persist_tokens` 相当。
///
/// 並行する Codex CLI / 別 insomnia プロセスが先に refresh していた場合の
/// fields を保護するため、書込前に再 load して merge する。
pub async fn persist_refreshed(
path: &Path,
new_id_token: Option<String>,
new_access_token: Option<String>,
new_refresh_token: Option<String>,
) -> Result<AuthSnapshot, CodexAuthError> {
let mut current = load(path).await?;
let raw = &mut current.raw;
let tokens = raw
.get_mut("tokens")
.and_then(Value::as_object_mut)
.ok_or_else(|| CodexAuthError::MalformedAuthJson("tokens not an object".into()))?;
if let Some(t) = new_id_token {
tokens.insert("id_token".into(), Value::String(t));
}
if let Some(t) = new_access_token {
tokens.insert("access_token".into(), Value::String(t));
}
if let Some(t) = new_refresh_token {
tokens.insert("refresh_token".into(), Value::String(t));
}
raw.as_object_mut()
.ok_or_else(|| CodexAuthError::MalformedAuthJson("auth.json not an object".into()))?
.insert("last_refresh".into(), Value::String(Utc::now().to_rfc3339()));
write_atomic(path, raw)?;
AuthSnapshot::from_value(raw.clone())
}
fn write_atomic(path: &Path, value: &Value) -> Result<(), CodexAuthError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
CodexAuthError::Io(format!("create_dir_all {}: {e}", parent.display()))
})?;
}
let json = serde_json::to_vec_pretty(value)
.map_err(|e| CodexAuthError::Io(format!("serialize: {e}")))?;
let mut options = OpenOptions::new();
options.truncate(true).write(true).create(true);
#[cfg(unix)]
{
options.mode(0o600);
}
let mut file = options
.open(path)
.map_err(|e| CodexAuthError::Io(format!("open {}: {e}", path.display())))?;
file.write_all(&json)
.map_err(|e| CodexAuthError::Io(format!("write {}: {e}", path.display())))?;
file.flush()
.map_err(|e| CodexAuthError::Io(format!("flush {}: {e}", path.display())))?;
// 既存ファイルが緩いパーミッションだった場合に備えて 0o600 を強制し直す。
// OpenOptions の `mode()` は新規作成時しか効かないため。
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.map_err(|e| CodexAuthError::Io(format!("chmod {}: {e}", path.display())))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn write_auth_json(dir: &Path, content: &str) -> std::path::PathBuf {
let path = dir.join("auth.json");
std::fs::write(&path, content).unwrap();
path
}
#[tokio::test]
async fn load_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = write_auth_json(
dir.path(),
r#"{
"auth_mode":"ChatgptAuthTokens",
"tokens":{
"id_token":"h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjLTEifX0.s",
"access_token":"acc",
"refresh_token":"ref",
"account_id":"acc-1"
},
"last_refresh":"2026-04-20T00:00:00Z",
"OPENAI_API_KEY":"sk-extra"
}"#,
);
let snap = load(&path).await.unwrap();
assert_eq!(snap.access_token, "acc");
assert_eq!(snap.refresh_token, "ref");
assert_eq!(snap.account_id, "acc-1");
assert!(snap.last_refresh.is_some());
// 未知フィールドが raw に保持されている
assert_eq!(snap.raw.get("OPENAI_API_KEY").and_then(Value::as_str), Some("sk-extra"));
}
#[tokio::test]
async fn account_id_falls_back_to_jwt_claim() {
let dir = tempfile::tempdir().unwrap();
// tokens.account_id を欠落させ、id_token JWT 内 claim から拾う
let id_token = "h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiZnJvbS1qd3QifX0.s";
let path = write_auth_json(
dir.path(),
&format!(
r#"{{ "tokens": {{ "id_token":"{id_token}", "access_token":"a", "refresh_token":"r" }} }}"#
),
);
let snap = load(&path).await.unwrap();
assert_eq!(snap.account_id, "from-jwt");
}
#[tokio::test]
async fn missing_file_returns_not_logged_in() {
let dir = tempfile::tempdir().unwrap();
let err = load(&dir.path().join("nope.json")).await.unwrap_err();
assert!(matches!(err, CodexAuthError::NotLoggedIn(_)));
}
#[tokio::test]
async fn persist_preserves_unknown_fields() {
let dir = tempfile::tempdir().unwrap();
let path = write_auth_json(
dir.path(),
r#"{
"tokens":{"id_token":"h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYSJ9fQ.s","access_token":"old-acc","refresh_token":"old-ref","account_id":"a"},
"agent_identity":{"workspace_id":"w","agent_runtime_id":"r","agent_private_key":"k","registered_at":"x"}
}"#,
);
let updated = persist_refreshed(&path, None, Some("new-acc".into()), Some("new-ref".into())).await.unwrap();
assert_eq!(updated.access_token, "new-acc");
assert_eq!(updated.refresh_token, "new-ref");
// 未知フィールド agent_identity が保たれる
let on_disk: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert!(on_disk.get("agent_identity").is_some());
assert!(on_disk.get("last_refresh").is_some());
}
#[cfg(unix)]
#[tokio::test]
async fn write_uses_mode_600() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = write_auth_json(
dir.path(),
r#"{"tokens":{"id_token":"h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYSJ9fQ.s","access_token":"a","refresh_token":"r","account_id":"a"}}"#,
);
// 既存ファイルを 644 に変えてから persist → 600 に直るか
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
persist_refreshed(&path, None, Some("a2".into()), None).await.unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
}

View File

@ -0,0 +1,72 @@
//! Codex OAuth サブモジュール内部のエラー型。
//!
//! `LlmClient` 境界に渡す際は `to_client_error` で `ClientError` に
//! 変換する。`Permanent` 系refresh_token 失効)は `codex login` の
//! 再実行案内付きメッセージにする。
use std::path::PathBuf;
use llm_worker::llm_client::ClientError;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum CodexAuthError {
#[error(
"not logged in to ChatGPT: {0} not found. Run `codex login` and ensure cli_auth_credentials_store = \"file\"."
)]
NotLoggedIn(PathBuf),
#[error("malformed ~/.codex/auth.json: {0}")]
MalformedAuthJson(String),
#[error("io error: {0}")]
Io(String),
#[error("token refresh failed (transient): {0}")]
RefreshTransient(String),
/// refresh_token が永続的に失効。再ログインが必要。
#[error("token refresh failed permanently ({reason:?}): {message}")]
RefreshPermanent {
reason: PermanentReason,
message: String,
},
#[error("invalid header value: {0}")]
InvalidHeader(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermanentReason {
Expired,
Reused,
Revoked,
Other,
}
impl CodexAuthError {
/// `LlmClient` トランスポート境界向けに変換する。
pub fn to_client_error(self) -> ClientError {
match self {
CodexAuthError::NotLoggedIn(_)
| CodexAuthError::MalformedAuthJson(_)
| CodexAuthError::Io(_)
| CodexAuthError::InvalidHeader(_) => ClientError::Config(self.to_string()),
CodexAuthError::RefreshTransient(msg) => ClientError::Api {
status: None,
code: Some("refresh_transient".into()),
message: msg,
},
CodexAuthError::RefreshPermanent { reason, message } => ClientError::Api {
status: Some(401),
code: Some(match reason {
PermanentReason::Expired => "refresh_token_expired".into(),
PermanentReason::Reused => "refresh_token_reused".into(),
PermanentReason::Revoked => "refresh_token_invalidated".into(),
PermanentReason::Other => "refresh_token_failed".into(),
}),
message: format!("{message}. Please run `codex login` again."),
},
}
}
}

View File

@ -0,0 +1,104 @@
//! JWT payload の最小限のパース(署名検証なし)。
//!
//! Codex CLI と同じく、access_token / id_token の payload を base64url
//! デコードして `exp` や ChatGPT 固有 claims を取り出すためだけに使う。
use base64::Engine;
use chrono::{DateTime, Utc};
use serde::Deserialize;
/// `Authorization: Bearer` で送る access_token JWT の `exp` を読む。
pub fn parse_exp(jwt: &str) -> Option<DateTime<Utc>> {
#[derive(Deserialize)]
struct Claims {
exp: Option<i64>,
}
let claims: Claims = decode_payload(jwt).ok()?;
DateTime::<Utc>::from_timestamp(claims.exp?, 0)
}
/// id_token JWT から ChatGPT 固有 claims を取り出す。
pub fn parse_chatgpt_claims(jwt: &str) -> Option<ChatGptClaims> {
#[derive(Deserialize)]
struct IdClaims {
#[serde(rename = "https://api.openai.com/auth", default)]
auth: Option<AuthClaims>,
}
#[derive(Deserialize)]
struct AuthClaims {
#[serde(default)]
chatgpt_account_id: Option<String>,
#[serde(default)]
chatgpt_account_is_fedramp: bool,
}
let claims: IdClaims = decode_payload(jwt).ok()?;
let auth = claims.auth?;
Some(ChatGptClaims {
account_id: auth.chatgpt_account_id,
is_fedramp: auth.chatgpt_account_is_fedramp,
})
}
#[derive(Debug, Clone)]
pub struct ChatGptClaims {
pub account_id: Option<String>,
pub is_fedramp: bool,
}
fn decode_payload<T: for<'de> Deserialize<'de>>(jwt: &str) -> Result<T, JwtError> {
let payload = jwt.split('.').nth(1).ok_or(JwtError::InvalidFormat)?;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload)
.map_err(|_| JwtError::InvalidBase64)?;
serde_json::from_slice(&bytes).map_err(|_| JwtError::InvalidJson)
}
#[derive(Debug)]
enum JwtError {
InvalidFormat,
InvalidBase64,
InvalidJson,
}
#[cfg(test)]
mod tests {
use super::*;
fn make_jwt(payload: &serde_json::Value) -> String {
let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_vec(payload).unwrap());
format!("h.{payload_b64}.s")
}
#[test]
fn parses_exp() {
let jwt = make_jwt(&serde_json::json!({ "exp": 1_700_000_000_i64 }));
let dt = parse_exp(&jwt).unwrap();
assert_eq!(dt.timestamp(), 1_700_000_000);
}
#[test]
fn parses_chatgpt_claims() {
let jwt = make_jwt(&serde_json::json!({
"https://api.openai.com/auth": {
"chatgpt_account_id": "acc-123",
"chatgpt_account_is_fedramp": true,
}
}));
let c = parse_chatgpt_claims(&jwt).unwrap();
assert_eq!(c.account_id.as_deref(), Some("acc-123"));
assert!(c.is_fedramp);
}
#[test]
fn missing_exp_returns_none() {
let jwt = make_jwt(&serde_json::json!({}));
assert!(parse_exp(&jwt).is_none());
}
#[test]
fn malformed_jwt_returns_none() {
assert!(parse_exp("not-a-jwt").is_none());
assert!(parse_chatgpt_claims("x.y").is_none());
}
}

View File

@ -0,0 +1,334 @@
//! ChatGPT OAuth (`~/.codex/auth.json`) を読んで `Authorization` /
//! `ChatGPT-Account-Id` / `X-OpenAI-Fedramp` ヘッダを組み立てる
//! [`AuthProvider`] 実装。
//!
//! 設計:
//!
//! - llm-worker は [`AuthProvider`] trait しか知らず、実体である
//! [`CodexAuthProvider`] はこのクレートに置くfeedback_llm_worker_scope
//! - access_token JWT の `exp` を読み、`now` 以下で proactive refresh
//! Codex CLI と同じバッファなし)
//! - 並行する Codex CLI / 別 insomnia の refresh と取り違えないよう、
//! refresh 直前に再 load して account_id 一致を確認guarded reload
//! - ファイルロックは取らず、書込前に再 load + diff merge で吸収
//! - Codex の Keyring storage は対象外。auth.json 不在ならエラーで案内
mod auth_json;
mod error;
mod jwt;
mod refresh;
use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use chrono::{Duration, Utc};
use llm_worker::llm_client::{
ClientError,
auth::AuthProvider,
};
use reqwest::header::{HeaderName, HeaderValue};
use tokio::sync::Mutex;
use auth_json::AuthSnapshot;
use error::CodexAuthError;
pub use error::PermanentReason;
/// Codex CLI の `last_refresh` ベース fallback 期限Codex CLI 準拠で 8 日)。
const TOKEN_REFRESH_INTERVAL_DAYS: i64 = 8;
/// `~/.codex/auth.json` を読んで Codex 互換のヘッダを返す provider。
pub struct CodexAuthProvider {
auth_path: PathBuf,
refresh_endpoint: String,
http: reqwest::Client,
state: Arc<Mutex<State>>,
}
impl std::fmt::Debug for CodexAuthProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CodexAuthProvider")
.field("auth_path", &self.auth_path)
.field("refresh_endpoint", &self.refresh_endpoint)
.finish_non_exhaustive()
}
}
#[derive(Default)]
struct State {
cached: Option<AuthSnapshot>,
}
impl CodexAuthProvider {
/// `CODEX_HOME` → `$HOME/.codex` の順で auth.json の場所を決める。
pub fn from_default_home() -> Result<Self, ClientError> {
let codex_home = if let Ok(p) = std::env::var("CODEX_HOME") {
PathBuf::from(p)
} else {
let home = std::env::var("HOME")
.map_err(|_| ClientError::Config("HOME not set".into()))?;
PathBuf::from(home).join(".codex")
};
Ok(Self::new(codex_home))
}
/// 任意の codex_home から構築する。
pub fn new(codex_home: PathBuf) -> Self {
Self {
auth_path: codex_home.join("auth.json"),
refresh_endpoint: refresh::REFRESH_URL.to_string(),
http: reqwest::Client::new(),
state: Arc::new(Mutex::new(State::default())),
}
}
/// テスト用に refresh エンドポイントを差し替える。
#[cfg(test)]
fn with_refresh_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.refresh_endpoint = endpoint.into();
self
}
/// テスト用に HTTP クライアントを差し替える。
#[cfg(test)]
fn with_http_client(mut self, client: reqwest::Client) -> Self {
self.http = client;
self
}
async fn ensure_fresh(&self) -> Result<AuthSnapshot, CodexAuthError> {
let mut state = self.state.lock().await;
// 1. 常にディスクから最新を読む(他プロセスの更新を反映)
let snap = auth_json::load(&self.auth_path).await?;
// 2. stale でなければそのまま返す
if !is_stale(&snap) {
state.cached = Some(snap.clone());
return Ok(snap);
}
// 3. Refresh 直前に再 load し account_id 一致を確認guarded reload
// 一致しなければ他プロセスが先に更新済 → 自分は refresh しない
let pre_refresh = auth_json::load(&self.auth_path).await?;
if !is_stale(&pre_refresh) {
state.cached = Some(pre_refresh.clone());
return Ok(pre_refresh);
}
if pre_refresh.account_id != snap.account_id {
// account が切り替わった → 新しい auth を採用、refresh はしない
state.cached = Some(pre_refresh.clone());
return Ok(pre_refresh);
}
// 4. Refresh 実行
let refreshed = refresh::request_refresh(
&self.http,
&self.refresh_endpoint,
&pre_refresh.refresh_token,
)
.await?;
// 5. 書き戻し(書込前に再 load + merge は persist_refreshed 内で実施)
let new_snap = auth_json::persist_refreshed(
&self.auth_path,
refreshed.id_token,
refreshed.access_token,
refreshed.refresh_token,
)
.await?;
state.cached = Some(new_snap.clone());
Ok(new_snap)
}
fn build_headers(snap: &AuthSnapshot) -> Result<Vec<(HeaderName, HeaderValue)>, CodexAuthError> {
let mut out = Vec::with_capacity(3);
let auth_val = HeaderValue::from_str(&format!("Bearer {}", snap.access_token))
.map_err(|e| CodexAuthError::InvalidHeader(format!("Authorization: {e}")))?;
out.push((HeaderName::from_static("authorization"), auth_val));
let acc_val = HeaderValue::from_str(&snap.account_id)
.map_err(|e| CodexAuthError::InvalidHeader(format!("ChatGPT-Account-Id: {e}")))?;
out.push((
HeaderName::from_static("chatgpt-account-id"),
acc_val,
));
// FedRAMP 組織は id_token JWT 内の claim で判定
if jwt::parse_chatgpt_claims(&snap.id_token)
.map(|c| c.is_fedramp)
.unwrap_or(false)
{
out.push((
HeaderName::from_static("x-openai-fedramp"),
HeaderValue::from_static("true"),
));
}
Ok(out)
}
}
#[async_trait]
impl AuthProvider for CodexAuthProvider {
async fn headers(&self) -> Result<Vec<(HeaderName, HeaderValue)>, ClientError> {
let snap = self.ensure_fresh().await.map_err(CodexAuthError::to_client_error)?;
Self::build_headers(&snap).map_err(CodexAuthError::to_client_error)
}
}
/// `access_token` の JWT `exp` を見て、期限切れなら true。
/// `exp` が読めない場合は `last_refresh + 8 日` で判定Codex CLI と同じ)。
fn is_stale(snap: &AuthSnapshot) -> bool {
if let Some(exp) = jwt::parse_exp(&snap.access_token) {
return exp <= Utc::now();
}
match snap.last_refresh {
Some(last) => last < Utc::now() - Duration::days(TOKEN_REFRESH_INTERVAL_DAYS),
None => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
use serde_json::json;
use wiremock::matchers::{method, path as url_path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn make_jwt(payload: serde_json::Value) -> String {
let p = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_vec(&payload).unwrap());
format!("h.{p}.s")
}
fn write_auth(dir: &std::path::Path, exp: i64, fedramp: bool, refresh: &str) -> PathBuf {
let id_token = make_jwt(json!({
"https://api.openai.com/auth": {
"chatgpt_account_id": "acc-xyz",
"chatgpt_account_is_fedramp": fedramp,
}
}));
let access_token = make_jwt(json!({ "exp": exp }));
let auth = json!({
"tokens": {
"id_token": id_token,
"access_token": access_token,
"refresh_token": refresh,
"account_id": "acc-xyz",
},
"last_refresh": "2026-04-20T00:00:00Z",
});
let path = dir.join("auth.json");
std::fs::write(&path, serde_json::to_vec_pretty(&auth).unwrap()).unwrap();
path
}
#[tokio::test]
async fn returns_headers_for_fresh_token() {
let dir = tempfile::tempdir().unwrap();
// 期限を遠い未来に
write_auth(dir.path(), Utc::now().timestamp() + 3600, false, "rt");
let provider = CodexAuthProvider::new(dir.path().to_path_buf());
let headers = provider.headers().await.unwrap();
let names: Vec<_> = headers.iter().map(|(n, _)| n.as_str().to_string()).collect();
assert!(names.contains(&"authorization".to_string()));
assert!(names.contains(&"chatgpt-account-id".to_string()));
assert!(!names.contains(&"x-openai-fedramp".to_string()));
let acc = headers
.iter()
.find(|(n, _)| n.as_str() == "chatgpt-account-id")
.unwrap();
assert_eq!(acc.1.to_str().unwrap(), "acc-xyz");
}
#[tokio::test]
async fn fedramp_header_added_when_claim_set() {
let dir = tempfile::tempdir().unwrap();
write_auth(dir.path(), Utc::now().timestamp() + 3600, true, "rt");
let provider = CodexAuthProvider::new(dir.path().to_path_buf());
let headers = provider.headers().await.unwrap();
let fedramp = headers
.iter()
.find(|(n, _)| n.as_str() == "x-openai-fedramp");
assert!(fedramp.is_some());
assert_eq!(fedramp.unwrap().1, "true");
}
#[tokio::test]
async fn refreshes_when_expired_and_persists() {
let dir = tempfile::tempdir().unwrap();
let path = write_auth(dir.path(), Utc::now().timestamp() - 60, false, "old-refresh");
// refresh エンドポイントを mock。新しい JWT (将来 exp) を返す
let server = MockServer::start().await;
let new_access = make_jwt(json!({ "exp": Utc::now().timestamp() + 3600 }));
let new_refresh = "new-refresh-token";
Mock::given(method("POST"))
.and(url_path("/oauth/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"access_token": new_access,
"refresh_token": new_refresh,
})))
.expect(1)
.mount(&server)
.await;
let provider = CodexAuthProvider::new(dir.path().to_path_buf())
.with_refresh_endpoint(format!("{}/oauth/token", server.uri()))
.with_http_client(reqwest::Client::new());
let headers = provider.headers().await.unwrap();
let auth = headers
.iter()
.find(|(n, _)| n.as_str() == "authorization")
.unwrap();
assert_eq!(auth.1.to_str().unwrap(), format!("Bearer {new_access}"));
// ファイルに新しい refresh_token が書き戻されている
let on_disk: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(
on_disk["tokens"]["refresh_token"].as_str(),
Some(new_refresh)
);
}
#[tokio::test]
async fn permanent_refresh_failure_surfaces_login_message() {
let dir = tempfile::tempdir().unwrap();
write_auth(dir.path(), Utc::now().timestamp() - 60, false, "bad-refresh");
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(url_path("/oauth/token"))
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
"error": { "code": "refresh_token_expired" }
})))
.mount(&server)
.await;
let provider = CodexAuthProvider::new(dir.path().to_path_buf())
.with_refresh_endpoint(format!("{}/oauth/token", server.uri()))
.with_http_client(reqwest::Client::new());
let err = provider.headers().await.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("codex login"), "expected hint, got: {msg}");
}
#[tokio::test]
async fn missing_auth_json_reports_not_logged_in() {
let dir = tempfile::tempdir().unwrap();
let provider = CodexAuthProvider::new(dir.path().to_path_buf());
let err = provider.headers().await.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("not logged in"), "got: {msg}");
}
}

View File

@ -0,0 +1,134 @@
//! ChatGPT OAuth トークンの refresh HTTP 呼出。
//!
//! Codex CLI と同じ `POST https://auth.openai.com/oauth/token` 形式。
//! 401 + `error.code` で永続失敗を分類する。
use serde::{Deserialize, Serialize};
use super::error::{CodexAuthError, PermanentReason};
pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
pub const REFRESH_URL: &str = "https://auth.openai.com/oauth/token";
#[derive(Serialize)]
struct RefreshRequest<'a> {
client_id: &'static str,
grant_type: &'static str,
refresh_token: &'a str,
}
#[derive(Deserialize, Debug, Default, Clone)]
pub struct RefreshResponse {
#[serde(default)]
pub id_token: Option<String>,
#[serde(default)]
pub access_token: Option<String>,
#[serde(default)]
pub refresh_token: Option<String>,
}
/// refresh_token を使って新しいトークン群を取得する。
///
/// 永続失敗401 + `refresh_token_(expired|reused|invalidated)`)は
/// `RefreshPermanent`、それ以外は `RefreshTransient`。
pub async fn request_refresh(
client: &reqwest::Client,
endpoint: &str,
refresh_token: &str,
) -> Result<RefreshResponse, CodexAuthError> {
let body = RefreshRequest {
client_id: CLIENT_ID,
grant_type: "refresh_token",
refresh_token,
};
let response = client
.post(endpoint)
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.map_err(|e| CodexAuthError::RefreshTransient(format!("send: {e}")))?;
let status = response.status();
if status.is_success() {
response
.json::<RefreshResponse>()
.await
.map_err(|e| CodexAuthError::RefreshTransient(format!("parse response: {e}")))
} else {
let body = response.text().await.unwrap_or_default();
if status == reqwest::StatusCode::UNAUTHORIZED {
let (reason, message) = classify_permanent(&body);
Err(CodexAuthError::RefreshPermanent { reason, message })
} else {
Err(CodexAuthError::RefreshTransient(format!(
"{status}: {body}"
)))
}
}
}
fn classify_permanent(body: &str) -> (PermanentReason, String) {
let code = extract_error_code(body);
let reason = match code.as_deref() {
Some("refresh_token_expired") => PermanentReason::Expired,
Some("refresh_token_reused") => PermanentReason::Reused,
Some("refresh_token_invalidated") => PermanentReason::Revoked,
_ => PermanentReason::Other,
};
let message = match reason {
PermanentReason::Expired => "Your refresh token has expired".to_string(),
PermanentReason::Reused => "Your refresh token was already used".to_string(),
PermanentReason::Revoked => "Your refresh token was revoked".to_string(),
PermanentReason::Other => format!("Unknown 401 from refresh endpoint: {body}"),
};
(reason, message)
}
fn extract_error_code(body: &str) -> Option<String> {
let value: serde_json::Value = serde_json::from_str(body).ok()?;
if let Some(error) = value.get("error") {
if let Some(obj) = error.as_object() {
if let Some(code) = obj.get("code").and_then(|v| v.as_str()) {
return Some(code.to_string());
}
}
if let Some(s) = error.as_str() {
return Some(s.to_string());
}
}
value.get("code").and_then(|v| v.as_str()).map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_expired() {
let body = r#"{"error":{"code":"refresh_token_expired"}}"#;
let (r, _) = classify_permanent(body);
assert_eq!(r, PermanentReason::Expired);
}
#[test]
fn classify_reused() {
let body = r#"{"error":{"code":"refresh_token_reused"}}"#;
let (r, _) = classify_permanent(body);
assert_eq!(r, PermanentReason::Reused);
}
#[test]
fn classify_unknown_falls_to_other() {
let body = r#"{"error":{"code":"weird"}}"#;
let (r, _) = classify_permanent(body);
assert_eq!(r, PermanentReason::Other);
}
#[test]
fn classify_top_level_code() {
let body = r#"{"code":"refresh_token_invalidated"}"#;
let (r, _) = classify_permanent(body);
assert_eq!(r, PermanentReason::Revoked);
}
}

View File

@ -10,6 +10,10 @@
//! なる認証ストア解決Codex OAuth の `~/.codex/auth.json` 読取等)は
//! このクレートに追加する。
pub mod codex_oauth;
use std::sync::Arc;
use llm_worker::llm_client::{
LlmClient,
capability::ModelCapability,
@ -74,11 +78,24 @@ fn resolve_auth(
}
Err(ProviderError::ApiKeyMissing { scheme })
}
AuthRef::CodexOAuth => Err(ProviderError::Config(
"codex_oauth auth not yet implemented (tickets/llm-auth-codex-oauth)".into(),
)),
AuthRef::CodexOAuth => {
let provider = codex_oauth::CodexAuthProvider::from_default_home()
.map_err(|e| ProviderError::Config(e.to_string()))?;
Ok(ResolvedAuth::Custom(Arc::new(provider)))
}
}
}
/// `AuthRef::CodexOAuth` 指定時、`base_url` 未指定なら ChatGPT backend を既定とする。
fn effective_base_url<S: Scheme>(scheme: &S, config: &ModelConfig) -> String {
if let Some(b) = &config.base_url {
return b.clone();
}
if matches!(config.auth, AuthRef::CodexOAuth) {
return "https://chatgpt.com/backend-api".to_string();
}
scheme.default_base_url().to_string()
}
fn build_transport<S: Scheme>(
scheme: S,
@ -100,10 +117,7 @@ fn build_transport<S: Scheme>(
.clone()
.or_else(|| scheme.capability_for(&config.model_id))
.unwrap_or_else(|| scheme.default_capability());
let base_url = config
.base_url
.clone()
.unwrap_or_else(|| scheme.default_base_url().to_string());
let base_url = effective_base_url(&scheme, config);
Ok(Box::new(HttpTransport::new(
scheme,
config.model_id.clone(),

View File

@ -1,70 +1,82 @@
# Codex OAuth 認証の流用
**Status: Reviewed / Approved**(詳細は `llm-auth-codex-oauth.review.md`
## 背景
決定済み方針(`docs/plan/llm_providers.md`で、ChatGPT サブスクリプションの OAuth トークンを流用して OpenAI Responses API を叩く経路を第一級サポートとする。OpenAI は Codex CLI を Apache-2.0 で公開し、ChatGPT OAuth の第三者ツール利用を service terms で名指し禁止していない(互換経路)。
Codex CLI の実装github.com/openai/codex、`codex-rs/login/` 配下)から以下が確定:
- トークンは `~/.codex/auth.json``{ auth_mode, tokens: { id_token, access_token, refresh_token, account_id }, last_refresh }` 形式で保存
- トークンは `~/.codex/auth.json``{ auth_mode, tokens: { id_token (JWT 文字列), access_token (JWT), refresh_token, account_id }, last_refresh }` 形式で保存。`OPENAI_API_KEY` フィールドも同居する場合あり
- リクエストは `https://chatgpt.com/backend-api/v1/responses` に投げるResponses API wire
- 認証ヘッダは `Authorization: Bearer <access_token>` + `ChatGPT-Account-ID: <account_id>`+ FedRAMP 組織で `X-OpenAI-Fedramp: true`
- リフレッシュは `https://auth.openai.com/oauth/token``grant_type=refresh_token`, `client_id=app_EMoamEEZ73f0CkXaXp7hrann`, `refresh_token` を POST
- 認証ヘッダは `Authorization: Bearer <access_token>` + `ChatGPT-Account-Id: <account_id>`FedRAMP 組織なら `X-OpenAI-Fedramp: true`
- リフレッシュは `https://auth.openai.com/oauth/token``{ client_id: "app_EMoamEEZ73f0CkXaXp7hrann", grant_type: "refresh_token", refresh_token }` を JSON POST。response は `{ id_token?, access_token?, refresh_token? }`、401 body の `error.code``refresh_token_expired|reused|invalidated` で永続失敗を分類
- proactive refresh の判定: access_token JWT の `exp` claim が `now` 以下、fallback で `last_refresh < now - 8 days`(バッファなし)
- Codex CLI 自身はファイルロックを取らず、(a) プロセス内 `AsyncMutex` (b) refresh 前の guarded reload (account_id 比較で他プロセスの先行更新を検知) (c) 書込前の再 load + diff merge で並行動作を吸収
- Codex CLI の credentials store はデフォルト `File` (auth.json)。`Keyring` / `Auto` モードは opt-in
## 要件
1. **`AuthRef::CodexOAuth` の追加**: `llm-model-config` で定義する認証列挙型に ChatGPT OAuth バリアントを追加
1. **`AuthRef::CodexOAuth` の追加**: `manifest::AuthRef` に ChatGPT OAuth バリアントを追加型定義済み、provider 側で実装する)
2. **トークン読み取り**: `~/.codex/auth.json` を読み、以下を取り出す
- `tokens.access_token`
- `tokens.refresh_token`
- `tokens.account_id`
- `last_refresh`(期限判定用)
- `tokens.id_token` の JWT claims`chatgpt_account_is_fedramp`、`chatgpt_plan_type` 参照)
- `tokens.access_token` / `tokens.refresh_token` / `tokens.account_id`
- `last_refresh`fallback 期限判定用)
- `tokens.id_token` の JWT payload を base64url decode し `https://api.openai.com/auth` claim から `chatgpt_account_is_fedramp` (bool) と `chatgpt_plan_type` を取得(署名検証なし)
3. **ヘッダ注入**: `HttpTransport``AuthRef::CodexOAuth` を解決するとき以下を組み立てる
- `Authorization: Bearer <access_token>`
- `ChatGPT-Account-ID: <account_id>`
- `ChatGPT-Account-Id: <account_id>`
- FedRAMP 組織なら `X-OpenAI-Fedramp: true`
4. **base_url の自動適用**: `ModelConfig.base_url` 未指定時は `https://chatgpt.com/backend-api` を既定とする。ユーザーが明示的に `https://api.openai.com` を指定した場合は尊重API key 経路と共用したいケース用)
実装方針: llm-worker 側で `ResolvedAuth::Custom(Arc<dyn AuthProvider>)` バリアントと `AuthProvider` traitasync でリクエスト毎にヘッダを返す)を新設し、`HttpTransport` の `AuthRequirement::Custom` 経路から呼ぶ。Codex 固有ロジックauth.json 読取・refresh`crates/provider/src/codex_oauth/` に置く。
4. **base_url の自動適用**: `AuthRef::CodexOAuth` 指定時、`ModelConfig.base_url` 未指定なら `https://chatgpt.com/backend-api` を既定とする。明示指定API key 経路と共用したい場合の `https://api.openai.com` 等)は尊重。`scheme` 側の既定(`https://api.openai.com`)は変えず、`build_client` で auth に応じて差し替える。
5. **トークンリフレッシュ**:
- `https://auth.openai.com/oauth/token``{ client_id: "app_EMoamEEZ73f0CkXaXp7hrann", grant_type: "refresh_token", refresh_token }` を POST
- 有効期限が近いaccess_token が期限切れ or 残り時間が閾値以下)とき自動更新
- 更新後の token を `~/.codex/auth.json` に書き戻す
- 複数プロセスCodex CLI との並行実行等)を想定した排他制御
- 送信前に proactive チェック: access_token JWT の `exp` claim が `now` 以下、fallback で `last_refresh + 8 days < now`。バッファは持たせないCodex 準拠。401 駆動の retry は将来拡張
- refresh は `https://auth.openai.com/oauth/token` に上記 body を POST、response の `access_token` / `id_token` / `refresh_token` を auth.json に書き戻し `last_refresh = now`更新
- 並行動作の排他: プロセス内 `tokio::sync::Mutex` を取った上で、refresh 直前に再 load し account_id が一致しないなら自分はスキップして読み直した値を採用guarded reload。書込時も再 load + diff mergeCodex CLI に揃える、ファイルロックは使わない)
- 失敗時: `RefreshTokenError::Permanent`401 + `refresh_token_expired|reused|invalidated`)は `ClientError::Auth` 相当で「`codex login` を再実行してください」のメッセージ、`Transient` (network 等) はそのまま伝播
6. **scheme/openai_responses との組合せで動作**: `ModelConfig { scheme: OpenAIResponses, base_url: (既定), model_id: "gpt-5-codex" 等, auth: CodexOAuth }` で ChatGPT 枠を使って Codex 相当の動作ができる
7. **完了時の動作**: ChatGPT アカウント保持者が `codex login` 済みの環境で insomnia を起動すると、追加設定なしで Codex と同じモデル(`gpt-5-codex` 等)が利用可能
7. **完了時の動作**: ChatGPT アカウント保持者が `codex login`File モード)済みの環境で insomnia を起動すると、追加設定なしで Codex と同じモデル(`gpt-5-codex` 等)が利用可能
## 設計課題
## 設計判断
### 1. auth.json の書き戻しと競合制御
### auth.json の書き戻しと競合制御
`~/.codex/auth.json` は Codex CLI と共用。両方が並行動作する場面で refresh token が古い値で上書きされると再認証が必要になる。ファイルロックを取るか、更新前に再読み込みして最新値で書くか
ファイルロックは取らない。Codex CLI 自身も取っていない(単純な truncate write + `mode 0o600`)。代わりに guarded reload + 書込前 merge で吸収する。`fs2` 依存を増やさない方が筋が良い
### 2. refresh token の失効時の挙動
### refresh token の失効時の挙動
refresh token も失効するケースAnthropic のサーバ側遮断のようにサブスク側が塞ぐケースで、ユーザーにどう通知するか。insomnia CLI で `codex login` の再実行を促すメッセージを返す
`Permanent` 分類のエラーをそのまま `ClientError::Auth { message }` で返し、CLI/TUI 上で「`codex login` を再実行」のメッセージを表示する。insomnia 側で再ログインフローは持たない
### 3. ChatGPT OAuth 専用モデル
### ChatGPT OAuth 専用モデル (gpt-5-codex 等)
`gpt-5-codex` / `codex-mini-latest` 等、ChatGPT backend でのみ利用可能なモデル ID がある場合、API key 経路と区別が必要。`ModelCapability` で「CodexOAuth でのみ有効」フラグを持つか、API 側の 401 を受けて動的にフォールバックするか
`scheme/openai_responses/capability.rs` の静的テーブルに該当モデル ID を追加するのみ。API key 経路で使われたら OpenAI 側が 401 で弾くので動的フォールバックは不要
### 4. セキュリティと権限
### Keyring モード対応
`~/.codex/auth.json` のパーミッション600を尊重。読み取り時にパーミッション確認 + 書き込み時にパーミッション再設定。
Scope 外。Codex CLI の `cli_auth_credentials_store = "keyring"` で保存しているユーザーには `~/.codex/config.toml``"file"` に切り替えて `codex login` をやり直すよう案内するauth.json 不在エラー時にメッセージで誘導)。
### セキュリティと権限
`~/.codex/auth.json` のパーミッション600を尊重。書き込み時に `OpenOptions``mode(0o600)` を再設定するCodex CLI と同じ)。
## Scope 外
- Claude Pro/Max OAuth 経路(方針上非採用)
- `claude -p` CLI fork 経路
- `codex login` 自体の実装Codex CLI に任せ、insomnia は auth.json を読むのみ)
- Codex CLI の Keyring credentials store 対応
- 401 駆動の動的 refresh + retryproactive のみで実装)
- ChatGPT backend の rate limit 観測Retry-After 処理は HttpTransport 共通の責務)
## 依存
- `tickets/llm-model-config.md``AuthRef` / `HttpTransport` 構造)
- `tickets/llm-scheme-openai-responses.md``/v1/responses` wire format
- `manifest::AuthRef::CodexOAuth`(型のみ定義済み)
- `llm_worker::llm_client::transport::HttpTransport` / `auth::AuthRequirement::Custom`(経路の枠は用意済み)
- `scheme/openai_responses``/v1/responses` wire format、実装済み

View File

@ -0,0 +1,33 @@
# Codex OAuth 認証の流用 — レビュー
判定: **Approved**(ブロッカーなし)
## 指摘事項
### 1. `ensure_fresh` の double-load が冗長(軽微)
`crates/provider/src/codex_oauth/mod.rs` 104 行目と 114 行目で、mutex 保持下に `auth_json::load()` を 2 回呼んでいる。1 回目で stale 判定、2 回目を guarded reload として別プロセス差分検知に使う意図だが、mutex 取得直後に連続 load しているので間隔がほぼゼロで実効的な差分検知になっていない。`persist_refreshed` 内の merge に任せ、`pre_refresh` を使わず `snap` をそのまま refresh 材料にするほうが素直。
判断: 他プロセスとの競合検知を「保険として二段階で絞る」意図なら害はない。このままでも OK。
### 2. `pub use PermanentReason``pub(crate)` に絞る(軽微)
`crates/provider/src/codex_oauth/mod.rs` 36 行目。クレート外利用者がいないので `pub(crate) use` で十分。
判断: 気が向いたら直す。
### 3. AGENTS.md の git 操作指示の追記
本チケット実装と独立した変更。commit 時は別コミットに切り出すか、本コミットで「ついで」と明記する。
判断: commit 作成時の注意事項。実装自体への指摘ではない。
### 4. `Arc::new(CodexAuthProvider)` の二重 Arc軽微
`crates/provider/src/lib.rs``ResolvedAuth::Custom(Arc::new(provider))` としており、`CodexAuthProvider` 自体も内部に `Arc<Mutex<State>>` を持つため Arc が二段。`CodexAuthProvider: Clone` にして単段で扱える余地はある。
判断: MVP として OK。
## 結論
4 件とも軽微な改善候補で、いずれも機能・正しさ・設計方針に影響しない。チケット要件はすべて満たされており、テストも通っている。完了として問題なし。