Own Genie Responses transport in Agent bridge

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Iliyan Malchev
2026-03-19 13:28:28 -07:00
parent f1a739e7eb
commit 4f6b0de73f
11 changed files with 312 additions and 100 deletions

View File

@@ -55,13 +55,16 @@ object AgentCodexAppServerClient {
}
}
fun readRuntimeStatus(context: Context): RuntimeStatus = synchronized(lifecycleLock) {
fun readRuntimeStatus(
context: Context,
refreshToken: Boolean = false,
): RuntimeStatus = synchronized(lifecycleLock) {
ensureStarted(context.applicationContext)
activeRequests.incrementAndGet()
try {
val accountResponse = request(
method = "account/read",
params = JSONObject().put("refreshToken", false),
params = JSONObject().put("refreshToken", refreshToken),
)
val configResponse = request(
method = "config/read",
@@ -346,7 +349,11 @@ object AgentCodexAppServerClient {
modelProviderId = configuredProvider ?: inferModelProviderId(accountType),
configuredModel = configuredModel,
effectiveModel = configuredModel,
upstreamBaseUrl = config.optString("chatgpt_base_url").ifBlank { "provider-default" },
upstreamBaseUrl = resolveUpstreamBaseUrl(
config = config,
accountType = accountType,
configuredProvider = configuredProvider,
),
)
}
@@ -357,4 +364,32 @@ object AgentCodexAppServerClient {
else -> "unknown"
}
}
private fun resolveUpstreamBaseUrl(
config: JSONObject,
accountType: String,
configuredProvider: String?,
): String {
val modelProviders = config.optJSONObject("model_providers")
val configuredProviderBaseUrl = configuredProvider?.let { providerId ->
modelProviders
?.optJSONObject(providerId)
?.optString("base_url")
?.ifBlank { null }
}
if (configuredProviderBaseUrl != null) {
return configuredProviderBaseUrl
}
return when (accountType) {
"chatgpt" -> config.optString("chatgpt_base_url")
.ifBlank { "https://chatgpt.com/backend-api/codex" }
"apiKey" -> config.optString("openai_base_url")
.ifBlank { "https://api.openai.com/v1" }
else -> config.optString("openai_base_url")
.ifBlank {
config.optString("chatgpt_base_url")
.ifBlank { "provider-default" }
}
}
}
}

View File

@@ -0,0 +1,143 @@
package com.openai.codexd
import android.content.Context
import android.util.Log
import java.io.File
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.nio.charset.StandardCharsets
import org.json.JSONObject
object AgentResponsesProxy {
private const val TAG = "AgentResponsesProxy"
private const val CONNECT_TIMEOUT_MS = 30_000
private const val READ_TIMEOUT_MS = 30_000
private const val DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
private const val DEFAULT_CHATGPT_BASE_URL = "https://chatgpt.com/backend-api/codex"
private const val DEFAULT_ORIGINATOR = "codex_cli_rs"
private const val DEFAULT_USER_AGENT = "codex_cli_rs/android_agent_bridge"
internal data class AuthSnapshot(
val authMode: String,
val bearerToken: String,
val accountId: String?,
)
fun sendResponsesRequest(
context: Context,
requestBody: String,
): CodexdLocalClient.HttpResponse {
val runtimeStatus = AgentCodexAppServerClient.readRuntimeStatus(
context = context,
refreshToken = true,
)
if (!runtimeStatus.authenticated) {
return CodexdLocalClient.HttpResponse(
statusCode = 401,
body = "not authenticated",
)
}
val authSnapshot = loadAuthSnapshot(File(context.filesDir, "codex-home/auth.json"))
val upstreamUrl = buildResponsesUrl(runtimeStatus.upstreamBaseUrl, authSnapshot.authMode)
Log.i(TAG, "Proxying /v1/responses -> $upstreamUrl (auth_mode=${authSnapshot.authMode})")
return executeRequest(upstreamUrl, requestBody, authSnapshot)
}
internal fun buildResponsesUrl(
upstreamBaseUrl: String,
authMode: String,
): String {
val normalizedBaseUrl = when {
upstreamBaseUrl.isBlank() || upstreamBaseUrl == "provider-default" -> {
if (authMode == "chatgpt") {
DEFAULT_CHATGPT_BASE_URL
} else {
DEFAULT_OPENAI_BASE_URL
}
}
else -> upstreamBaseUrl
}
return "${normalizedBaseUrl.trimEnd('/')}/responses"
}
internal fun loadAuthSnapshot(authFile: File): AuthSnapshot {
if (!authFile.isFile) {
throw IOException("Missing Agent auth file at ${authFile.absolutePath}")
}
val json = JSONObject(authFile.readText())
val openAiApiKey = json.stringOrNull("OPENAI_API_KEY")
val authMode = when (json.stringOrNull("auth_mode")) {
"apiKey", "apikey", "api_key" -> "apiKey"
"chatgpt", "chatgptAuthTokens", "chatgpt_auth_tokens" -> "chatgpt"
null -> if (openAiApiKey != null) "apiKey" else "chatgpt"
else -> if (openAiApiKey != null) "apiKey" else "chatgpt"
}
return if (authMode == "apiKey") {
val apiKey = openAiApiKey
?: throw IOException("Agent auth file is missing OPENAI_API_KEY")
AuthSnapshot(
authMode = authMode,
bearerToken = apiKey,
accountId = null,
)
} else {
val tokens = json.optJSONObject("tokens")
?: throw IOException("Agent auth file is missing chatgpt tokens")
val accessToken = tokens.stringOrNull("access_token")
?: throw IOException("Agent auth file is missing access_token")
AuthSnapshot(
authMode = "chatgpt",
bearerToken = accessToken,
accountId = tokens.stringOrNull("account_id"),
)
}
}
private fun executeRequest(
upstreamUrl: String,
requestBody: String,
authSnapshot: AuthSnapshot,
): CodexdLocalClient.HttpResponse {
val connection = (URL(upstreamUrl).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = READ_TIMEOUT_MS
doInput = true
doOutput = true
instanceFollowRedirects = true
setRequestProperty("Authorization", "Bearer ${authSnapshot.bearerToken}")
setRequestProperty("Content-Type", "application/json")
setRequestProperty("Accept", "text/event-stream")
setRequestProperty("Accept-Encoding", "identity")
setRequestProperty("originator", DEFAULT_ORIGINATOR)
setRequestProperty("User-Agent", DEFAULT_USER_AGENT)
if (authSnapshot.authMode == "chatgpt" && !authSnapshot.accountId.isNullOrBlank()) {
setRequestProperty("ChatGPT-Account-ID", authSnapshot.accountId)
}
}
return try {
connection.outputStream.use { output ->
output.write(requestBody.toByteArray(StandardCharsets.UTF_8))
output.flush()
}
val statusCode = connection.responseCode
val stream = if (statusCode >= 400) connection.errorStream else connection.inputStream
val responseBody = stream?.bufferedReader(StandardCharsets.UTF_8)?.use { it.readText() }
.orEmpty()
CodexdLocalClient.HttpResponse(
statusCode = statusCode,
body = responseBody,
)
} finally {
connection.disconnect()
}
}
private fun JSONObject.stringOrNull(key: String): String? {
if (!has(key) || isNull(key)) {
return null
}
return optString(key).ifBlank { null }
}
}

View File

@@ -4,11 +4,9 @@ import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.util.Log
import com.openai.codex.bridge.BridgeHttpRequest
import com.openai.codex.bridge.BridgeHttpResponse
import com.openai.codex.bridge.BridgeRuntimeStatus
import com.openai.codex.bridge.ICodexAgentBridgeService
import java.io.IOException
class CodexAgentBridgeService : Service() {
companion object {
@@ -35,18 +33,16 @@ class CodexAgentBridgeService : Service() {
)
}
override fun sendHttpRequest(request: BridgeHttpRequest): BridgeHttpResponse {
override fun sendResponsesRequest(requestBody: String?): BridgeHttpResponse {
val response = runCatching {
CodexdLocalClient.waitForResponse(
AgentResponsesProxy.sendResponsesRequest(
this@CodexAgentBridgeService,
request.method,
request.path,
request.body,
requestBody.orEmpty(),
)
}.getOrElse { err ->
throw err.asBinderError("sendHttpRequest")
throw err.asBinderError("sendResponsesRequest")
}
Log.i(TAG, "Proxied ${request.method} ${request.path}")
Log.i(TAG, "Proxied /v1/responses")
return BridgeHttpResponse(response.statusCode, response.body)
}
}