codexのOAuthを使う実装
This commit is contained in:
parent
24ade197d1
commit
6c6eb0dcb6
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
---
|
||||
|
||||
Gitは基本的にすべてユーザーが操作している。書き込みが必要な操作は明示的に許可されない限り行わないこと
|
||||
|
||||
---
|
||||
|
||||
`TODO.md`、`tickets/`はgitで管理されていて、時系列の管理はgitを参照して把握すること。
|
||||
|
||||
### TODO.md
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
---
|
||||
|
||||
Gitは基本的にすべてユーザーが操作している。書き込みが必要な操作は明示的に許可されない限り行わないこと
|
||||
|
||||
---
|
||||
|
||||
`TODO.md`、`tickets/`はgitで管理されていて、時系列の管理はgitを参照して把握すること。
|
||||
|
||||
### TODO.md
|
||||
|
|
|
|||
557
Cargo.lock
generated
557
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
2
TODO.md
2
TODO.md
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
265
crates/provider/src/codex_oauth/auth_json.rs
Normal file
265
crates/provider/src/codex_oauth/auth_json.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
72
crates/provider/src/codex_oauth/error.rs
Normal file
72
crates/provider/src/codex_oauth/error.rs
Normal 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."),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
104
crates/provider/src/codex_oauth/jwt.rs
Normal file
104
crates/provider/src/codex_oauth/jwt.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
334
crates/provider/src/codex_oauth/mod.rs
Normal file
334
crates/provider/src/codex_oauth/mod.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
134
crates/provider/src/codex_oauth/refresh.rs
Normal file
134
crates/provider/src/codex_oauth/refresh.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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` trait(async でリクエスト毎にヘッダを返す)を新設し、`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 merge(Codex 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 + retry(proactive のみで実装)
|
||||
- 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、実装済み)
|
||||
|
|
|
|||
33
tickets/llm-auth-codex-oauth.review.md
Normal file
33
tickets/llm-auth-codex-oauth.review.md
Normal 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 件とも軽微な改善候補で、いずれも機能・正しさ・設計方針に影響しない。チケット要件はすべて満たされており、テストも通っている。完了として問題なし。
|
||||
Loading…
Reference in New Issue
Block a user