Skip to content
Open
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
31 changes: 31 additions & 0 deletions src/main/java/us/cubk/BearerApiClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,42 @@
import java.time.Duration;
import java.util.Map;

/**
* 带 Bearer 授权的 HTTP 客户端。
* 支持普通 POST/GET 请求和 SSE (Server-Sent Events) 流式请求。
* 所有请求自动添加 Cosy 协议相关的请求头、签名和 Bearer 授权信息。
* 请求体经过 QoderEncoding 编码后发送。
*/
public final class BearerApiClient {
public static final ObjectMapper objectMapper = new ObjectMapper();
private final BearerBuilder.SessionContext sess;
private final HttpClient http = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).connectTimeout(Duration.ofSeconds(15)).build();

public BearerApiClient(BearerBuilder.SessionContext sess) { this.sess = sess; }

/** 发送 POST 请求并返回 JSON 响应 */
public JsonNode callPost(String fullUrl, Object jsonBody) throws Exception {
return call("POST", fullUrl, jsonBody, null);
}

/** 发送 GET 请求并返回 JSON 响应 */
public JsonNode callGet(String fullUrl) throws Exception {
return call("GET", fullUrl, null, null);
}
/** 打开 SSE 流式连接,返回原始 InputStream 响应(基于 Java 11 HttpClient) */
public HttpResponse<java.io.InputStream> openStream(String fullUrl, Object jsonBody, Map<String, String> extraHeaders) throws Exception {
return openCallStream(fullUrl, jsonBody, extraHeaders);
}

/**
* 打开 SSE 流式连接并逐行回调处理(基于 Apache HttpClient 5)。
* 使用生产者-消费者模式异步读取流数据,支持 3 秒超时自动结束。
*
* @param fullUrl 完整的请求 URL
* @param jsonBody 请求体对象(将被 JSON 序列化后经 QoderEncoding 编码)
* @param extraHeaders 额外的请求头
* @param onLine 每读取到一行数据时的回调函数
*/
public void openStreamLines(String fullUrl, Object jsonBody, Map<String, String> extraHeaders, java.util.function.Consumer<String> onLine) throws Exception {
URI u = URI.create(fullUrl);
String pathQuery = u.getRawPath();
Expand Down Expand Up @@ -73,6 +91,11 @@ public void openStreamLines(String fullUrl, Object jsonBody, Map<String, String>
}
}

/**
* SSE 流响应处理器。
* 启动后台线程读取输入流,通过队列传递字节到主线程逐行解析。
* 当超过 3 秒无新数据时自动结束。
*/
private Void execHandler(org.apache.hc.core5.http.ClassicHttpResponse response, java.util.function.Consumer<String> onLine) throws Exception {
if (response.getCode() != 200) {
String errBody = org.apache.hc.core5.http.io.entity.EntityUtils.toString(response.getEntity());
Expand Down Expand Up @@ -118,6 +141,10 @@ private Void execHandler(org.apache.hc.core5.http.ClassicHttpResponse response,
return null;
}

/**
* 发送 HTTP 请求的通用方法(基于 Java 11 HttpClient)。
* 自动完成: 请求体 QoderEncoding 编码 → 构建 payload → 计算签名 → 组装 Bearer
*/
private JsonNode call(String method, String fullUrl, Object jsonBody, Map<String,String> extraHeaders) throws Exception {
URI u = URI.create(fullUrl);
String pathQuery = u.getRawPath();
Expand Down Expand Up @@ -165,6 +192,10 @@ private JsonNode call(String method, String fullUrl, Object jsonBody, Map<String
return objectMapper.readTree(resp.body());
}

/**
* 打开 SSE 流式连接(基于 Java 11 HttpClient,返回 InputStream)。
* 超时设置为 5 分钟,适用于长时间流式响应。
*/
private HttpResponse<java.io.InputStream> openCallStream(String fullUrl, Object jsonBody,
Map<String,String> extraHeaders) throws Exception {
URI u = URI.create(fullUrl);
Expand Down
30 changes: 30 additions & 0 deletions src/main/java/us/cubk/BearerBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,19 @@
import java.util.Map;
import java.util.UUID;

/**
* Bearer 认证令牌构建器。
* 负责创建会话上下文、生成请求签名和组装 Bearer 令牌。
* 认证流程:
* 1. 生成 16 字节的临时 AES 密钥
* 2. 使用服务器 RSA 公钥加密临时密钥 → cosyKey
* 3. 使用临时密钥 AES 加密用户身份信息 → info
* 4. 请求时将 info + cosyKey + 请求内容组合后 MD5 签名
* 5. 组装 "Bearer COSY.{payload}.{signature}" 格式的授权头
*/
public final class BearerBuilder {

/** 服务器 RSA 公钥,用于加密临时密钥 */
public static final String SERVER_PUBKEY_PEM = (
"-----BEGIN PUBLIC KEY-----\n"
+ "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc\n"
Expand All @@ -27,23 +38,37 @@ public final class BearerBuilder {
+ "-----END PUBLIC KEY-----");

private static final ObjectMapper objectMapper = new ObjectMapper();
/** 用户身份信息记录,包含用户名、账号 ID、UID、组织信息和认证令牌等 */
public record AuthIdentity(String name, String aid, String uid, String yxUid, String organizationId, String organizationName, String userType, String securityOauthToken, String refreshToken) {}

/** 会话上下文,包含临时密钥、加密后的 cosyKey、身份信息和机器标识等 */
public record SessionContext(byte[] tempKey, String cosyKey, String info, AuthIdentity identity, String machineId, String machineToken, String machineType) {
}

/**
* 创建新的认证会话。
* 流程: 生成随机临时密钥(16字节) → RSA加密临时密钥 → AES加密身份信息
*/
public static SessionContext newSession(AuthIdentity id, String machineId, String machineToken, String machineType) throws Exception {
byte[] tempKey = UUID.randomUUID().toString().replace("-", "").substring(0, 16).getBytes(StandardCharsets.US_ASCII);
String cosyKey = Base64.getEncoder().encodeToString(rsaEncrypt(tempKey));
String info = Base64.getEncoder().encodeToString(aesEncrypt(authPayloadJson(id), tempKey));
return new SessionContext(tempKey, cosyKey, info, id, machineId, machineToken, machineType);
}

/**
* 生成请求签名。
* 签名算法: MD5(payload + "\n" + cosyKey + "\n" + date + "\n" + body + "\n" + path)
*/
public static String signRequest(String payloadB64, String cosyKey, String cosyDate, String body, String pathWithoutAlgo) throws Exception {
String s = payloadB64 + "\n" + cosyKey + "\n" + cosyDate + "\n" + body + "\n" + pathWithoutAlgo;
return md5Hex(s);
}

/**
* 构建请求负载的 Base64 编码。
* 负载包含版本号、加密后的身份信息和请求 ID。
*/
public static String buildPayloadB64(String info) throws Exception {
Map<String, String> m = new LinkedHashMap<>();
m.put("cosyVersion", "0.1.43");
Expand All @@ -55,10 +80,12 @@ public static String buildPayloadB64(String info) throws Exception {
return Base64.getEncoder().encodeToString(objectMapper.writeValueAsBytes(sorted));
}

/** 组装最终的 Bearer 授权头,格式: "Bearer COSY.{payloadB64}.{signature}" */
public static String composeBearer(String payloadB64, String sig) {
return "Bearer COSY." + payloadB64 + "." + sig;
}

/** 将身份信息序列化为 JSON 字节数组,用于 AES 加密 */
static byte[] authPayloadJson(AuthIdentity id) throws Exception {
ObjectNode n = objectMapper.createObjectNode();
n.put("name", id.name());
Expand All @@ -73,6 +100,7 @@ static byte[] authPayloadJson(AuthIdentity id) throws Exception {
return objectMapper.writeValueAsBytes(n);
}

/** 使用服务器 RSA 公钥加密临时密钥(RSA/ECB/PKCS1Padding) */
static byte[] rsaEncrypt(byte[] tempKey) throws Exception {
String b64 = SERVER_PUBKEY_PEM.replace("-----BEGIN PUBLIC KEY-----", "").replace("-----END PUBLIC KEY-----", "").replaceAll("\\s+", "");
byte[] der = Base64.getDecoder().decode(b64);
Expand All @@ -82,12 +110,14 @@ static byte[] rsaEncrypt(byte[] tempKey) throws Exception {
return c.doFinal(tempKey);
}

/** AES/CBC/PKCS5Padding 加密,IV 与密钥相同 */
static byte[] aesEncrypt(byte[] plain, byte[] key) throws Exception {
Cipher c = Cipher.getInstance("AES/CBC/PKCS5Padding");
c.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new IvParameterSpec(key));
return c.doFinal(plain);
}

/** 计算 MD5 哈希值,返回 32 位小写十六进制字符串 */
static String md5Hex(String s) throws Exception {
byte[] h = MessageDigest.getInstance("MD5").digest(s.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(32);
Expand Down
16 changes: 16 additions & 0 deletions src/main/java/us/cubk/JobTokenClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,27 @@
import java.net.http.HttpResponse;
import java.time.Duration;

/**
* 作业令牌交换客户端。
* 通过个人访问令牌(PAT)向 Qoder 中心服务交换会话令牌,
* 获取包含用户 ID、安全令牌、刷新令牌等信息的会话对象。
*/
public final class JobTokenClient {
private static final ObjectMapper objectMapper = new ObjectMapper();

/** 会话信息记录,包含用户 ID、名称、安全令牌、刷新令牌、过期时间、邮箱、计划类型和原始响应 */
public record Session(String userId, String name, String securityOauthToken, String refreshToken, long expireTime, String email, String plan, String raw) {}

/**
* 使用个人令牌交换会话令牌。
* 请求体经过 QoderEncoding 编码后发送到 Qoder 中心服务。
*
* @param personalToken 个人访问令牌 (PAT)
* @param machineId 机器唯一标识
* @param machineToken 机器令牌
* @param machineType 机器类型标识
* @return 会话信息
*/
public static Session exchange(String personalToken, String machineId, String machineToken, String machineType) throws Exception {
String date = Signature.currentDate();
String sig = Signature.sign(date);
Expand Down
19 changes: 19 additions & 0 deletions src/main/java/us/cubk/LocalAuth.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,38 @@
import java.nio.file.Paths;
import java.util.Base64;

/**
* 本地认证信息读取器。
* 从用户主目录下的 ~/.qoder/.auth/ 目录读取机器 ID 和加密存储的用户信息。
* 用户信息使用 AES/CBC/PKCS5Padding 加密存储,密钥为机器 ID 的前 16 位。
*/
public final class LocalAuth {

private static final ObjectMapper OM = new ObjectMapper();

/**
* 获取默认的认证文件目录: ~/.qoder/.auth/
*/
public static Path defaultDir() {
String home = System.getProperty("user.home");
return Paths.get(home, ".qoder", ".auth");
}

/**
* 读取本地存储的机器 ID。
* 文件路径: ~/.qoder/.auth/id
*/
public static String readMachineId() throws Exception {
return new String(Files.readAllBytes(defaultDir().resolve("id")), StandardCharsets.UTF_8).trim();
}

/**
* 读取并解密本地存储的用户信息。
* 文件路径: ~/.qoder/.auth/user(Base64 编码的 AES 密文)
* 解密密钥: 机器 ID 的前 16 字节,同时作为 IV 使用
*
* @return 解密后的用户信息 JSON
*/
public static JsonNode readUserInfo() throws Exception {
String mid = readMachineId();
byte[] cipherBytes = Base64.getDecoder().decode(new String(Files.readAllBytes(defaultDir().resolve("user")), StandardCharsets.UTF_8).trim());
Expand Down
35 changes: 34 additions & 1 deletion src/main/java/us/cubk/OpenAiBridge.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,25 @@
import java.util.Map;
import java.util.UUID;

/**
* OpenAI 兼容 API 网关,项目主入口。
* 在本地启动 HTTP 服务器,提供兼容 OpenAI 的 /v1/chat/completions 接口。
* 将 OpenAI 格式的请求转换为 Qoder API 格式,并将响应转换回 OpenAI 格式返回。
* 支持流式 (SSE) 和非流式两种响应模式。
*/
public final class OpenAiBridge {

private static final ObjectMapper objectMapper = new ObjectMapper();
private final BearerBuilder.SessionContext sess;
private final BearerApiClient bearerClient;
private final JsonNode templateBase;

/**
* 初始化桥接服务。
* 流程: PAT 令牌交换 → 创建认证会话 → 加载 baseprompt.json 模板
*
* @param pat 个人访问令牌 (Personal Access Token)
*/
public OpenAiBridge(String pat) throws Exception {
String mid = UUID.randomUUID().toString();
String mtoken = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString((UUID.randomUUID().toString() + UUID.randomUUID()).substring(0, 50).getBytes());
Expand All @@ -42,6 +54,7 @@ public OpenAiBridge(String pat) throws Exception {
this.templateBase = objectMapper.readTree(basePrompt);
}

/** 启动 HTTP 服务器,监听指定端口的 /v1/chat/completions 路径 */
public void start(int port) throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0);
server.createContext("/v1/chat/completions", this::handleChat);
Expand All @@ -50,6 +63,14 @@ public void start(int port) throws Exception {
System.out.println("[bridge] listening http://127.0.0.1:" + port + "/v1/chat/completions");
}

/**
* 处理 OpenAI 格式的聊天完成请求。
* 流程:
* 1. 解析 OpenAI 格式的请求(提取 model、messages、stream 参数)
* 2. 构建 Qoder API 格式的请求体(基于 baseprompt.json 模板)
* 3. 调用 Qoder API 获取流式响应
* 4. 将响应转换为 OpenAI 格式返回(流式或非流式)
*/
private void handleChat(HttpExchange ex) throws IOException {
try {
if (!"POST".equals(ex.getRequestMethod())) {
Expand Down Expand Up @@ -186,6 +207,7 @@ private void handleChat(HttpExchange ex) throws IOException {
}
}

/** 从 SSE 输入流中逐行解析并回调内容(备用方法,当前未使用) */
private void streamSseChunks(java.io.InputStream is, java.util.function.Consumer<String> onChunk) throws IOException {
java.io.ByteArrayOutputStream lineBuf = new java.io.ByteArrayOutputStream();
int b;
Expand All @@ -211,6 +233,11 @@ private void streamSseChunks(java.io.InputStream is, java.util.function.Consumer
}
}

/**
* 从 SSE data 行中提取内容文本。
* 响应格式: {"body": "{\"choices\":[{\"delta\":{\"content\":\"...\"}]}"}
* 需要两层 JSON 解析:外层取 body 字段,内层取 choices[].delta.content
*/
private String extractContent(String dataLine) {
try {
JsonNode wrapper = objectMapper.readTree(dataLine);
Expand All @@ -227,6 +254,7 @@ private String extractContent(String dataLine) {
return null;
}

/** 构建 OpenAI 流式响应的 chunk 对象模板 */
private ObjectNode makeChunk(String id, long created, String model) {
ObjectNode root = objectMapper.createObjectNode();
root.put("id", id); root.put("object", "chat.completion.chunk");
Expand All @@ -241,6 +269,10 @@ private ObjectNode makeChunk(String id, long created, String model) {
return root;
}

/**
* 启动桥接服务的便捷方法。
* 若未提供 PAT,将从系统属性 QODER_PAT 中读取。
*/
public static void run(String pat, int port) throws Exception {
if (pat == null || pat.isBlank()) {
pat = System.getProperty("QODER_PAT");
Expand All @@ -250,7 +282,8 @@ public static void run(String pat, int port) throws Exception {
Thread.currentThread().join();
}

/** 程序入口,默认监听 8963 端口 */
public static void main(String[] args) throws Exception {
run(null, 8963);
run("", 8963);
}
}
Loading