Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions crates/tw-control/src/chatgpt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down Expand Up @@ -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::<DeviceCode>(&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"));
Expand Down
4 changes: 2 additions & 2 deletions crates/tw-control/src/zai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,14 +325,14 @@ async fn init(s: &ControlState, want: &Want, http: &reqwest::Client) -> Result<F
}
let every = d
.get("poll_interval_sec")
.and_then(|v| v.as_u64())
.and_then(tw_gateway::lenient::seconds)
.map(Duration::from_secs)
.unwrap_or(POLL_MIN)
.max(POLL_MIN);
// 平台给的是绝对时刻(秒)。给得比我们的上限还长时按我们的来
let ttl = d
.get("expires_at")
.and_then(|v| v.as_u64())
.and_then(tw_gateway::lenient::seconds)
.map(|at| Duration::from_secs(at.saturating_sub(now_secs())))
.filter(|d| !d.is_zero())
.map_or(LOGIN_TTL, |d| d.min(LOGIN_TTL));
Expand Down
2 changes: 1 addition & 1 deletion crates/tw-gateway/src/chatgpt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,7 @@ pub async fn exchange_code(
id_token: field("id_token").ok_or_else(|| missing("id_token"))?,
access: field("access_token").ok_or_else(|| missing("access_token"))?,
refresh: field("refresh_token").ok_or_else(|| missing("refresh_token"))?,
expires_in: v.get("expires_in").and_then(|x| x.as_u64()),
expires_in: v.get("expires_in").and_then(crate::lenient::seconds),
})
}

Expand Down
50 changes: 50 additions & 0 deletions crates/tw-gateway/src/lenient.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//! 对方答的 JSON 里,**数字有时写成字符串**。
//!
//! OpenAI 的设备码接口 2026-10 起把 `interval` 从 `5` 改成了 `"5"`,按 `u64` 读的整个登录
//! 就报「无法识别」停在那儿。这类「隔几秒」「几秒后过期」的字段两种写法都认;认不出来的
//! 当没给,由调用方用自己的默认值 —— 一个轮询间隔写成了别的样子,不该让登录走不下去。

use serde_json::Value;

/// 秒数:非负整数,或者只由数字组成的字符串(前后空白不算)。别的写法是 `None`
pub fn seconds(v: &Value) -> Option<u64> {
match v {
Value::Number(n) => n.as_u64(),
Value::String(s) => s.trim().parse::<u64>().ok(),
_ => None,
}
}

/// 给 serde 用:字段是数字或数字字符串都读成秒数,认不出来或没给是 0
pub fn seconds_or_zero<'de, D: serde::Deserializer<'de>>(d: D) -> Result<u64, D::Error> {
let v = <Value as serde::Deserialize>::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}");
}
}
}
1 change: 1 addition & 0 deletions crates/tw-gateway/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion crates/tw-gateway/src/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Loading