598 lines
21 KiB
Rust
598 lines
21 KiB
Rust
use crate::{
|
|
model::{Channel, SourceKind, TimelineEvent},
|
|
persona_runtime::PublicPersona,
|
|
storage,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{json, Value};
|
|
use std::{fs, net::IpAddr, time::Duration};
|
|
use tauri::AppHandle;
|
|
|
|
const KEYCHAIN_SERVICE: &str = "world.guanghu.hololake.persona-model";
|
|
const PROVIDER_KIND: &str = "OPENAI_COMPATIBLE_CHAT_COMPLETIONS_V1";
|
|
const MAX_HISTORY_EVENTS: usize = 24;
|
|
const MAX_RESPONSE_CHARS: usize = 64_000;
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
pub struct PersonaModelConfig {
|
|
pub schema: String,
|
|
pub persona_id: String,
|
|
pub provider_kind: String,
|
|
pub display_name: String,
|
|
pub endpoint_url: String,
|
|
pub model: String,
|
|
pub configured_at: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PersonaInferenceSnapshot {
|
|
pub schema: &'static str,
|
|
pub state: String,
|
|
pub active_persona_id: Option<String>,
|
|
pub provider_kind: Option<String>,
|
|
pub display_name: Option<String>,
|
|
pub endpoint_url: Option<String>,
|
|
pub model: Option<String>,
|
|
pub api_key_present: bool,
|
|
pub model_is_persona: bool,
|
|
pub execution_authority: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PersonaInteractionReceipt {
|
|
pub schema: &'static str,
|
|
pub receipt_id: String,
|
|
pub state: String,
|
|
pub user_event: TimelineEvent,
|
|
pub persona_event: Option<TimelineEvent>,
|
|
pub system_receipt: Option<TimelineEvent>,
|
|
}
|
|
|
|
fn config_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
|
Ok(storage::root(app)?.join("persona-inference/model-binding.json"))
|
|
}
|
|
|
|
fn load_config(app: &AppHandle) -> Result<Option<PersonaModelConfig>, String> {
|
|
let path = config_path(app)?;
|
|
if !path.exists() {
|
|
return Ok(None);
|
|
}
|
|
storage::read_json(&path).map(Some)
|
|
}
|
|
|
|
fn credential_account(persona_id: &str) -> String {
|
|
format!("{persona_id}:api-key:v1")
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn load_api_key(persona_id: &str) -> Result<Option<Vec<u8>>, String> {
|
|
match security_framework::passwords::get_generic_password(
|
|
KEYCHAIN_SERVICE,
|
|
&credential_account(persona_id),
|
|
) {
|
|
Ok(value) => Ok(Some(value)),
|
|
Err(error) if error.code() == -25300 => Ok(None),
|
|
Err(_) => Err("PERSONA_MODEL_KEYCHAIN_READ_FAILED".into()),
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn save_api_key(persona_id: &str, value: &[u8]) -> Result<(), String> {
|
|
security_framework::passwords::set_generic_password(
|
|
KEYCHAIN_SERVICE,
|
|
&credential_account(persona_id),
|
|
value,
|
|
)
|
|
.map_err(|_| "PERSONA_MODEL_KEYCHAIN_WRITE_FAILED".to_string())
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn delete_api_key(persona_id: &str) -> Result<(), String> {
|
|
match security_framework::passwords::delete_generic_password(
|
|
KEYCHAIN_SERVICE,
|
|
&credential_account(persona_id),
|
|
) {
|
|
Ok(()) => Ok(()),
|
|
Err(error) if error.code() == -25300 => Ok(()),
|
|
Err(_) => Err("PERSONA_MODEL_KEYCHAIN_DELETE_FAILED".into()),
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
fn load_api_key(_persona_id: &str) -> Result<Option<Vec<u8>>, String> {
|
|
Err("PERSONA_MODEL_KEYCHAIN_UNAVAILABLE_ON_PLATFORM".into())
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
fn save_api_key(_persona_id: &str, _value: &[u8]) -> Result<(), String> {
|
|
Err("PERSONA_MODEL_KEYCHAIN_UNAVAILABLE_ON_PLATFORM".into())
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
fn delete_api_key(_persona_id: &str) -> Result<(), String> {
|
|
Err("PERSONA_MODEL_KEYCHAIN_UNAVAILABLE_ON_PLATFORM".into())
|
|
}
|
|
|
|
fn validate_endpoint(value: &str) -> Result<String, String> {
|
|
let endpoint = reqwest::Url::parse(value.trim())
|
|
.map_err(|_| "PERSONA_MODEL_ENDPOINT_INVALID".to_string())?;
|
|
if endpoint.username() != ""
|
|
|| endpoint.password().is_some()
|
|
|| endpoint.query().is_some()
|
|
|| endpoint.fragment().is_some()
|
|
{
|
|
return Err("PERSONA_MODEL_ENDPOINT_CREDENTIAL_OR_QUERY_REJECTED".into());
|
|
}
|
|
let host = endpoint
|
|
.host_str()
|
|
.ok_or_else(|| "PERSONA_MODEL_ENDPOINT_HOST_MISSING".to_string())?;
|
|
match endpoint.scheme() {
|
|
"https" => {}
|
|
"http"
|
|
if host.eq_ignore_ascii_case("localhost")
|
|
|| host
|
|
.parse::<IpAddr>()
|
|
.map(|ip| ip.is_loopback())
|
|
.unwrap_or(false) => {}
|
|
_ => return Err("PERSONA_MODEL_ENDPOINT_REQUIRES_HTTPS_OR_LOOPBACK".into()),
|
|
}
|
|
if !endpoint.path().ends_with("/chat/completions") {
|
|
return Err("PERSONA_MODEL_ENDPOINT_MUST_BE_CHAT_COMPLETIONS".into());
|
|
}
|
|
Ok(endpoint.to_string())
|
|
}
|
|
|
|
fn clean_text(value: &str, empty_error: &str, max_chars: usize) -> Result<String, String> {
|
|
let value = value.trim();
|
|
if value.is_empty() || value.chars().count() > max_chars {
|
|
return Err(empty_error.into());
|
|
}
|
|
Ok(value.into())
|
|
}
|
|
|
|
pub fn snapshot(
|
|
app: &AppHandle,
|
|
active_persona_id: Option<String>,
|
|
) -> Result<PersonaInferenceSnapshot, String> {
|
|
let config = load_config(app)?;
|
|
let api_key_present = if let Some(config) = &config {
|
|
load_api_key(&config.persona_id)?.is_some()
|
|
} else {
|
|
false
|
|
};
|
|
let state = match (&active_persona_id, &config) {
|
|
(None, _) => "READY_NO_PERSONA",
|
|
(Some(_), None) => "PERSONA_MODEL_BINDING_REQUIRED",
|
|
(Some(active), Some(config)) if active != &config.persona_id => {
|
|
"PERSONA_MODEL_BINDING_PERSONA_MISMATCH"
|
|
}
|
|
(Some(_), Some(_)) => "PERSONA_MODEL_BOUND_READY",
|
|
};
|
|
Ok(PersonaInferenceSnapshot {
|
|
schema: "hololake.persona-inference-snapshot/v1",
|
|
state: state.into(),
|
|
active_persona_id,
|
|
provider_kind: config.as_ref().map(|value| value.provider_kind.clone()),
|
|
display_name: config.as_ref().map(|value| value.display_name.clone()),
|
|
endpoint_url: config.as_ref().map(|value| value.endpoint_url.clone()),
|
|
model: config.as_ref().map(|value| value.model.clone()),
|
|
api_key_present,
|
|
model_is_persona: false,
|
|
execution_authority: false,
|
|
})
|
|
}
|
|
|
|
pub fn configure(
|
|
app: &AppHandle,
|
|
persona_id: &str,
|
|
display_name: &str,
|
|
endpoint_url: &str,
|
|
model: &str,
|
|
api_key: Option<String>,
|
|
) -> Result<PersonaInferenceSnapshot, String> {
|
|
if !crate::persona_runtime::exists(app, persona_id)? {
|
|
return Err("PERSONA_NOT_FOUND".into());
|
|
}
|
|
let persona_id = clean_text(persona_id, "PERSONA_ID_INVALID", 96)?;
|
|
let display_name = clean_text(display_name, "PERSONA_MODEL_DISPLAY_NAME_INVALID", 64)?;
|
|
let model = clean_text(model, "PERSONA_MODEL_ID_INVALID", 160)?;
|
|
let endpoint_url = validate_endpoint(endpoint_url)?;
|
|
let is_loopback = reqwest::Url::parse(&endpoint_url)
|
|
.ok()
|
|
.and_then(|url| url.host_str().map(str::to_owned))
|
|
.map(|host| {
|
|
host.eq_ignore_ascii_case("localhost")
|
|
|| host
|
|
.parse::<IpAddr>()
|
|
.map(|ip| ip.is_loopback())
|
|
.unwrap_or(false)
|
|
})
|
|
.unwrap_or(false);
|
|
if let Some(value) = api_key
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty())
|
|
{
|
|
if value.chars().count() > 8192 {
|
|
return Err("PERSONA_MODEL_API_KEY_TOO_LARGE".into());
|
|
}
|
|
save_api_key(&persona_id, value.as_bytes())?;
|
|
}
|
|
if !is_loopback && load_api_key(&persona_id)?.is_none() {
|
|
return Err("PERSONA_MODEL_API_KEY_REQUIRED_FOR_REMOTE_ENDPOINT".into());
|
|
}
|
|
storage::write_json(
|
|
&config_path(app)?,
|
|
&PersonaModelConfig {
|
|
schema: "hololake.persona-model-binding/v1".into(),
|
|
persona_id: persona_id.clone(),
|
|
provider_kind: PROVIDER_KIND.into(),
|
|
display_name,
|
|
endpoint_url,
|
|
model,
|
|
configured_at: storage::now(),
|
|
},
|
|
)?;
|
|
storage::append_event(
|
|
app,
|
|
&storage::event(
|
|
SourceKind::SystemReceipt,
|
|
"人格模型已绑定",
|
|
format!(
|
|
"{persona_id} 已绑定可替换推理资源;密钥仅保存在系统钥匙串,模型不因此成为人格体。"
|
|
),
|
|
"SUCCEEDED",
|
|
),
|
|
)?;
|
|
snapshot(app, Some(persona_id))
|
|
}
|
|
|
|
pub fn clear(app: &AppHandle, persona_id: &str) -> Result<PersonaInferenceSnapshot, String> {
|
|
if let Some(config) = load_config(app)? {
|
|
if config.persona_id != persona_id {
|
|
return Err("PERSONA_MODEL_BINDING_PERSONA_MISMATCH".into());
|
|
}
|
|
delete_api_key(persona_id)?;
|
|
let path = config_path(app)?;
|
|
if path.exists() {
|
|
fs::remove_file(path).map_err(|_| "PERSONA_MODEL_CONFIG_DELETE_FAILED".to_string())?;
|
|
}
|
|
storage::append_event(
|
|
app,
|
|
&storage::event(
|
|
SourceKind::SystemReceipt,
|
|
"人格模型绑定已移除",
|
|
format!("{persona_id} 的模型配置与钥匙串凭据已移除;人格试用记录未被删除。"),
|
|
"SUCCEEDED",
|
|
),
|
|
)?;
|
|
}
|
|
snapshot(app, Some(persona_id.into()))
|
|
}
|
|
|
|
pub fn clear_if_bound(app: &AppHandle, persona_id: &str) -> Result<(), String> {
|
|
if load_config(app)?
|
|
.as_ref()
|
|
.map(|config| config.persona_id.as_str())
|
|
== Some(persona_id)
|
|
{
|
|
clear(app, persona_id)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn inference_context(channel: &Channel, persona: &PublicPersona) -> Value {
|
|
json!({
|
|
"schema": "hololake.persona-inference-context/v1",
|
|
"sourceKind": "SYSTEM_CONTEXT",
|
|
"channel": {"channelId": channel.channel_id, "name": channel.name},
|
|
"persona": {
|
|
"personaId": persona.persona_id,
|
|
"displayName": persona.display_name,
|
|
"state": persona.state,
|
|
"verifiedExistingPersona": false
|
|
},
|
|
"boundaries": {
|
|
"modelIsReplaceableInferenceResourceNotPersona": true,
|
|
"systemReceiptIsNotHumanOrPersonaSpeech": true,
|
|
"executionAuthority": false,
|
|
"toolCallsEnabled": false,
|
|
"fifthDomainPrivateContentMounted": false,
|
|
"honestyBeforeImmersion": true
|
|
},
|
|
"responseContract": {
|
|
"sourceKind": "PERSONA_RESPONSE",
|
|
"plainNaturalLanguageOnly": true,
|
|
"doNotPretendAnActionExecuted": true,
|
|
"doNotRepeatSystemContext": true
|
|
}
|
|
})
|
|
}
|
|
|
|
fn build_request(
|
|
config: &PersonaModelConfig,
|
|
channel: &Channel,
|
|
persona: &PublicPersona,
|
|
timeline: &[TimelineEvent],
|
|
) -> Value {
|
|
let mut dialogue = timeline
|
|
.iter()
|
|
.filter_map(|event| match event.source_kind {
|
|
SourceKind::UserMessage => Some(json!({"role":"user","content":event.content})),
|
|
SourceKind::PersonaResponse
|
|
if event.actor_id.as_deref() == Some(persona.persona_id.as_str()) =>
|
|
{
|
|
Some(json!({"role":"assistant","content":event.content}))
|
|
}
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
if dialogue.len() > MAX_HISTORY_EVENTS {
|
|
dialogue.drain(..dialogue.len() - MAX_HISTORY_EVENTS);
|
|
}
|
|
let mut messages = vec![json!({
|
|
"role": "system",
|
|
"content": inference_context(channel, persona).to_string()
|
|
})];
|
|
messages.extend(dialogue);
|
|
json!({"model":config.model,"messages":messages,"stream":false})
|
|
}
|
|
|
|
fn parse_response(value: &Value) -> Result<String, String> {
|
|
let content = value
|
|
.pointer("/choices/0/message/content")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.ok_or_else(|| "PERSONA_MODEL_RESPONSE_CONTENT_MISSING".to_string())?;
|
|
if content.chars().count() > MAX_RESPONSE_CHARS {
|
|
return Err("PERSONA_MODEL_RESPONSE_TOO_LARGE".into());
|
|
}
|
|
Ok(content.into())
|
|
}
|
|
|
|
fn request_model(
|
|
config: &PersonaModelConfig,
|
|
api_key: Option<&[u8]>,
|
|
payload: &Value,
|
|
) -> Result<String, String> {
|
|
let client = reqwest::blocking::Client::builder()
|
|
.timeout(Duration::from_secs(90))
|
|
.build()
|
|
.map_err(|_| "PERSONA_MODEL_CLIENT_INIT_FAILED".to_string())?;
|
|
let mut request = client.post(&config.endpoint_url).json(payload);
|
|
if let Some(bytes) = api_key {
|
|
let key =
|
|
std::str::from_utf8(bytes).map_err(|_| "PERSONA_MODEL_API_KEY_INVALID".to_string())?;
|
|
request = request.bearer_auth(key);
|
|
}
|
|
let response = request
|
|
.send()
|
|
.map_err(|_| "PERSONA_MODEL_UNREACHABLE".to_string())?;
|
|
let status = response.status();
|
|
if !status.is_success() {
|
|
return Err(format!("PERSONA_MODEL_HTTP_{}", status.as_u16()));
|
|
}
|
|
let value: Value = response
|
|
.json()
|
|
.map_err(|_| "PERSONA_MODEL_RESPONSE_INVALID_JSON".to_string())?;
|
|
parse_response(&value)
|
|
}
|
|
|
|
pub fn respond(
|
|
app: &AppHandle,
|
|
user_event: TimelineEvent,
|
|
) -> Result<PersonaInteractionReceipt, String> {
|
|
let runtime = crate::persona_runtime::snapshot(app)?;
|
|
let Some(persona_id) = runtime.active_persona_id else {
|
|
return waiting_receipt(
|
|
app,
|
|
user_event,
|
|
"尚未建立人格体;用户语言已保存,可由外部 AI 实时桥接收。",
|
|
);
|
|
};
|
|
let persona = runtime
|
|
.personas
|
|
.into_iter()
|
|
.find(|value| value.persona_id == persona_id)
|
|
.ok_or_else(|| "ACTIVE_PERSONA_NOT_FOUND".to_string())?;
|
|
let Some(config) = load_config(app)? else {
|
|
return waiting_receipt(
|
|
app,
|
|
user_event,
|
|
"人格体尚未绑定模型;用户语言已保存,可先在设置中配置模型。",
|
|
);
|
|
};
|
|
if config.persona_id != persona.persona_id {
|
|
return failed_receipt(app, user_event, "PERSONA_MODEL_BINDING_PERSONA_MISMATCH");
|
|
}
|
|
let channel = storage::channel(app)?.ok_or_else(|| "CHANNEL_NOT_INITIALIZED".to_string())?;
|
|
let mut timeline = storage::events(app)?;
|
|
timeline.reverse();
|
|
let payload = build_request(&config, &channel, &persona, &timeline);
|
|
let api_key = load_api_key(&persona.persona_id)?;
|
|
match request_model(&config, api_key.as_deref(), &payload) {
|
|
Ok(content) => {
|
|
let persona_event = storage::actor_event(
|
|
SourceKind::PersonaResponse,
|
|
&persona.persona_id,
|
|
persona.display_name,
|
|
content,
|
|
"SUCCEEDED",
|
|
);
|
|
storage::append_event(app, &persona_event)?;
|
|
Ok(PersonaInteractionReceipt {
|
|
schema: "hololake.persona-interaction-receipt/v1",
|
|
receipt_id: storage::id("HL-PER-RCP"),
|
|
state: "PERSONA_RESPONSE_RECORDED".into(),
|
|
user_event,
|
|
persona_event: Some(persona_event),
|
|
system_receipt: None,
|
|
})
|
|
}
|
|
Err(code) => Ok(failed_receipt(app, user_event, &code)?),
|
|
}
|
|
}
|
|
|
|
fn waiting_receipt(
|
|
app: &AppHandle,
|
|
user_event: TimelineEvent,
|
|
message: &str,
|
|
) -> Result<PersonaInteractionReceipt, String> {
|
|
let system_receipt = storage::event(SourceKind::SystemReceipt, "系统回执", message, "WAITING");
|
|
storage::append_event(app, &system_receipt)?;
|
|
Ok(PersonaInteractionReceipt {
|
|
schema: "hololake.persona-interaction-receipt/v1",
|
|
receipt_id: storage::id("HL-PER-RCP"),
|
|
state: "WAITING_PERSONA_RUNTIME".into(),
|
|
user_event,
|
|
persona_event: None,
|
|
system_receipt: Some(system_receipt),
|
|
})
|
|
}
|
|
|
|
fn failed_receipt(
|
|
app: &AppHandle,
|
|
user_event: TimelineEvent,
|
|
code: &str,
|
|
) -> Result<PersonaInteractionReceipt, String> {
|
|
let system_receipt = storage::event(
|
|
SourceKind::SystemReceipt,
|
|
"人格推理失败",
|
|
format!("模型没有产生人格回应;用户语言仍已保存。错误码:{code}"),
|
|
"FAILED",
|
|
);
|
|
storage::append_event(app, &system_receipt)?;
|
|
Ok(PersonaInteractionReceipt {
|
|
schema: "hololake.persona-interaction-receipt/v1",
|
|
receipt_id: storage::id("HL-PER-RCP"),
|
|
state: "PERSONA_RESPONSE_FAILED".into(),
|
|
user_event,
|
|
persona_event: None,
|
|
system_receipt: Some(system_receipt),
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::{
|
|
io::{Read, Write},
|
|
net::TcpListener,
|
|
thread,
|
|
};
|
|
|
|
fn config() -> PersonaModelConfig {
|
|
PersonaModelConfig {
|
|
schema: "hololake.persona-model-binding/v1".into(),
|
|
persona_id: "HL-PERSONA-TEST".into(),
|
|
provider_kind: PROVIDER_KIND.into(),
|
|
display_name: "本地测试模型".into(),
|
|
endpoint_url: "http://127.0.0.1:8181/v1/chat/completions".into(),
|
|
model: "test-model".into(),
|
|
configured_at: "2026-09-04T00:00:00.000Z".into(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn endpoint_requires_https_or_loopback_and_exact_chat_route() {
|
|
assert!(validate_endpoint("https://api.example.com/v1/chat/completions").is_ok());
|
|
assert!(validate_endpoint("http://127.0.0.1:8080/v1/chat/completions").is_ok());
|
|
assert!(validate_endpoint("http://api.example.com/v1/chat/completions").is_err());
|
|
assert!(validate_endpoint("file:///tmp/key").is_err());
|
|
assert!(validate_endpoint("https://api.example.com/v1/responses").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn request_keeps_system_receipts_out_of_dialogue_roles() {
|
|
let channel = Channel {
|
|
channel_id: "HL-CH-TEST".into(),
|
|
name: "测试频道".into(),
|
|
created_at: "2026-09-04T00:00:00.000Z".into(),
|
|
private_git_path: "/private/not-exported".into(),
|
|
public_distribution_state: "TEST".into(),
|
|
};
|
|
let persona = PublicPersona {
|
|
persona_id: "HL-PERSONA-TEST".into(),
|
|
display_name: "湖心".into(),
|
|
state: "LOCAL_TRIAL_UNVERIFIED_EXTERNAL_HOST_REQUIRED".into(),
|
|
created_at: "2026-09-04T00:00:00.000Z".into(),
|
|
reversible_until_unix_ms: 0,
|
|
};
|
|
let events = vec![
|
|
storage::event(
|
|
SourceKind::SystemReceipt,
|
|
"系统回执",
|
|
"不应进入对话",
|
|
"SUCCEEDED",
|
|
),
|
|
storage::event(SourceKind::UserMessage, "用户语言", "你好", "RECORDED"),
|
|
storage::actor_event(
|
|
SourceKind::PersonaResponse,
|
|
"HL-PERSONA-TEST",
|
|
"湖心",
|
|
"你好。",
|
|
"SUCCEEDED",
|
|
),
|
|
];
|
|
let request = build_request(&config(), &channel, &persona, &events);
|
|
let messages = request["messages"].as_array().unwrap();
|
|
assert_eq!(messages.len(), 3);
|
|
assert!(messages[0]["content"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("executionAuthority"));
|
|
assert!(!request.to_string().contains("不应进入对话"));
|
|
assert!(!request.to_string().contains("/private/not-exported"));
|
|
}
|
|
|
|
#[test]
|
|
fn response_requires_nonempty_persona_content() {
|
|
assert_eq!(
|
|
parse_response(&json!({"choices":[{"message":{"content":" 回应 "}}]})).unwrap(),
|
|
"回应"
|
|
);
|
|
assert!(parse_response(&json!({"choices":[{"message":{"content":""}}]})).is_err());
|
|
assert!(parse_response(&json!({"output_text":"wrong protocol"})).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn real_loopback_request_returns_typed_persona_text_without_executing_tools() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let address = listener.local_addr().unwrap();
|
|
let server = thread::spawn(move || {
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
let mut request = vec![0_u8; 16_384];
|
|
let size = stream.read(&mut request).unwrap();
|
|
let request = String::from_utf8_lossy(&request[..size]);
|
|
assert!(request.starts_with("POST /v1/chat/completions HTTP/1.1"));
|
|
assert!(request
|
|
.to_ascii_lowercase()
|
|
.contains("authorization: bearer test-secret"));
|
|
assert!(request.contains("SYSTEM_CONTEXT"));
|
|
let body =
|
|
r#"{"choices":[{"message":{"content":"这是来自真实本机 HTTP 回路的人格回应。"}}]}"#;
|
|
write!(
|
|
stream,
|
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
|
body.len(),
|
|
body
|
|
)
|
|
.unwrap();
|
|
});
|
|
let mut local = config();
|
|
local.endpoint_url = format!("http://{address}/v1/chat/completions");
|
|
let payload = json!({
|
|
"model": "test-model",
|
|
"messages": [{"role":"system","content":"SYSTEM_CONTEXT"}],
|
|
"stream": false
|
|
});
|
|
assert_eq!(
|
|
request_model(&local, Some(b"test-secret"), &payload).unwrap(),
|
|
"这是来自真实本机 HTTP 回路的人格回应。"
|
|
);
|
|
server.join().unwrap();
|
|
}
|
|
}
|