diff --git a/AGENTS.md b/AGENTS.md index 8fc1f6a..b340680 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ CommandCode Go 走 JSON API 抓取用量数据,Codex / ChatGPT Plus 通过 aut - ✅ DeepSeek — REST API `GET /user/balance`,API Key 认证 - ✅ OpenCode Go — OkHttp 抓 dashboard HTML,解析 `rollingUsage`/`weeklyUsage`/`monthlyUsage` - ✅ CommandCode Go — JSON API 抓取用量数据,`CommandCodeUsageRepository` 解析(workspaceId = `"commandcode"`) -- ✅ Codex / ChatGPT Plus — 粘贴完整 auth.json(含 refresh_token),调 `chatgpt.com/backend-api/wham/usage`;token 过期前 60 分钟自动刷新 +- ✅ Codex / ChatGPT Plus — 支持 **OAuth PKCE 登录(无头模式)** 或粘贴完整 auth.json(含 refresh_token),调 `chatgpt.com/backend-api/wham/usage`;token 过期前 60 分钟自动刷新。OpenAI 采用 refresh_token 单次轮换机制,被外部工具使用后旧 token 立即失效,需重新 OAuth 登录或导入新 auth.json。 - ✅ Ollama Pro — Cookie 认证,OkHttp 抓 `ollama.com/settings` HTML,正则解析 plan/session(5h)/weekly 百分比 + `data-time` 重置时间 + `data-model` 模型级请求次数;无官方 API(ollama/ollama#12532) - ✅ 文案统一:所有服务标签均使用中文("每周"统一代替 "weekly"/"Weekly"/"weekly") - ❌ OpenCode Zen / 小米 MiMo — 未实现 @@ -37,6 +37,10 @@ CommandCode Go 走 JSON API 抓取用量数据,Codex / ChatGPT Plus 通过 aut - ✅ `UsageDataViewModel` — 原始记录分页浏览(20条/页),支持时间+模型筛选,页码输入跳转 - ✅ 全局刷新绑定 — Dashboard 下拉刷新 → `DashboardViewModel.refresh()` → `UsageViewModel.sync()`(增量) +**调试与错误诊断**: +- ✅ `DebugLog` — 内存 ring buffer(200 条,线程安全),所有 Repository 关键路径(网络请求、Token 刷新、解析错误)均写入日志;设置页提供「调试日志」入口,无需连电脑即可在 APP 内查看 ERROR/WARN/INFO 三级日志。 +- ✅ `RepositoryError.InvalidCredential` 支持自定义 `detail`,Codex `RefreshResult` 密封类明确区分刷新成功/失败原因,错误信息可透传到 Dashboard 卡片与调试日志。 + **凭据回显红线**: > ⚠️ `CredentialEditViewModel.load()` 首次加载已有凭据时,需针对每种 `Credential` 子类显式编写回显分支。 diff --git a/README.md b/README.md index 4e45121..0c5f384 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go | 💡 **使用小技巧** | 首页随机展示一条操作提示(每次启动刷新);设置页可查看全部 13 条隐藏操作技巧 | | ⚡ **Room 数据库** | 用量记录存 Room(indexed on workspaceId+timeCreated),DAO 查询替代全量 JSON 序列化;首次启动自动从旧 DataStore 迁移 | | 🎀 **雨晴粉主题** | Material Design 3 · 草莓粉 #FF85A2 · 樱粉 #FFD1DC | +| 🔐 **Codex OAuth 登录** | 无头模式 OAuth PKCE:APP 生成授权链接 → 外部浏览器登录 → 粘贴回调 URL 完成授权,无需手动导出 auth.json | +| 🐛 **调试日志** | APP 内「调试日志」页面,查看 Repository 网络请求、Token 刷新、解析错误等详细日志,无需连接电脑 | --- @@ -57,7 +59,7 @@ Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go 前往 [Releases](https://github.com/CATMIAOZHI/Rainytoken/releases) 下载最新 APK。 -> ⚠️ 需要配置 DeepSeek API Key、OpenCode Go 登录凭据、CommandCode Go API Key、Codex auth.json 或 Ollama Pro Cookie 才能拉取数据。 +> ⚠️ 需要配置 DeepSeek API Key、OpenCode Go 登录凭据、CommandCode Go API Key、Codex(OAuth 登录或粘贴 auth.json)或 Ollama Pro Cookie 才能拉取数据。 --- diff --git a/app/src/main/java/com/rainy/token/data/debug/DebugLog.kt b/app/src/main/java/com/rainy/token/data/debug/DebugLog.kt new file mode 100644 index 0000000..3c9e7db --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/debug/DebugLog.kt @@ -0,0 +1,51 @@ +package com.rainy.token.data.debug + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale +import java.util.concurrent.ConcurrentLinkedDeque + +/** + * APP 内调试日志,内存 ring buffer(默认 200 条)。 + * 各 Repository 在关键路径写入,用户可在设置页 → 调试日志 查看。 + */ +object DebugLog { + + enum class Level(val label: String) { INFO("INFO"), WARN("WARN"), ERROR("ERROR") } + + data class Entry( + val timestamp: Long, + val tag: String, + val level: Level, + val message: String + ) { + override fun toString(): String { + val fmt = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.US) + return "${fmt.format(Calendar.getInstance().apply { timeInMillis = this@Entry.timestamp }.time)} ${level.label}/$tag: $message" + } + } + + private const val MAX_SIZE = 200 + private val deque = ConcurrentLinkedDeque() + private val _entries = MutableStateFlow>(emptyList()) + val entries: StateFlow> = _entries.asStateFlow() + + fun log(tag: String, level: Level, message: String) { + val entry = Entry(System.currentTimeMillis(), tag, level, message) + deque.addFirst(entry) + while (deque.size > MAX_SIZE) deque.pollLast() + _entries.value = deque.toList() + } + + fun i(tag: String, message: String) = log(tag, Level.INFO, message) + fun w(tag: String, message: String) = log(tag, Level.WARN, message) + fun e(tag: String, message: String) = log(tag, Level.ERROR, message) + + fun clear() { + deque.clear() + _entries.value = emptyList() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/data/repository/CodexOAuthHelper.kt b/app/src/main/java/com/rainy/token/data/repository/CodexOAuthHelper.kt new file mode 100644 index 0000000..aecf47f --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/repository/CodexOAuthHelper.kt @@ -0,0 +1,175 @@ +package com.rainy.token.data.repository + +import android.util.Base64 +import com.rainy.token.data.debug.DebugLog +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.security.MessageDigest +import java.security.SecureRandom + +/** + * Codex / ChatGPT Plus OAuth PKCE 辅助工具。 + * + * 参考:7shi/codex-oauth 的 Python 实现,移植到 Kotlin/Android。 + * 流程: + * 1. 生成 code_verifier(随机 96 字节 base64url) + * 2. 计算 code_challenge = SHA256(code_verifier) base64url 无填充 + * 3. 构建 auth URL → 用户在 WebView 中登录 + * 4. 拦截 localhost:1455/auth/callback?code=xxx&state=xxx + * 5. POST token endpoint 用 code + code_verifier 换 access/refresh token + * 6. 从 JWT (id_token / access_token) 提取 chatgpt_account_id + */ +object CodexOAuthHelper { + + private const val TAG = "CodexOAuth" + + const val AUTH_URL = "https://auth.openai.com/oauth/authorize" + const val TOKEN_URL = "https://auth.openai.com/oauth/token" + const val CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" + const val REDIRECT_URI = "http://localhost:1455/auth/callback" + const val SCOPE = "openid profile email offline_access" + const val CALLBACK_PREFIX = "http://localhost:1455/auth/callback" + + private val json = Json { ignoreUnknownKeys = true } + + /** PKCE 参数对 */ + data class PkcePair(val codeVerifier: String, val codeChallenge: String) + + /** 生成 PKCE code_verifier 和 code_challenge (S256) */ + fun generatePkce(): PkcePair { + val randomBytes = ByteArray(96) + SecureRandom().nextBytes(randomBytes) + val codeVerifier = Base64.encodeToString( + randomBytes, + Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP + ) + val digest = MessageDigest.getInstance("SHA-256").digest(codeVerifier.toByteArray()) + val codeChallenge = Base64.encodeToString( + digest, + Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP + ) + return PkcePair(codeVerifier, codeChallenge) + } + + /** 生成随机 state(CSRF 防护) */ + fun generateState(): String { + val bytes = ByteArray(32) + SecureRandom().nextBytes(bytes) + return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + } + + /** 构建授权 URL */ + fun buildAuthUrl(codeChallenge: String, state: String): String { + val params = mapOf( + "response_type" to "code", + "client_id" to CLIENT_ID, + "redirect_uri" to REDIRECT_URI, + "scope" to SCOPE, + "code_challenge" to codeChallenge, + "code_challenge_method" to "S256", + "state" to state, + "id_token_add_organizations" to "true", + "codex_cli_simplified_flow" to "true", + "originator" to "opencode" + ) + // 手动拼 URL(不用 URLEncoder.encode,因为 OAuth 参数不需要编码特殊字符) + val query = params.entries.joinToString("&") { (k, v) -> + "$k=${java.net.URLEncoder.encode(v, "UTF-8")}" + } + return "$AUTH_URL?$query" + } + + /** Token 交换响应 */ + @Serializable + data class TokenResponse( + @kotlinx.serialization.SerialName("access_token") val accessToken: String, + @kotlinx.serialization.SerialName("refresh_token") val refreshToken: String? = null, + @kotlinx.serialization.SerialName("expires_in") val expiresIn: Long = 3600, + @kotlinx.serialization.SerialName("id_token") val idToken: String? = null, + @kotlinx.serialization.SerialName("token_type") val tokenType: String? = null + ) + + /** 用 authorization code 换 token */ + fun exchangeCode( + okHttpClient: OkHttpClient, + code: String, + codeVerifier: String + ): TokenResponse? { + val formBody = buildString { + append("grant_type=authorization_code") + append("&code=").append(java.net.URLEncoder.encode(code, "UTF-8")) + append("&redirect_uri=").append(java.net.URLEncoder.encode(REDIRECT_URI, "UTF-8")) + append("&client_id=").append(CLIENT_ID) + append("&code_verifier=").append(codeVerifier) + }.toRequestBody("application/x-www-form-urlencoded".toMediaType()) + + val request = Request.Builder() + .url(TOKEN_URL) + .header("Content-Type", "application/x-www-form-urlencoded") + .post(formBody) + .build() + + return try { + okHttpClient.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) { + val errorBody = resp.body?.string() + DebugLog.e(TAG, "token exchange failed: HTTP ${resp.code} | $errorBody") + return@use null + } + val body = resp.body?.string() ?: return@use null + json.decodeFromString(TokenResponse.serializer(), body) + } + } catch (e: Exception) { + DebugLog.e(TAG, "token exchange exception: ${e::class.simpleName}: ${e.message}") + null + } + } + + /** + * 从 JWT 中提取 chatgpt_account_id(不验证签名,只读 payload)。 + * 3 级回退: + * 1. payload.chatgpt_account_id + * 2. payload["https://api.openai.com/auth"].chatgpt_account_id + * 3. payload.organizations[0].id + */ + fun extractAccountId(idToken: String?, accessToken: String?): String? { + for (token in listOfNotNull(idToken, accessToken)) { + val payload = decodeJWTPayload(token) ?: continue + // 1. top-level + payload["chatgpt_account_id"]?.jsonPrimitive?.content?.let { return it } + // 2. nested namespace + payload["https://api.openai.com/auth"]?.jsonObject + ?.get("chatgpt_account_id")?.jsonPrimitive?.content?.let { return it } + // 3. organizations[0].id + (payload["organizations"] as? kotlinx.serialization.json.JsonArray) + ?.firstOrNull() + ?.let { it as? JsonObject } + ?.get("id")?.jsonPrimitive?.content?.let { return it } + } + return null + } + + /** 解码 JWT payload(第二段),不验证签名 */ + private fun decodeJWTPayload(jwt: String): JsonObject? { + return try { + val parts = jwt.split(".") + if (parts.size < 2) return null + val payloadB64 = parts[1] + // base64url decode,补 padding + val decoded = Base64.decode( + payloadB64, + Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP + ) + json.parseToJsonElement(String(decoded)).jsonObject + } catch (_: Exception) { + null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt index 95c7299..3193c82 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt @@ -18,6 +18,8 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import android.util.Log +import com.rainy.token.data.debug.DebugLog import java.io.IOException import javax.inject.Singleton @@ -34,33 +36,51 @@ class CodexRepository( private const val OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token" private const val CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" private const val REFRESH_BUFFER_MS = 60L * 60 * 1000 + private const val TAG = "Codex" } suspend fun fetchBalance(): Result = withContext(Dispatchers.IO) { val credential = credentialRepository.get(ServiceType.CODEX) - ?: return@withContext Result.failure(RepositoryError.InvalidCredential()) + ?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 Codex 凭据")) if (credential !is Credential.CodexCredential) - return@withContext Result.failure(RepositoryError.InvalidCredential()) + return@withContext Result.failure(RepositoryError.InvalidCredential("凭据类型不匹配")) val effectiveCred = if (tokenNeedsRefresh(credential)) { - val refreshed = refreshToken(credential) - if (refreshed != null) { credentialRepository.save(refreshed); refreshed } else credential + DebugLog.i(TAG, "access_token 即将过期,尝试刷新(expiresAt=${credential.expiresAt})") + when (val r = refreshToken(credential)) { + is RefreshResult.Success -> { + DebugLog.i(TAG, "token 刷新成功,新 expiresAt=${r.cred.expiresAt}") + credentialRepository.save(r.cred); r.cred + } + is RefreshResult.Failure -> { + DebugLog.e(TAG, "token 主动刷新失败: ${r.reason}") + credential + } + } } else credential val usageResult = try { fetchJson(WHAM_USAGE, effectiveCred.accessToken) } catch (e: IOException) { + DebugLog.e(TAG, "网络异常: ${e.message}") return@withContext Result.failure(RepositoryError.Network(e)) } catch (e: RepositoryError) { if (e is RepositoryError.InvalidCredential && effectiveCred == credential) { - val retry = refreshToken(credential) - if (retry != null) { - credentialRepository.save(retry) - try { fetchJson(WHAM_USAGE, retry.accessToken) } - catch (e2: RepositoryError) { return@withContext Result.failure(e2) } - catch (e2: IOException) { return@withContext Result.failure(RepositoryError.Network(e2)) } - catch (e2: Throwable) { return@withContext Result.failure(RepositoryError.Unknown(e2)) } - } else return@withContext Result.failure(e) + DebugLog.w(TAG, "401 收到,尝试用 refresh_token 二次刷新") + when (val r = refreshToken(credential)) { + is RefreshResult.Success -> { + DebugLog.i(TAG, "二次刷新成功") + credentialRepository.save(r.cred) + try { fetchJson(WHAM_USAGE, r.cred.accessToken) } + catch (e2: RepositoryError) { return@withContext Result.failure(e2) } + catch (e2: IOException) { return@withContext Result.failure(RepositoryError.Network(e2)) } + catch (e2: Throwable) { return@withContext Result.failure(RepositoryError.Unknown(e2)) } + } + is RefreshResult.Failure -> { + DebugLog.e(TAG, "二次刷新也失败: ${r.reason}") + return@withContext Result.failure(RepositoryError.InvalidCredential(r.reason)) + } + } } else return@withContext Result.failure(e) } catch (e: Throwable) { return@withContext Result.failure(RepositoryError.Unknown(e)) } @@ -89,20 +109,63 @@ class CodexRepository( private fun tokenNeedsRefresh(cred: Credential.CodexCredential): Boolean = System.currentTimeMillis() >= cred.expiresAt - REFRESH_BUFFER_MS - private fun refreshToken(cred: Credential.CodexCredential): Credential.CodexCredential? { - val bodyStr = json.encodeToString(OAuthRefreshRequest.serializer(), - OAuthRefreshRequest("refresh_token", cred.refreshToken, CLIENT_ID, "openid profile email")) + private sealed class RefreshResult { + data class Success(val cred: Credential.CodexCredential) : RefreshResult() + data class Failure(val reason: String) : RefreshResult() + } + + private fun refreshToken(cred: Credential.CodexCredential): RefreshResult { + // OpenAI auth endpoint 要求 form-urlencoded,不能用 JSON(否则返回 401) + val formBody = "grant_type=refresh_token&refresh_token=${cred.refreshToken}&client_id=$CLIENT_ID" + .toRequestBody("application/x-www-form-urlencoded".toMediaType()) val request = Request.Builder().url(OAUTH_TOKEN_URL) - .header("Content-Type", "application/json") - .post(bodyStr.toRequestBody("application/json".toMediaType())).build() + .header("Content-Type", "application/x-www-form-urlencoded") + .post(formBody).build() return try { okHttpClient.newCall(request).execute().use { resp -> - if (!resp.isSuccessful) return@use null - val tr = json.decodeFromString(OAuthRefreshResponse.serializer(), resp.body?.string() ?: return@use null) - cred.copy(accessToken = tr.accessToken, refreshToken = tr.refreshToken, - expiresAt = System.currentTimeMillis() + tr.expiresIn * 1000L, lastVerifiedAt = System.currentTimeMillis()) + if (!resp.isSuccessful) { + val errorBody = resp.body?.string() + Log.w("CodexRepository", "token refresh failed: HTTP ${resp.code} ${resp.message} body=$errorBody") + DebugLog.e(TAG, "token refresh failed: HTTP ${resp.code} ${resp.message}" + + (errorBody?.take(200)?.let { " | $it" } ?: "")) + // 解析 OpenAI 错误响应,给出用户可读的提示 + val reason = parseRefreshError(resp.code, errorBody) + return@use RefreshResult.Failure(reason) + } + val bodyStr = resp.body?.string() ?: return@use RefreshResult.Failure("响应体为空") + val tr = json.decodeFromString(OAuthRefreshResponse.serializer(), bodyStr) + // OpenAI 轮换 refresh_token:响应中可能含新 refresh_token,也可能不含(不轮换时) + RefreshResult.Success(cred.copy( + accessToken = tr.accessToken, + refreshToken = tr.refreshToken ?: cred.refreshToken, + expiresAt = System.currentTimeMillis() + tr.expiresIn * 1000L, + lastVerifiedAt = System.currentTimeMillis() + )) } - } catch (e: Exception) { null } + } catch (e: Exception) { + Log.w("CodexRepository", "token refresh exception: ${e::class.simpleName}: ${e.message}") + DebugLog.e(TAG, "token refresh exception: ${e::class.simpleName}: ${e.message}") + RefreshResult.Failure("网络异常: ${e::class.simpleName}: ${e.message}") + } + } + + /** 把 OpenAI token endpoint 的错误响应翻译成用户可读的中文提示 */ + private fun parseRefreshError(httpCode: Int, errorBody: String?): String { + if (errorBody == null) return "HTTP $httpCode,无错误详情" + // 尝试提取 error.message 字段 + val msg = try { + json.parseToJsonElement(errorBody).jsonObject["error"]?.jsonObject?.get("message")?.jsonPrimitive?.content + } catch (_: Exception) { null } + return when { + msg != null && msg.contains("already been used") -> + "refresh_token 已被使用(被其他工具轮换),请重新导出 auth.json 并导入" + msg != null && msg.contains("sign in", ignoreCase = true) -> + "refresh_token 已失效,请重新登录获取新 auth.json" + httpCode == 401 && msg != null -> "认证失败: $msg" + httpCode == 401 -> "认证失败 (HTTP 401),refresh_token 可能已过期" + httpCode == 400 && msg != null -> "请求参数错误: $msg" + else -> "HTTP $httpCode: ${msg ?: errorBody.take(100)}" + } } private fun fetchJson(url: String, token: String): JsonObject { @@ -115,7 +178,11 @@ class CodexRepository( .header("Authorization", "Bearer $token").get().build() val resp = okHttpClient.newCall(request).execute() resp.use { - if (!it.isSuccessful) throw if (it.code in listOf(401, 403)) RepositoryError.InvalidCredential() else RepositoryError.ServerError(it.code) + if (!it.isSuccessful) { + Log.w("CodexRepository", "fetchJson failed: HTTP ${it.code} ${it.message} url=$url") + DebugLog.e(TAG, "fetchJson failed: HTTP ${it.code} ${it.message} url=$url") + throw if (it.code in listOf(401, 403)) RepositoryError.InvalidCredential("HTTP ${it.code}") else RepositoryError.ServerError(it.code) + } return json.parseToJsonElement(it.body?.string() ?: throw RepositoryError.ParseError("响应体为空")).jsonObject } } @@ -142,6 +209,5 @@ class CodexRepository( private fun durationLabel(seconds: Long?): String = when { seconds == null -> "Usage"; seconds / 60.0 >= 10079 -> "每周"; seconds / 60.0 >= 1439 -> "${(seconds / 86400).toInt()}d"; seconds / 60.0 >= 60 -> "${(seconds / 3600).toInt()}h"; else -> "${maxOf(1, (seconds / 60).toInt())}m" } - @Serializable data class OAuthRefreshRequest(@kotlinx.serialization.SerialName("grant_type") val grantType: String, @kotlinx.serialization.SerialName("refresh_token") val refreshToken: String, @kotlinx.serialization.SerialName("client_id") val clientId: String, val scope: String) - @Serializable data class OAuthRefreshResponse(@kotlinx.serialization.SerialName("access_token") val accessToken: String, @kotlinx.serialization.SerialName("refresh_token") val refreshToken: String, @kotlinx.serialization.SerialName("expires_in") val expiresIn: Long, @kotlinx.serialization.SerialName("token_type") val tokenType: String? = null) + @Serializable data class OAuthRefreshResponse(@kotlinx.serialization.SerialName("access_token") val accessToken: String, @kotlinx.serialization.SerialName("refresh_token") val refreshToken: String? = null, @kotlinx.serialization.SerialName("expires_in") val expiresIn: Long = 3600, @kotlinx.serialization.SerialName("token_type") val tokenType: String? = null) } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt index 61c6674..12ea98b 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt @@ -99,7 +99,7 @@ class CommandCodeUsageRepository( if (resp.code == 401 || resp.code == 403) { val detail = if (body != null && body.length < 200) ":$body" else "" return@withContext Result.failure(RepositoryError.InvalidCredential( - RuntimeException("HTTP ${resp.code}$detail") + "HTTP ${resp.code}$detail" )) } return@withContext Result.failure(RepositoryError.ServerError(resp.code)) diff --git a/app/src/main/java/com/rainy/token/data/repository/DeepSeekRepository.kt b/app/src/main/java/com/rainy/token/data/repository/DeepSeekRepository.kt index b88cab3..5542295 100644 --- a/app/src/main/java/com/rainy/token/data/repository/DeepSeekRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/DeepSeekRepository.kt @@ -76,7 +76,7 @@ class DeepSeekRepository @Inject constructor( } private fun mapHttpError(e: HttpException): RepositoryError = when (e.code()) { - 401, 403 -> RepositoryError.InvalidCredential(e) + 401, 403 -> RepositoryError.InvalidCredential(cause = e) 429 -> { val retryAfter = e.response()?.headers()?.get("Retry-After")?.toLongOrNull() RepositoryError.RateLimited(retryAfter) diff --git a/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt b/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt index ea944d1..c6ed324 100644 --- a/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt +++ b/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt @@ -5,8 +5,9 @@ package com.rainy.token.data.repository */ sealed class RepositoryError(message: String, cause: Throwable? = null) : Exception(message, cause) { - /** 凭据无效(如 401 Unauthorized) */ - class InvalidCredential(cause: Throwable? = null) : RepositoryError("凭据无效", cause) + /** 凭据无效(如 401 Unauthorized),detail 为具体原因 */ + class InvalidCredential(detail: String? = null, cause: Throwable? = null) : + RepositoryError("凭据无效" + (detail?.let { ": $it" } ?: ""), cause) /** 限流(429 Too Many Requests) */ class RateLimited(val retryAfterSeconds: Long? = null) : diff --git a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt index 710d17c..06dc033 100644 --- a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt +++ b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt @@ -35,6 +35,7 @@ import androidx.navigation.navArgument import com.rainy.token.data.repository.CommandCodeUsageRepository import com.rainy.token.domain.service.ServiceType import com.rainy.token.ui.components.rememberWindowSizeClass +import com.rainy.token.ui.components.DebugLogScreen import com.rainy.token.ui.components.TipsScreen import com.rainy.token.ui.dashboard.DashboardScreen import com.rainy.token.ui.dashboard.UsageChartViewModel @@ -48,6 +49,7 @@ import com.rainy.token.ui.settings.CredentialEditScreen import com.rainy.token.ui.settings.SettingsScreen import com.rainy.token.ui.theme.inkMuted import com.rainy.token.ui.theme.StrawberryPink +import com.rainy.token.ui.webview.CodexOAuthScreen import com.rainy.token.ui.webview.WebViewLoginScreen /** @@ -63,6 +65,8 @@ object Routes { const val DASHBOARD = "dashboard" const val SETTINGS = "settings" const val TIPS = "tips" + const val DEBUG_LOG = "debug_log" + const val CODEX_OAUTH = "codex_oauth" const val CREDENTIAL_EDIT = "credential_edit/{type}" fun credentialEdit(type: ServiceType) = "credential_edit/${type.name}" const val WEBVIEW_LOGIN = "webview_login/{type}" @@ -243,12 +247,22 @@ private fun CompactNavHost() { SettingsScreen( onBack = guardedPop, onEditCredential = { type -> navController.navigate(Routes.credentialEdit(type)) }, - onOpenTips = { navController.navigate(Routes.TIPS) } + onOpenTips = { navController.navigate(Routes.TIPS) }, + onOpenDebugLog = { navController.navigate(Routes.DEBUG_LOG) } ) } composable(Routes.TIPS) { TipsScreen(onBack = guardedPop) } + composable(Routes.DEBUG_LOG) { + DebugLogScreen(onBack = guardedPop) + } + composable(Routes.CODEX_OAUTH) { + CodexOAuthScreen( + onBack = guardedPop, + onSuccess = guardedPop + ) + } composable( route = Routes.CREDENTIAL_EDIT, arguments = listOf(navArgument("type") { type = NavType.StringType }) @@ -258,7 +272,8 @@ private fun CompactNavHost() { service = type, onBack = guardedPop, onStartWebViewLogin = { service -> navController.navigate(Routes.webviewLogin(service)) }, - onWebViewLoginSuccess = { } + onWebViewLoginSuccess = { }, + onStartCodexOAuth = { navController.navigate(Routes.CODEX_OAUTH) } ) } composable( @@ -422,12 +437,22 @@ private fun ExpandedDetailPane( onEditCredential = { type -> settingsNavController.navigate(Routes.credentialEdit(type)) }, - onOpenTips = { settingsNavController.navigate("tips") } + onOpenTips = { settingsNavController.navigate("tips") }, + onOpenDebugLog = { settingsNavController.navigate("debug_log") } ) } composable("tips") { TipsScreen(onBack = { settingsNavController.popBackStack() }) } + composable("debug_log") { + DebugLogScreen(onBack = { settingsNavController.popBackStack() }) + } + composable(Routes.CODEX_OAUTH) { + CodexOAuthScreen( + onBack = { settingsNavController.popBackStack() }, + onSuccess = { settingsNavController.popBackStack() } + ) + } composable( route = Routes.CREDENTIAL_EDIT, arguments = listOf(navArgument("type") { type = NavType.StringType }) @@ -439,7 +464,8 @@ private fun ExpandedDetailPane( onStartWebViewLogin = { svc -> settingsNavController.navigate(Routes.webviewLogin(svc)) }, - onWebViewLoginSuccess = { } + onWebViewLoginSuccess = { }, + onStartCodexOAuth = { settingsNavController.navigate(Routes.CODEX_OAUTH) } ) } composable( diff --git a/app/src/main/java/com/rainy/token/ui/components/DebugLogScreen.kt b/app/src/main/java/com/rainy/token/ui/components/DebugLogScreen.kt new file mode 100644 index 0000000..2a180b2 --- /dev/null +++ b/app/src/main/java/com/rainy/token/ui/components/DebugLogScreen.kt @@ -0,0 +1,150 @@ +package com.rainy.token.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.rainy.token.data.debug.DebugLog +import com.rainy.token.ui.theme.InkMuted +import com.rainy.token.ui.theme.StrawberryPink + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DebugLogScreen(onBack: () -> Unit) { + val entries by DebugLog.entries.collectAsStateWithLifecycle() + + Scaffold( + containerColor = Color.Transparent, + topBar = { + TopAppBar( + title = { + Text( + "调试日志", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.Filled.ArrowBack, + contentDescription = "返回", + tint = StrawberryPink + ) + } + }, + actions = { + TextButton(onClick = { DebugLog.clear() }) { + Text("清空", color = StrawberryPink) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent + ) + ) + } + ) { innerPadding -> + if (entries.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center + ) { + Text( + "暂无日志记录\n刷新 Codex 用量后这里会出现调试信息", + style = MaterialTheme.typography.bodyMedium, + color = InkMuted, + modifier = Modifier.padding(32.dp) + ) + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 16.dp), + contentPadding = PaddingValues(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + items(entries, key = { it.timestamp.toString() + it.tag + it.message }) { entry -> + val levelColor = when (entry.level) { + DebugLog.Level.INFO -> InkMuted + DebugLog.Level.WARN -> Color(0xFFFFA726) + DebugLog.Level.ERROR -> Color(0xFFE91E63) + } + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = entry.level.label, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = levelColor + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = entry.tag, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = InkMuted + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = entry.toString().substringBefore(' '), + style = MaterialTheme.typography.labelSmall, + color = InkMuted.copy(alpha = 0.6f) + ) + } + Spacer(modifier = Modifier.padding(top = 4.dp)) + Text( + text = entry.message, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt index 0ad12a2..2671b83 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.material.icons.filled.Refresh import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -137,8 +138,8 @@ fun ServiceDetailScreen( item { CommandCodeGoUsageCard(uiState.state) } } ServiceType.CODEX -> { - // Codex 详情:暂无专用详情卡片,用通用余额展示即可 - } + item { CodexUsageCard(uiState.state) } + } ServiceType.OLLAMA -> { item { OllamaUsageCard(uiState.state) } } @@ -211,7 +212,7 @@ private fun CommandCodeGoUsageCard(state: State) { val fiveHourUsed = extras["fiveHour.used"]?.toDoubleOrNull() val fiveHourCap = extras["fiveHour.cap"]?.toDoubleOrNull() if (fiveHourUsed != null && fiveHourCap != null && fiveHourCap > 0) { - val pct = ((fiveHourUsed / fiveHourCap) * 100).toInt().coerceIn(0, 100) + val pct = ((fiveHourUsed / fiveHourCap) * 100).toFloat().coerceIn(0f, 100f) UsageWindowRow( label = "5 小时滚动", pct = pct, @@ -226,7 +227,7 @@ private fun CommandCodeGoUsageCard(state: State) { val weeklyUsed = extras["weekly.used"]?.toDoubleOrNull() val weeklyCap = extras["weekly.cap"]?.toDoubleOrNull() if (weeklyUsed != null && weeklyCap != null && weeklyCap > 0) { - val pct = ((weeklyUsed / weeklyCap) * 100).toInt().coerceIn(0, 100) + val pct = ((weeklyUsed / weeklyCap) * 100).toFloat().coerceIn(0f, 100f) UsageWindowRow( label = "本周", pct = pct, @@ -240,7 +241,7 @@ private fun CommandCodeGoUsageCard(state: State) { // 3. 本月(最下面) if (monthlyTotal != null && monthlyTotal > 0) { val used = monthlyTotal - monthlyRemaining - val pct = ((used / monthlyTotal) * 100).toInt().coerceIn(0, 100) + val pct = ((used / monthlyTotal) * 100).toFloat().coerceIn(0f, 100f) UsageWindowRow( label = "本月", pct = pct, @@ -314,7 +315,7 @@ private fun OpenCodeGoWindowsCard(state: State) { Spacer(modifier = Modifier.height(12.dp)) UsageWindowRow( label = "5 小时滚动", - pct = extras["rolling.pct"]?.toIntOrNull(), + pct = extras["rolling.pct"]?.toFloatOrNull(), resetInSec = extras["rolling.resetInSec"]?.toLongOrNull() ) Spacer(modifier = Modifier.height(14.dp)) @@ -322,7 +323,7 @@ private fun OpenCodeGoWindowsCard(state: State) { Spacer(modifier = Modifier.height(14.dp)) UsageWindowRow( label = "本周", - pct = extras["weekly.pct"]?.toIntOrNull(), + pct = extras["weekly.pct"]?.toFloatOrNull(), resetInSec = extras["weekly.resetInSec"]?.toLongOrNull() ) Spacer(modifier = Modifier.height(14.dp)) @@ -330,7 +331,7 @@ private fun OpenCodeGoWindowsCard(state: State) { Spacer(modifier = Modifier.height(14.dp)) UsageWindowRow( label = "本月", - pct = extras["monthly.pct"]?.toIntOrNull(), + pct = extras["monthly.pct"]?.toFloatOrNull(), resetInSec = extras["monthly.resetInSec"]?.toLongOrNull() ) } @@ -338,8 +339,8 @@ private fun OpenCodeGoWindowsCard(state: State) { } @Composable -private fun UsageWindowRow(label: String, pct: Int?, resetInSec: Long?) { - val pctValue = (pct ?: 0).coerceIn(0, 100).toFloat() +private fun UsageWindowRow(label: String, pct: Float?, resetInSec: Long?, decimals: Int = 0) { + val pctValue = (pct ?: 0f).coerceIn(0f, 100f) Column { Row( modifier = Modifier.fillMaxWidth(), @@ -353,7 +354,7 @@ private fun UsageWindowRow(label: String, pct: Int?, resetInSec: Long?) { ) Row(verticalAlignment = Alignment.Bottom) { Text( - text = (pct ?: 0).toString(), + text = String.format(Locale.US, "%.${decimals}f", pctValue), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold ) @@ -390,6 +391,96 @@ private fun UsageWindowRow(label: String, pct: Int?, resetInSec: Long?) { } } +/** + * Codex 专属:用量窗口进度卡。 + * + * 数据从 balance.extras 中的 window_*.label / remainingPct / resetAt 解析, + * 展示每个窗口的已用百分比与重置倒计时。 + */ +@Composable +private fun CodexUsageCard(state: State) { + val balance = when (state) { + is State.Fresh -> state.data + is State.Stale -> state.data + is State.Error -> state.cached + else -> null + } + val extras = balance?.extras ?: return + val plan = extras["plan"].orEmpty() + val windows = remember(extras) { extractCodexWindows(extras) } + if (windows.isEmpty()) return + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "用量窗口", + style = MaterialTheme.typography.labelLarge, + color = inkMuted() + ) + Spacer(modifier = Modifier.weight(1f)) + if (plan.isNotBlank()) { + Text( + text = plan, + style = MaterialTheme.typography.labelLarge, + color = StrawberryPink, + fontWeight = FontWeight.Bold + ) + } + } + Spacer(modifier = Modifier.height(12.dp)) + windows.forEachIndexed { index, window -> + if (index > 0) { + Spacer(modifier = Modifier.height(14.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(modifier = Modifier.height(14.dp)) + } + val resetInSec = window.resetAt?.let { + (it - System.currentTimeMillis()) / 1000 + }?.takeIf { it > 0 } + UsageWindowRow( + label = window.label, + pct = window.usedPct, + resetInSec = resetInSec, + decimals = 2 + ) + } + } + } +} + +private data class CodexWindow( + val label: String, + val usedPct: Float, + val resetAt: Long? +) + +private fun extractCodexWindows(extras: Map): List { + val result = mutableListOf() + var i = 0 + while (true) { + val rawLabel = extras["window_${i}.label"] ?: break + val remaining = extras["window_${i}.remainingPct"]?.toFloatOrNull() ?: 0f + val resetAt = extras["window_${i}.resetAt"]?.toLongOrNull()?.takeIf { it > 0 } + val usedPct = (100f - remaining).coerceIn(0f, 100f) + result.add(CodexWindow(formatCodexWindowLabel(rawLabel), usedPct, resetAt)) + i++ + } + return result +} + +private fun formatCodexWindowLabel(raw: String): String = when (raw.lowercase()) { + "5h" -> "5 小时" + "7d", "每周" -> "本周" + "30d", "每月" -> "本月" + else -> raw +} + /** * Ollama Pro 专属:5h + 每周用量窗口卡 + 模型级调用次数。 */ @@ -431,8 +522,9 @@ private fun OllamaUsageCard(state: State) { Spacer(modifier = Modifier.height(12.dp)) UsageWindowRow( label = "5 小时", - pct = extras["session.pct"]?.toFloatOrNull()?.toInt(), - resetInSec = extras["session.resetAt"]?.toLongOrNull()?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 } + pct = extras["session.pct"]?.toFloatOrNull(), + resetInSec = extras["session.resetAt"]?.toLongOrNull()?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }, + decimals = 2 ) if (sessionModels.isNotEmpty()) { Spacer(modifier = Modifier.height(10.dp)) @@ -443,8 +535,9 @@ private fun OllamaUsageCard(state: State) { Spacer(modifier = Modifier.height(14.dp)) UsageWindowRow( label = "每周", - pct = extras["weekly.pct"]?.toFloatOrNull()?.toInt(), - resetInSec = extras["weekly.resetAt"]?.toLongOrNull()?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 } + pct = extras["weekly.pct"]?.toFloatOrNull(), + resetInSec = extras["weekly.resetAt"]?.toLongOrNull()?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }, + decimals = 2 ) if (weeklyModels.isNotEmpty()) { Spacer(modifier = Modifier.height(10.dp)) diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt index 3b970ef..c371bb1 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt @@ -63,6 +63,7 @@ fun CredentialEditScreen( onBack: () -> Unit, onStartWebViewLogin: (ServiceType) -> Unit, onWebViewLoginSuccess: (ServiceType) -> Unit, + onStartCodexOAuth: () -> Unit = {}, viewModel: CredentialEditViewModel = hiltViewModel() ) { LaunchedEffect(service) { viewModel.bind(service) } @@ -129,7 +130,8 @@ fun CredentialEditScreen( hasExisting = uiState.hasExisting, onAuthJsonChange = viewModel::updateCodexAuthJson, onSave = viewModel::saveCodexAuthJson, - onShowHelp = { showCodexHelp = true } + onShowHelp = { showCodexHelp = true }, + onStartOAuth = onStartCodexOAuth ) } else { ApiKeyForm( @@ -529,14 +531,25 @@ private fun CodexAuthJsonForm( hasExisting: Boolean, onAuthJsonChange: (String) -> Unit, onSave: () -> Unit, - onShowHelp: () -> Unit + onShowHelp: () -> Unit, + onStartOAuth: () -> Unit = {} ) { Text(text = "Codex / ChatGPT Plus 凭据", style = MaterialTheme.typography.titleMedium) Text( - text = "从 codex-oauth-proxy 的 auth.json 复制完整内容粘贴到下方。APP 会自动解析并支持自动刷新。", + text = "推荐使用 OAuth 登录自动获取凭据。也可手动粘贴 auth.json 内容。", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline ) + Button(onClick = onStartOAuth, modifier = Modifier.fillMaxWidth()) { + Text("🔐 OAuth 登录(推荐)") + } + Text( + text = "── 或手动导入 ──", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) OutlinedTextField( value = authJson, onValueChange = onAuthJsonChange, diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt index ace9f82..ade0462 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt @@ -327,8 +327,13 @@ class CredentialEditViewModel @Inject constructor( val accessToken = tokens["access_token"]?.jsonPrimitive?.content val refreshToken = tokens["refresh_token"]?.jsonPrimitive?.content val accountId = tokens["account_id"]?.jsonPrimitive?.content ?: "" + // 支持三种过期时间格式: + // expiresAt / expires_at → epoch 毫秒(绝对时间) + // expires_in → 相对秒数(token 有效期),转为 now + seconds*1000 + // 无该字段 → 默认 10 天后过期(假定 token 尚未到期) val expiresAt = tokens["expiresAt"]?.jsonPrimitive?.content?.toLongOrNull() ?: tokens["expires_at"]?.jsonPrimitive?.content?.toLongOrNull() + ?: tokens["expires_in"]?.jsonPrimitive?.content?.toLongOrNull()?.let { System.currentTimeMillis() + it * 1000L } ?: System.currentTimeMillis() + 10L * 24 * 3600 * 1000 if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) { diff --git a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt index 77d16ea..abbc6aa 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt @@ -61,6 +61,7 @@ fun SettingsScreen( onBack: () -> Unit, onEditCredential: (ServiceType) -> Unit, onOpenTips: () -> Unit = {}, + onOpenDebugLog: () -> Unit = {}, viewModel: SettingsViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -126,6 +127,9 @@ fun SettingsScreen( item { TipsCard(onClick = { onOpenTips() }) } + item { + DebugLogCard(onClick = { onOpenDebugLog() }) + } item { Spacer(modifier = Modifier.padding(top = 8.dp)) AboutCard() @@ -242,4 +246,41 @@ private fun TipsCard(onClick: () -> Unit) { } } } +} + +@Composable +private fun DebugLogCard(onClick: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() }, + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "🔍", + style = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "调试日志", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = InkMuted + ) + Text( + text = "查看 token 刷新、网络请求等调试记录", + style = MaterialTheme.typography.bodySmall, + color = InkMuted, + modifier = Modifier.padding(top = 2.dp) + ) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthScreen.kt b/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthScreen.kt new file mode 100644 index 0000000..ac36eae --- /dev/null +++ b/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthScreen.kt @@ -0,0 +1,308 @@ +package com.rainy.token.ui.webview + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.view.ViewGroup +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.rainy.token.ui.theme.StrawberryPink + +/** + * Codex OAuth PKCE 登录页面。 + * + * 两种模式: + * - WEBVIEW:APP 内 WebView 打开 OpenAI 授权页,拦截 localhost:1455 回调 + * - HEADLESS:显示授权 URL + 复制按钮 + 在浏览器打开 + 粘贴回调 URL 输入框 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CodexOAuthScreen( + onBack: () -> Unit, + onSuccess: () -> Unit, + viewModel: CodexOAuthViewModel = hiltViewModel() +) { + LaunchedEffect(Unit) { viewModel.start(CodexOAuthMode.HEADLESS) } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + LaunchedEffect(uiState.loginSucceeded) { + if (uiState.loginSucceeded) onSuccess() + } + + BackHandler(enabled = !uiState.loginSucceeded) { onBack() } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Codex OAuth 登录") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Filled.ArrowBack, contentDescription = "返回") + } + } + ) + } + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + when { + uiState.exchanging -> { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator(color = StrawberryPink) + Text( + "正在交换 Token...", + modifier = Modifier.padding(top = 16.dp), + style = MaterialTheme.typography.bodyLarge + ) + } + } + + uiState.error != null && uiState.mode == CodexOAuthMode.WEBVIEW -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + uiState.error!!, + style = MaterialTheme.typography.bodyLarge, + color = androidx.compose.ui.graphics.Color(0xFFE91E63) + ) + TextButton(onClick = { viewModel.start(CodexOAuthMode.WEBVIEW) }) { + Text("重试", color = StrawberryPink) + } + } + } + + // 无头模式:显示授权 URL + 粘贴回调 + uiState.mode == CodexOAuthMode.HEADLESS && uiState.authUrl.isNotEmpty() -> { + HeadlessOAuthContent( + authUrl = uiState.authUrl, + error = uiState.error, + onCopyUrl = { copyToClipboard(context, uiState.authUrl) }, + onOpenInBrowser = { openInBrowser(context, uiState.authUrl) }, + onSubmit = { url -> viewModel.submitCallbackUrl(url) }, + onRetry = { viewModel.start(CodexOAuthMode.HEADLESS) } + ) + } + + // WebView 模式 + uiState.authUrl.isNotEmpty() -> { + AndroidView( + factory = { ctx -> + WebView(ctx).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.useWideViewPort = true + settings.loadWithOverviewMode = true + + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest? + ): Boolean { + val url = request?.url?.toString() + if (url != null && viewModel.onUrlChanged(url)) { + return true + } + return false + } + } + webChromeClient = WebChromeClient() + loadUrl(uiState.authUrl) + } + }, + modifier = Modifier.fillMaxSize() + ) + } + + else -> { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator(color = StrawberryPink) + Text( + "正在准备授权...", + modifier = Modifier.padding(top = 16.dp), + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + } + } +} + +/** + * 无头模式 UI:授权 URL + 操作按钮 + 回调 URL 粘贴输入框 + */ +@Composable +private fun HeadlessOAuthContent( + authUrl: String, + error: String?, + onCopyUrl: () -> Unit, + onOpenInBrowser: () -> Unit, + onSubmit: (String) -> Unit, + onRetry: () -> Unit +) { + var callbackUrl by remember { mutableStateOf("") } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "无头模式登录", + style = MaterialTheme.typography.titleMedium, + color = StrawberryPink + ) + Text( + text = "1. 点击「在浏览器中打开」或复制下方链接到浏览器\n" + + "2. 在 OpenAI 页面完成登录和授权\n" + + "3. 浏览器会跳转到一个 localhost 地址(页面打不开是正常的)\n" + + "4. 复制浏览器地址栏的完整 URL 粘贴到下方输入框\n" + + "5. 点击「提交回调 URL」", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline + ) + + // 授权 URL 预览 + OutlinedTextField( + value = authUrl, + onValueChange = {}, + label = { Text("授权链接") }, + readOnly = true, + minLines = 3, + maxLines = 5, + modifier = Modifier.fillMaxWidth() + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = onOpenInBrowser, + modifier = Modifier.weight(1f) + ) { + Text("在浏览器中打开") + } + OutlinedButton( + onClick = onCopyUrl, + modifier = Modifier.weight(1f) + ) { + Text("复制链接") + } + } + + Text( + text = "── 登录后粘贴回调 URL ──", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + textAlign = TextAlign.Center + ) + + OutlinedTextField( + value = callbackUrl, + onValueChange = { callbackUrl = it }, + label = { Text("回调 URL") }, + placeholder = { Text("http://localhost:1455/auth/callback?code=...") }, + minLines = 2, + maxLines = 6, + modifier = Modifier.fillMaxWidth() + ) + + Button( + onClick = { onSubmit(callbackUrl) }, + modifier = Modifier.fillMaxWidth(), + enabled = callbackUrl.isNotBlank() + ) { + Text("提交回调 URL") + } + + if (error != null) { + Text( + text = error, + style = MaterialTheme.typography.bodySmall, + color = androidx.compose.ui.graphics.Color(0xFFE91E63), + modifier = Modifier.padding(top = 4.dp) + ) + TextButton(onClick = onRetry) { + Text("重新生成授权链接", color = StrawberryPink) + } + } + } +} + +private fun copyToClipboard(context: Context, text: String) { + val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(ClipData.newPlainText("auth_url", text)) +} + +private fun openInBrowser(context: Context, url: String) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(intent) +} \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthViewModel.kt b/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthViewModel.kt new file mode 100644 index 0000000..a89b11f --- /dev/null +++ b/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthViewModel.kt @@ -0,0 +1,153 @@ +package com.rainy.token.ui.webview + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.rainy.token.data.debug.DebugLog +import com.rainy.token.data.repository.CodexOAuthHelper +import com.rainy.token.data.repository.CredentialRepository +import com.rainy.token.domain.model.Credential +import com.rainy.token.domain.service.ServiceType +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import javax.inject.Inject + +enum class CodexOAuthMode { WEBVIEW, HEADLESS } + +/** + * Codex OAuth PKCE 登录 ViewModel。 + * + * 两种模式: + * - WEBVIEW:APP 内 WebView 打开授权页,拦截 localhost:1455 回调 + * - HEADLESS:生成授权 URL 让用户复制到外部浏览器,登录后粘贴回调 URL 回来 + */ +@HiltViewModel +class CodexOAuthViewModel @Inject constructor( + private val okHttpClient: OkHttpClient, + private val credentialRepository: CredentialRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(CodexOAuthUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val TAG = "CodexOAuth" + + fun start(mode: CodexOAuthMode = CodexOAuthMode.WEBVIEW) { + val pkce = CodexOAuthHelper.generatePkce() + val state = CodexOAuthHelper.generateState() + val authUrl = CodexOAuthHelper.buildAuthUrl(pkce.codeChallenge, state) + DebugLog.i(TAG, "OAuth 流程启动 (${mode.name}),构建授权 URL") + _uiState.update { + CodexOAuthUiState( + mode = mode, + authUrl = authUrl, + codeVerifier = pkce.codeVerifier, + expectedState = state + ) + } + } + + /** + * WebView 拦截到 URL 变化时调用。 + * 如果 URL 匹配 callback,返回 true 表示已处理。 + */ + fun onUrlChanged(url: String?): Boolean { + if (url == null) return false + if (!url.startsWith(CodexOAuthHelper.CALLBACK_PREFIX)) return false + handleCallbackUrl(url) + return true + } + + /** + * 无头模式:用户粘贴回调 URL 后调用。 + */ + fun submitCallbackUrl(url: String) { + handleCallbackUrl(url.trim()) + } + + fun clearError() { + _uiState.update { it.copy(error = null) } + } + + /** + * 处理回调 URL:解析 code/state,交换 token,保存凭据。 + */ + private fun handleCallbackUrl(url: String) { + if (!url.startsWith(CodexOAuthHelper.CALLBACK_PREFIX)) { + _uiState.update { it.copy(error = "URL 应以 ${CodexOAuthHelper.CALLBACK_PREFIX} 开头") } + return + } + + val uri = android.net.Uri.parse(url) + val code = uri.getQueryParameter("code") + val receivedState = uri.getQueryParameter("state") + val error = uri.getQueryParameter("error") + + if (error != null) { + val errorDesc = uri.getQueryParameter("error_description") ?: error + DebugLog.e(TAG, "OAuth 授权失败: $errorDesc") + _uiState.update { it.copy(error = "授权失败: $errorDesc") } + return + } + + if (receivedState != _uiState.value.expectedState) { + DebugLog.e(TAG, "OAuth state 不匹配(CSRF 防护)") + _uiState.update { it.copy(error = "State 不匹配,请重试") } + return + } + + if (code.isNullOrBlank()) { + DebugLog.e(TAG, "OAuth 回调缺少 code 参数") + _uiState.update { it.copy(error = "回调缺少授权码") } + return + } + + _uiState.update { it.copy(exchanging = true, error = null) } + val verifier = _uiState.value.codeVerifier ?: return + + viewModelScope.launch { + val result = withContext(Dispatchers.IO) { + CodexOAuthHelper.exchangeCode(okHttpClient, code, verifier) + } + + if (result == null) { + DebugLog.e(TAG, "token 交换失败") + _uiState.update { it.copy(exchanging = false, error = "Token 交换失败,请查看调试日志") } + return@launch + } + + val accountId = CodexOAuthHelper.extractAccountId(result.idToken, result.accessToken) + val expiresAt = System.currentTimeMillis() + result.expiresIn * 1000L + + DebugLog.i(TAG, "OAuth 登录成功,accountId=$accountId,expiresAt=$expiresAt") + + val cred = Credential.CodexCredential( + service = ServiceType.CODEX, + accessToken = result.accessToken, + refreshToken = result.refreshToken ?: "", + accountId = accountId ?: "", + expiresAt = expiresAt, + lastVerifiedAt = System.currentTimeMillis() + ) + credentialRepository.save(cred) + + _uiState.update { it.copy(exchanging = false, loginSucceeded = true) } + } + } +} + +data class CodexOAuthUiState( + val mode: CodexOAuthMode = CodexOAuthMode.WEBVIEW, + val authUrl: String = "", + val codeVerifier: String? = null, + val expectedState: String? = null, + val exchanging: Boolean = false, + val loginSucceeded: Boolean = false, + val error: String? = null +) \ No newline at end of file diff --git a/tools/codex_oauth.py b/tools/codex_oauth.py new file mode 100644 index 0000000..4a7bc4b --- /dev/null +++ b/tools/codex_oauth.py @@ -0,0 +1,338 @@ +import argparse +import base64 +import json +import time +import httpx +from openai import OpenAI, AuthenticationError + +AUTH_URL = "https://auth.openai.com/oauth/authorize" +TOKEN_URL = "https://auth.openai.com/oauth/token" +BASE_URL = "https://chatgpt.com/backend-api/wham" + +# Originally the Codex CLI client ID; OpenCode uses the same value. +# No standard allocation mechanism exists for third-party apps. +CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" +CLIENT_NAME = "sample-script" +CLIENT_VER = "0.0.1" + +# seconds before expiry to trigger refresh +SAFETY_MARGIN = 30 + +__all__ = [ + "AuthManager", + "extract_account_id", + "cmd_login", +] + + +def extract_account_id(id_token: str | None, access_token: str | None) -> str | None: + """Extract account_id from JWT with 3-level fallback. + + No signature verification needed — we only read the payload for account_id. + The '-len % 4' padding trick avoids adding '=' when length is already a multiple of 4. + Tries id_token first, then access_token, because the claim location varies by token. + """ + for token in [id_token, access_token]: + if not token: + continue + try: + payload_b64 = token.split(".")[1] + payload_b64 += "=" * (-len(payload_b64) % 4) + payload = json.loads(base64.urlsafe_b64decode(payload_b64)) + except Exception: + continue + # 1. top-level chatgpt_account_id + if aid := payload.get("chatgpt_account_id"): + return aid + # 2. inside https://api.openai.com/auth namespace + if aid := payload.get("https://api.openai.com/auth", {}).get("chatgpt_account_id"): + return aid + # 3. organizations[0].id + orgs = payload.get("organizations", []) + if orgs and (aid := orgs[0].get("id")): + return aid + return None + + +class AuthManager: + def __init__(self, path: str = "auth.json", tokens: dict = None): + self.path = path + if tokens is not None: + self._set_data(tokens) + else: + self.data = self._load() + + def _load(self) -> dict: + """Returns {} on first run (no auth.json yet); FileNotFoundError is expected.""" + try: + with open(self.path) as f: + return json.load(f) + except FileNotFoundError: + return {} + + def _save(self): + with open(self.path, "w") as f: + json.dump(self.data, f, indent=2, ensure_ascii=False) + + def _set_data(self, tokens: dict, fallback_account_id=None): + expires_in = tokens.get("expires_in") or 3600 + expires = int(time.time() * 1000) + expires_in * 1000 + id_token = tokens.get("id_token") + access_token = tokens.get("access_token") + account_id = extract_account_id(id_token, access_token) or fallback_account_id + data = { + "type": "oauth", + "access": access_token, + "refresh": tokens.get("refresh_token"), + "expires": expires, + } + if account_id: + data["accountId"] = account_id + self.data = data + + def refresh(self): + """POST grant_type=refresh_token; _set_data recalculates expiry and updates account_id.""" + resp = httpx.post( + TOKEN_URL, + data={ + "grant_type": "refresh_token", + "refresh_token": self.data["refresh"], + "client_id": CLIENT_ID, + }, + ) + if not (200 <= resp.status_code < 300): + raise RuntimeError(f"Token refresh failed: {resp.status_code}") + self._set_data(resp.json(), self.data.get("accountId")) + self._save() + + def ensure_valid(self): + now_ms = int(time.time() * 1000) + if not self.data.get("access") or self.data.get("expires", 0) < now_ms + SAFETY_MARGIN * 1000: + self.refresh() + + def make_client(self) -> OpenAI: + """api_key accepts the OAuth access token directly; + the library formats it as "Authorization: Bearer ". + """ + headers = {"User-Agent": f"{CLIENT_NAME}/{CLIENT_VER}"} + if account_id := self.data.get("accountId"): + headers["ChatGPT-Account-Id"] = account_id + return OpenAI( + api_key=self.data["access"], + base_url=BASE_URL, + default_headers=headers, + ) + + def auto_refresh(self, f, *args, **kwargs): + """Checks token validity before calling f, and retries once on HTTP 401. + + f receives self as its first argument so it can call make_client() + after a refresh and get a client with the updated token. + """ + self.ensure_valid() + try: + f(self, *args, **kwargs) + except AuthenticationError: + self.refresh() + f(self, *args, **kwargs) + + +def cmd_login(): + """Run the Authorization Code + PKCE flow to obtain OAuth tokens. + + Opens a browser for the user to log in, receives the auth code via a local + HTTP callback server, exchanges it for tokens, and saves them to auth.json. + """ + import datetime + import hashlib + import secrets + import threading + import webbrowser + from http.server import BaseHTTPRequestHandler, HTTPServer + from urllib.parse import parse_qs, urlparse + from authlib.integrations.httpx_client import OAuth2Client + + # SCOPE and REDIRECT_URI are used only inside cmd_login. + SCOPE = "openid profile email offline_access" + REDIRECT_URI = "http://localhost:1455/auth/callback" + + # authlib's OAuth2Client does not auto-add code_challenge even when + # code_challenge_method="S256" is passed to the constructor, so generate manually. + code_verifier = secrets.token_urlsafe(96) + digest = hashlib.sha256(code_verifier.encode()).digest() + code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode() + + # authlib manages state and builds the authorization URL. + # code_challenge and code_challenge_method must be passed explicitly here. + # OpenAI-specific params are appended to the URL as extra query parameters. + oauth = OAuth2Client(client_id=CLIENT_ID, redirect_uri=REDIRECT_URI, scope=SCOPE) + auth_url, state = oauth.create_authorization_url( + AUTH_URL, + code_challenge=code_challenge, + code_challenge_method="S256", + id_token_add_organizations="true", # OpenAI-specific + codex_cli_simplified_flow="true", # OpenAI-specific + originator="opencode", # OpenAI-specific + ) + + # Verify state on callback to prevent CSRF (attacker-supplied auth codes). + class CallbackHandler(BaseHTTPRequestHandler): + def do_GET(self): + parsed = urlparse(self.path) + if parsed.path == "/auth/callback": + params = parse_qs(parsed.query) + received_state = params.get("state", [None])[0] + + error = None + code = None + if received_state != self.server.expected_state: + error = "CSRF error: state mismatch" + elif "error" in params: + error = params.get("error_description", ["Unknown error"])[0] + else: + code = params.get("code", [None])[0] + + self.server.auth_code = code + self.server.error = error + + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + if self.server.auth_code: + html = "

Authentication successful

You can close this tab.

" + else: + html = f"

Authentication failed

{self.server.error}

" + self.wfile.write(html.encode()) + threading.Thread(target=self.server.shutdown).start() + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format, *args): + pass # suppress server logs + + server = HTTPServer(("localhost", 1455), CallbackHandler) + server.auth_code = None + server.error = None + server.expected_state = state # state returned by create_authorization_url() + + print("Opening browser for authentication...") + print(f"If the browser does not open, visit the following URL:\n\n{auth_url}\n") + webbrowser.open(auth_url) + server.serve_forever() # blocks until shutdown() is called from CallbackHandler + + if server.error: + raise RuntimeError(f"Authentication error: {server.error}") + + print("Exchanging tokens...") + # grant_type, client_id, and redirect_uri are added automatically by authlib; + # code_verifier must be passed explicitly for PKCE verification. + token = oauth.fetch_token(TOKEN_URL, code=server.auth_code, code_verifier=code_verifier) + + # AuthManager computes expiry, extracts account_id, and builds self.data internally. + am = AuthManager(tokens=token) + am._save() + + expires_dt = datetime.datetime.fromtimestamp(am.data["expires"] / 1000).strftime("%Y-%m-%d %H:%M:%S") + + print("\n=== Authentication Info ===") + print(f"CODEX_ACCESS_TOKEN : {(am.data['access'] or '')[:40]}...") + print(f"CODEX_ACCOUNT_ID : {am.data.get('accountId')}") + print(f"Expiry : {expires_dt}") + print("\nSaved to auth.json") + + +def cmd_test(auth, model): + """WHAM Responses API requirements (differ from Chat Completions API): + - content type must be "input_text" (not "text") + - store=False is required (omitting it causes an error) + - instructions (system prompt) is required + + WHAM is stateless: full conversation history must be included in the input array. + For multi-turn sessions, pass prompt_cache_key= and + extra_headers={"session_id": } to enable server-side caching and reduce TTFT. + + Reasoning summary (CoT) requires both effort and summary in reasoning={}. + Streaming events for reasoning summary: + - response.reasoning_summary_text.delta: incremental reasoning summary + - response.reasoning_summary_text.done: reasoning summary complete + Note: reasoning.encrypted_content in include is for multi-turn context + continuity, not for CoT display. + """ + client = auth.make_client() + in_reasoning = False + with client.responses.create( + model=model, + instructions="You are a helpful coding assistant.", + input=[{ + "role": "user", + "content": [{"type": "input_text", "text": "What is your name?"}], + }], + store=False, + reasoning={"effort": "medium", "summary": "auto"}, + include=[], + tools=[], + tool_choice="auto", + parallel_tool_calls=True, + stream=True, + ) as stream: + in_answer = False + for event in stream: + if event.type == "response.reasoning_summary_text.delta": + if not in_reasoning: + print("[Thinking]", flush=True) + in_reasoning = True + print(event.delta, end="", flush=True) + elif event.type == "response.reasoning_summary_text.done": + print() + in_reasoning = False + elif event.type == "response.output_text.delta": + if not in_answer: + print("[Answer]", flush=True) + in_answer = True + print(event.delta, end="", flush=True) + print() + + +def cmd_list(auth): + """WHAM /models differs from the standard API: + response key is "models" (not "data"), model identifier is "slug" (not "id"), + and client_version query parameter is required. + + Use with_raw_response because the non-standard schema cannot be parsed by the + standard response object. + """ + client = auth.make_client() + response = client.models.with_raw_response.list(extra_query={"client_version": CLIENT_VER}) + data = json.loads(response.text) + + with open("models.json", "w") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + print("Saved to models.json") + + for model in data["models"]: + print(model["slug"]) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser("login") + test_parser = subparsers.add_parser("test") + test_parser.add_argument("-m", "--model", default="gpt-5.1-codex-mini") + subparsers.add_parser("list") + args = parser.parse_args(argv) + + if args.command == "login": + cmd_login() + else: + auth = AuthManager() + if args.command == "test": + auth.auto_refresh(cmd_test, args.model) + elif args.command == "list": + auth.auto_refresh(cmd_list) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())