From 29d318ed36f549ecae3f72371c608e14b8683cee Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:23:12 +0800 Subject: [PATCH] ChatGPT login: read the device code's interval as a number or a numeric string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI's device code endpoint now answers `"interval": "5"` instead of `5`, so reading it as u64 failed and "Sign in on another device" stopped at "The answer to the device code request could not be read: invalid type: string \"5\", expected u64". A new `tw_gateway::lenient::seconds` reads a non-negative integer or a string of digits; anything else counts as not given. It reads the device code's interval (unknown → 0, so polling uses its lower bound), `expires_in` in the ChatGPT and generic OAuth token answers, and Z.ai's `poll_interval_sec` and `expires_at`. Co-Authored-By: Claude Opus 5.5 --- crates/tw-control/src/chatgpt.rs | 24 +++++++++++++-- crates/tw-control/src/zai.rs | 4 +-- crates/tw-gateway/src/chatgpt.rs | 2 +- crates/tw-gateway/src/lenient.rs | 50 ++++++++++++++++++++++++++++++++ crates/tw-gateway/src/lib.rs | 1 + crates/tw-gateway/src/oauth.rs | 2 +- 6 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 crates/tw-gateway/src/lenient.rs diff --git a/crates/tw-control/src/chatgpt.rs b/crates/tw-control/src/chatgpt.rs index 510d3c76..94e267db 100644 --- a/crates/tw-control/src/chatgpt.rs +++ b/crates/tw-control/src/chatgpt.rs @@ -397,8 +397,9 @@ struct DeviceCode { device_auth_id: String, /// 给用户看、让他输进去的码 user_code: String, - /// 服务端让我们隔多少秒问一次 - #[serde(default)] + /// 服务端让我们隔多少秒问一次。**数字和数字字符串都认**:2026-10 起它答的是 `"5"`; + /// 认不出来是 0,轮询时按下限来 + #[serde(default, deserialize_with = "tw_gateway::lenient::seconds_or_zero")] interval: u64, } @@ -1110,6 +1111,25 @@ async fn use_reset( mod tests { use super::*; + /// 2026-10 起设备码接口把 `interval` 答成字符串(`"interval": "5"`),按数字读的话 + /// 整个登录停在「无法识别设备码请求的响应」 + #[test] + fn the_device_code_interval_is_read_as_a_number_or_a_numeric_string() { + let read = |interval: &str| { + let text = format!( + r#"{{"device_auth_id": "deviceauth_1", "user_code": "ABCD-1234"{interval}}}"# + ); + serde_json::from_str::(&text) + .expect("the device code is readable") + .interval + }; + assert_eq!(read(r#", "interval": "5""#), 5); + assert_eq!(read(r#", "interval": 5"#), 5); + // 没给、写成别的样子:0,轮询按下限来 + assert_eq!(read(""), 0); + assert_eq!(read(r#", "interval": "soon""#), 0); + } + #[test] fn only_app_links_are_accepted_as_the_return_address() { assert!(app_link("thinkwatch://login/done")); diff --git a/crates/tw-control/src/zai.rs b/crates/tw-control/src/zai.rs index 01792d06..504f4130 100644 --- a/crates/tw-control/src/zai.rs +++ b/crates/tw-control/src/zai.rs @@ -325,14 +325,14 @@ async fn init(s: &ControlState, want: &Want, http: &reqwest::Client) -> Result Option { + match v { + Value::Number(n) => n.as_u64(), + Value::String(s) => s.trim().parse::().ok(), + _ => None, + } +} + +/// 给 serde 用:字段是数字或数字字符串都读成秒数,认不出来或没给是 0 +pub fn seconds_or_zero<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let v = ::deserialize(d)?; + Ok(seconds(&v).unwrap_or(0)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn a_number_and_a_numeric_string_are_both_seconds() { + assert_eq!(seconds(&json!(5)), Some(5)); + assert_eq!(seconds(&json!("5")), Some(5)); + assert_eq!(seconds(&json!(" 3600 ")), Some(3600)); + } + + #[test] + fn anything_else_is_not_given() { + for v in [ + json!(-1), + json!(1.5), + json!("5s"), + json!(""), + json!(null), + json!(true), + json!([5]), + ] { + assert_eq!(seconds(&v), None, "{v}"); + } + } +} diff --git a/crates/tw-gateway/src/lib.rs b/crates/tw-gateway/src/lib.rs index 7cb53c2f..344e80b7 100644 --- a/crates/tw-gateway/src/lib.rs +++ b/crates/tw-gateway/src/lib.rs @@ -30,6 +30,7 @@ pub mod key_limits; pub mod l1; pub mod l3; pub mod latency; +pub mod lenient; pub mod limits; pub mod listen; pub mod live; diff --git a/crates/tw-gateway/src/oauth.rs b/crates/tw-gateway/src/oauth.rs index 0061b30f..20ad938e 100644 --- a/crates/tw-gateway/src/oauth.rs +++ b/crates/tw-gateway/src/oauth.rs @@ -602,7 +602,7 @@ async fn exchange(cfg: &OAuth, refresh: &str, http: &reqwest::Client) -> Result< .to_string(); Ok(Fresh { access, - expires_in: v.get("expires_in").and_then(|x| x.as_u64()), + expires_in: v.get("expires_in").and_then(crate::lenient::seconds), refresh: v .get("refresh_token") .and_then(|x| x.as_str())