// 联网评审的数据层:造请求体、造 curl 配置、解析响应。
//
// 这一整层都是纯函数——不联网、不碰文件,所以能离线跑测试。真正调 curl 的那半
// 在 cmd/moonreview,因为 MoonBit 的 native 目标没有 HTTPS 标准库,发请求只能借外部
// 程序;把「发什么」和「怎么发」分开,换供应商时只需要改常量和环境变量。
//
// 一个刻意的取舍:API Key 只写进 curl 配置文件,绝不出现在命令行参数里。
// 进程列表是整机可见的,命令行带密钥等于泄露给同一台机器上的所有用户。
///|
/// 供应商相关的默认值。用 OpenAI 兼容接口的服务都能跑,
/// 靠 MOONREVIEW_BASE_URL / MOONREVIEW_MODEL 覆盖成自己那家。
let default_base_url : String = "https://token.sensenova.cn/v1"
///|
let default_model : String = "sensenova-6.7-flash-lite"
///|
/// 请求体、curl 配置、响应都落在当前目录的隐藏文件里:
/// 用相对路径可以绕开 Windows 反斜杠在 curl 配置里被当转义符的坑,
/// 也让命令本身变成一个不含任何外部输入的固定字符串。
pub let body_file : String = ".moonreview-body.json"
///|
pub let config_file : String = ".moonreview-curl.cfg"
///|
pub let response_file : String = ".moonreview-response.json"
///|
/// OpenAI 兼容接口都是 base_url 再加一段路径;末尾多余的斜杠会拼出 `//v1//`
/// 这种地址,所以先去掉再拼。
fn drop_trailing_slash(s : String) -> String {
guard s.has_suffix("/") && s.length() > 1 else { return s }
s.unsafe_substring(start=0, end=s.length() - 1)
}
///|
pub fn chat_endpoint(base_url : String) -> String {
drop_trailing_slash(base_url) + "/chat/completions"
}
///|
/// curl 配置里的值用双引号包住,串里的 `\` 和 `"` 要再转义一层,
/// 否则密钥里带个引号就能把后面的行改成别的配置项。
fn curl_quote(value : String) -> String {
let escaped = value
.replace_all(old="\\", new="\\\\")
.replace_all(old="\"", new="\\\"")
"\"" + escaped + "\""
}
///|
/// curl 的配置文件格式,对应 `curl -K `。
/// 请求体走 `data-binary = "@file"`,只有文件名进配置,正文不进。
pub fn curl_config(base_url : String, api_key : String) -> String {
[
"silent",
"show-error",
"max-time = \"180\"",
"request = \"POST\"",
"url = " + curl_quote(chat_endpoint(base_url)),
"header = \"Content-Type: application/json\"",
"header = " + curl_quote("Authorization: Bearer " + api_key),
"data-binary = " + curl_quote("@" + body_file),
"output = " + curl_quote(response_file),
].join("\n") +
"\n"
}
///|
/// 固定命令串:不含任何来自 diff、密钥或环境的内容,所以交给 shell 执行是安全的。
pub let curl_command : String = "curl -K " + config_file
///|
/// 请求体:system 消息放规则,user 消息放逐文件的 diff 文本。
pub fn Diff::chat_body(self : Diff, model : String) -> String {
let files : Array[String] = self.files.map(file_text)
jobj([
("model", jstr(model)),
(
"messages",
jarr([
jobj([("role", jstr("system")), ("content", jstr(prompt_rules))]),
jobj([("role", jstr("user")), ("content", jstr(files.join("\n\n")))]),
]),
),
("stream", jbool(false)),
]).stringify()
}
///|
fn json_get(j : Json?, key : String) -> Json? {
match j {
Some(Object(obj)) => obj.get(key)
_ => None
}
}
///|
fn json_item(j : Json?, index : Int) -> Json? {
match j {
Some(Array(items)) =>
if index >= 0 && index < items.length() {
Some(items[index])
} else {
None
}
_ => None
}
}
///|
fn json_text(j : Json?) -> String? {
match j {
Some(String(s)) => Some(s)
_ => None
}
}
///|
/// 成功时返回模型写的正文,失败时返回一句能直接给人看的说明。
/// 用元组而不是新类型,是为了不把错误类型的构造器再导出一次。
pub fn chat_reply(raw : String) -> (Bool, String) {
let root : Json? = Some(
@json.parse(raw.view()) catch {
_ => return (false, "接口返回的不是 JSON: " + trim_one_line(raw))
},
)
let api_error = json_get(json_get(root, "error"), "message") |> json_text
match api_error {
Some(msg) => (false, "接口报错: " + msg)
None => {
let message = json_get(json_item(json_get(root, "choices"), 0), "message")
let text = json_get(message, "content") |> json_text
match text {
Some(t) => (true, t)
None =>
(
false,
"响应里没有 choices[0].message.content: " +
trim_one_line(raw),
)
}
}
}
}
///|
/// 原始响应可能很长(也可能根本不是 JSON),只留够排查的一段。
///
/// 按字符数裁,不按字节数:`unsafe_substring` 的下标是字节下标,落在多字节字符
/// 中间会切出非法 UTF-8,而响应正文里带中文是常态(网关的报错就是中文)。
fn trim_one_line(s : String) -> String {
let one_line = s
.replace_all(old="\r\n", new=" ")
.replace_all(old="\r", new=" ")
.replace_all(old="\n", new=" ")
let cs = one_line.to_array()
if cs.length() <= 400 {
one_line
} else {
String::from_array(cs[:400]) + " …"
}
}
///|
/// base_url 和 model 都允许不传,这时用默认值。
pub fn resolve_base_url(value : String?) -> String {
match value {
None | Some("") => default_base_url
Some(v) => v
}
}
///|
pub fn resolve_model(value : String?) -> String {
match value {
None | Some("") => default_model
Some(v) => v
}
}