Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
package com.ai.assistance.operit.api.chat.llmprovider

import com.ai.assistance.operit.core.chat.hooks.PromptTurn
import com.ai.assistance.operit.util.AppLogger
import java.net.URL
import java.net.URLEncoder
import java.security.MessageDigest
import java.util.Base64
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import org.json.JSONObject

/** DeepSeek Files API 返回的文件对象(4 个端点共用) */
internal data class DeepseekFileInfo(
val id: String,
val filename: String,
val bytes: Long,
val createdAt: Long,
val purpose: String
)

/**
* DeepSeek Files API 客户端,供 Chat Completions 与 Responses 两种协议共用。
*
* 覆盖官方 4 个端点:
* - POST /files 上传图片(换取 file_id,用于 chat 请求引用)
* - GET /files 列出文件(用于跨会话同图复用 file_id,避免重复上传堆积)
* - GET /files/{id} 查询单个文件信息
* - DELETE /files/{id} 删除文件(保留给后续清理策略挂载)
*
* 上传文件名采用图片内容 SHA-256 前缀命名,跨会话恒定:
* 图片 id 是每次添加随机生成的 UUID(见 ImagePoolManager),不能作为稳定键,
* 内容寻址命名使"列出->按文件名匹配->复用 file_id"在任意会话下都成立。
* 上传失败的图片不缓存,由调用方回退为 base64 内嵌。
*/
internal class DeepseekFileUploader(
private val apiEndpoint: String,
private val apiKeyProvider: ApiKeyProvider,
private val httpClient: OkHttpClient,
private val customHeaders: Map<String, String>
) {
/** 图片 id -> DeepSeek Files API file_id 的会话级缓存(避免同一图片重复上传) */
private val uploadedImageFileIds = ConcurrentHashMap<String, String>()
/** DeepSeek Files API 支持上传的图片 MIME 类型 */
private val supportedUploadMimeTypes = setOf("image/jpeg", "image/png", "image/gif", "image/webp")
/** DeepSeek Files API 单文件上传上限(64 MiB) */
private val maxUploadBytes = 64L * 1024L * 1024L

/**
* 扫描消息历史中的图片,优先复用远端已存在的同名文件(跨会话免重传),
* 否则上传至 DeepSeek Files API 换取 file_id。
* 上传失败的图片保持原样,构建请求时回退为 base64 内嵌(见各 Provider 的 buildImageContentPart)。
* @param supportsVision 当前配置是否开启"直接图片处理";关闭时不触发上传
* @param logTag 日志标签(区分 Chat Completions / Responses 两条调用线)
*/
suspend fun prepareMedia(supportsVision: Boolean, chatHistory: List<PromptTurn>, logTag: String) {
if (!supportsVision) return
val seenIds = mutableSetOf<String>()
val imageLinks = mutableListOf<ImageLink>()
for (turn in chatHistory) {
MediaLinkParser.extractImageLinks(turn.content).forEach { link ->
if (seenIds.add(link.id)) imageLinks.add(link)
}
}
// 候选上传项:本地缓存未命中 + 通过格式/大小校验
val candidates = mutableListOf<Triple<ImageLink, ByteArray, String>>()
for (link in imageLinks) {
if (uploadedImageFileIds.containsKey(link.id)) continue
if (link.mimeType.lowercase() !in supportedUploadMimeTypes) continue
val bytes =
runCatching { Base64.getDecoder().decode(link.base64Data) }.getOrNull() ?: continue
if (bytes.isEmpty() || bytes.size > maxUploadBytes) continue
val contentName = "image_${sha256Prefix(bytes)}.${extensionForMime(link.mimeType)}"
candidates.add(Triple(link, bytes, contentName))
}
if (candidates.isEmpty()) return
// 列出远端文件,按文件名匹配复用已有 file_id(列出失败则全部走直传)
val remoteByName = listFiles()?.associate { it.filename to it.id } ?: emptyMap()
for ((link, bytes, contentName) in candidates) {
val existingFileId = remoteByName[contentName]
if (existingFileId != null) {
uploadedImageFileIds[link.id] = existingFileId
AppLogger.d(logTag, "图片 ${link.id} 已在 DeepSeek Files API 存在($existingFileId),复用免重传")
continue
}
val fileId = uploadImageToFilesApi(bytes, link.mimeType, contentName)
if (fileId != null) {
uploadedImageFileIds[link.id] = fileId
AppLogger.d(logTag, "图片 ${link.id} 已上传至 DeepSeek Files API: $fileId")
}
}
}

/** 已上传图片对应的 file_id;未上传返回 null(调用方回退 base64) */
fun fileIdFor(linkId: String): String? = uploadedImageFileIds[linkId]

/** 列出当前 API key 下的所有文件(自动分页);失败返回 null */
suspend fun listFiles(): List<DeepseekFileInfo>? {
val filesEndpoint = buildFilesEndpoint() ?: return null
val result = mutableListOf<DeepseekFileInfo>()
var after: String? = null
while (true) {
val base = "${filesEndpoint}?purpose=user_data&limit=1000&order=desc"
val pageUrl = if (after == null) base else "$base&after=${URLEncoder.encode(after, "UTF-8")}"
val page = runCatching {
val builder = Request.Builder().url(pageUrl).get()
applyAuthAndCustomHeaders(builder)
withContext(Dispatchers.IO) { httpClient.newCall(builder.build()).execute() }.use { response ->
if (!response.isSuccessful) {
AppLogger.w(
"DeepseekFileUploader",
"Files API 列表失败 HTTP ${response.code}: ${response.body?.string()?.take(200)}"
)
return@use null
}
response.body?.string()
}?.let { parseFileListPage(it) }
}.getOrElse { e ->
AppLogger.w("DeepseekFileUploader", "Files API 列表异常: ${e.message}")
null
} ?: return null
result.addAll(page.files)
after = if (page.hasMore) page.lastId else null
if (after == null) break
}
return result
}

/** 查询单个文件信息;失败或不存在返回 null */
suspend fun retrieveFile(fileId: String): DeepseekFileInfo? {
val filesEndpoint = buildFilesEndpoint() ?: return null
val url = "$filesEndpoint/${URLEncoder.encode(fileId, "UTF-8")}"
return runCatching {
val builder = Request.Builder().url(url).get()
applyAuthAndCustomHeaders(builder)
withContext(Dispatchers.IO) { httpClient.newCall(builder.build()).execute() }.use { response ->
if (!response.isSuccessful) {
AppLogger.w(
"DeepseekFileUploader",
"Files API 查询失败 HTTP ${response.code}: ${response.body?.string()?.take(200)}"
)
return@use null
}
response.body?.string()?.let { parseFileObject(JSONObject(it)) }
}
}.getOrElse { e ->
AppLogger.w("DeepseekFileUploader", "Files API 查询异常: ${e.message}")
null
}
}

/** 删除单个文件;成功返回 true,失败返回 false */
suspend fun deleteFile(fileId: String): Boolean {
val filesEndpoint = buildFilesEndpoint() ?: return false
val url = "$filesEndpoint/${URLEncoder.encode(fileId, "UTF-8")}"
return runCatching {
val builder = Request.Builder().url(url).delete()
applyAuthAndCustomHeaders(builder)
withContext(Dispatchers.IO) { httpClient.newCall(builder.build()).execute() }.use { response ->
if (!response.isSuccessful) {
AppLogger.w(
"DeepseekFileUploader",
"Files API 删除失败 HTTP ${response.code}: ${response.body?.string()?.take(200)}"
)
return@use false
}
val json = response.body?.string()?.let { runCatching { JSONObject(it) }.getOrNull() }
json?.optBoolean("deleted", false) == true
}
}.getOrElse { e ->
AppLogger.w("DeepseekFileUploader", "Files API 删除异常: ${e.message}")
false
}
}

/** 上传单张图片到 DeepSeek Files API,返回 file_id;失败返回 null */
private suspend fun uploadImageToFilesApi(
bytes: ByteArray,
mimeType: String,
contentName: String
): String? {
val filesEndpoint = buildFilesEndpoint() ?: return null
return runCatching {
val body =
MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("purpose", "user_data")
.addFormDataPart(
"file",
contentName,
RequestBody.create(mimeType.toMediaType(), bytes)
)
.build()
val builder = Request.Builder().url(filesEndpoint).post(body)
applyAuthAndCustomHeaders(builder)
withContext(Dispatchers.IO) { httpClient.newCall(builder.build()).execute() }.use { response ->

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 将文件请求接入停止生成的取消链

这里创建的 Call 没有进入 OpenAIProvider.activeCall,也没有协程取消到 Call.cancel() 的连接。上传或列表请求很慢时,点击停止只会取消聊天 activeCall 和发送 Job,而阻塞 execute() 仍要等网络返回;MessageProcessingDelegate 的停止路径随后 join 发送 Job,导致停止和 loading 收尾一起等待。请将文件 Call 接入取消机制,并保留 CancellationException 的传播;第 118 行的列表请求也需处理。

if (!response.isSuccessful) {
AppLogger.w(
"DeepseekFileUploader",
"Files API 上传失败 HTTP ${response.code}: ${response.body?.string()?.take(200)}"
)
return@use null
}
val json = JSONObject(response.body?.string().orEmpty())
json.optString("id").takeIf { it.startsWith("file-") }
}
}.getOrElse { e ->
AppLogger.w("DeepseekFileUploader", "Files API 上传异常: ${e.message}")
null
}
}

/** 给请求附加认证头与自定义头(排除会破坏 multipart 边界的 Content-Type 覆写) */
private suspend fun applyAuthAndCustomHeaders(builder: Request.Builder) {
val apiKey = apiKeyProvider.getApiKey().trim()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 为媒体上传与聊天请求固定同一个 API key

MultiApiKeyProvider 每次 getApiKey() 都推进轮询索引,最终 OpenAIProvider.createRequest 又取一次 key。两个可用 key 的空缓存首图流程会变成 list=A、upload=B、chat=A;DeepSeek 官方文档 明确文件归属 API key,所以聊天请求无法引用另一 key 上传的文件。缓存仅按图片 ID 保存,后续换 key 也会继续复用错误归属。请让列表、上传、聊天及缓存共用一次请求选定的 key,重试换 key 时重新准备对应媒体。

if (apiKey.isNotEmpty()) {
builder.addHeader("Authorization", "Bearer $apiKey")
}
customHeaders.forEach { (key, value) ->
if (!key.equals("Content-Type", ignoreCase = true)) builder.addHeader(key, value)
}
}

/** 解析列表端点的一页响应;失败返回 null */
private fun parseFileListPage(body: String): FileListPage? {
return runCatching {
val json = JSONObject(body)
val data = json.optJSONArray("data") ?: return null
val files = mutableListOf<DeepseekFileInfo>()
for (i in 0 until data.length()) {
data.optJSONObject(i)?.let { obj ->
parseFileObject(obj)?.let { files.add(it) }
}
}
FileListPage(
files = files,
hasMore = json.optBoolean("has_more", false),
lastId = json.optString("last_id").takeIf { it.isNotEmpty() }
)
}.getOrNull()
}

/** 解析单个 file object;失败返回 null */
private fun parseFileObject(json: JSONObject): DeepseekFileInfo? {
val id = json.optString("id")
if (!id.startsWith("file-")) return null
return DeepseekFileInfo(
id = id,
filename = json.optString("filename"),
bytes = json.optLong("bytes"),
createdAt = json.optLong("created_at"),
purpose = json.optString("purpose")
)
}

/** 由聊天 API 端点推导 Files API 地址(官方为 https://api.deepseek.com/files) */
private fun buildFilesEndpoint(): String? {
val raw = apiEndpoint.trim().removeSuffix("#").trim()
return runCatching {
val url = URL(raw)
val portPart = if (url.port in 1..65535) ":" + url.port else ""
"${url.protocol}://${url.host}${portPart}/files"
}.getOrNull()
}

/** 图片内容 SHA-256 的十六进制前 24 字符(作为跨会话稳定的文件名键) */
private fun sha256Prefix(bytes: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
return digest.take(12).joinToString("") { "%02x".format(it.toInt() and 0xFF) }
}

private fun extensionForMime(mimeType: String): String {
return when (mimeType.lowercase()) {
"image/jpeg" -> "jpg"
"image/png" -> "png"
"image/gif" -> "gif"
"image/webp" -> "webp"
else -> "bin"
}
}

private data class FileListPage(
val files: List<DeepseekFileInfo>,
val hasMore: Boolean,
val lastId: String?
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,25 @@ class DeepseekProvider(
thinkingOptionId = thinkingOptionId
) {
private val configuredApiEndpoint = apiEndpoint
/** DeepSeek Files API 图片上传器(与 Responses 协议线共用的抽取实现) */
private val fileUploader = DeepseekFileUploader(apiEndpoint, apiKeyProvider, client, customHeaders)

/** 发送前把消息中的图片上传至 DeepSeek Files API 换取 file_id,失败回退 base64 */
override suspend fun prepareMediaForRequest(context: Context, chatHistory: List<PromptTurn>) {
fileUploader.prepareMedia(supportsVision, chatHistory, "DeepseekProvider")
}

/** 已有 file_id 时用 DeepSeek 文件块引用,否则回退基类的 base64 内嵌 */
override fun buildImageContentPart(link: ImageLink): JSONObject {
val fileId = fileUploader.fileIdFor(link.id)
if (fileId != null) {
return JSONObject().apply {
put("type", "file")
put("file_id", fileId)
}
}
return super.buildImageContentPart(link)
}
companion object {
fun create(
config: ModelConfigData,
Expand Down Expand Up @@ -598,6 +616,30 @@ private class DeepseekResponsesProvider(
) {
override val useResponsesApi: Boolean = true
override val bufferResponsesOutputTextUntilItemDone: Boolean = true
/** DeepSeek Files API 图片上传器(与 Chat Completions 协议线共用的抽取实现) */
private val fileUploader =
DeepseekFileUploader(responsesApiEndpoint, apiKeyProvider, client, customHeaders)

/** 发送前把消息中的图片上传至 DeepSeek Files API 换取 file_id,失败回退 base64 */
override suspend fun prepareMediaForRequest(context: Context, chatHistory: List<PromptTurn>) {
fileUploader.prepareMedia(supportsVision, chatHistory, "DeepseekResponsesProvider")
}

/**
* Responses 线同样先输出 DeepSeek 文件块,随后由
* OpenAIResponsesPayloadAdapter.convertMessageContentForResponses 转换为
* Responses 协议的 input_image + file_id 引用;未上传成功则回退 base64。
*/
override fun buildImageContentPart(link: ImageLink): JSONObject {
val fileId = fileUploader.fileIdFor(link.id)
if (fileId != null) {
return JSONObject().apply {
put("type", "file")
put("file_id", fileId)
}
}
return super.buildImageContentPart(link)
}

override fun isResponsesCommentaryMessage(item: JSONObject): Boolean {
return item.optString("phase", "").trim().equals("commentary", ignoreCase = true)
Expand Down
Loading