Merge remote-tracking branch 'origin/master' into fix/copy-pasted-on-canva
# Conflicts: # docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
Generated
+103
@@ -231,6 +231,7 @@ dependencies = [
|
||||
"platform-wechat",
|
||||
"reqwest",
|
||||
"ring",
|
||||
"rmcp",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
@@ -827,6 +828,17 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@@ -1932,6 +1944,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
@@ -3679,6 +3692,12 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
@@ -4516,6 +4535,17 @@ dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.2",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
@@ -4554,6 +4584,12 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -4720,6 +4756,35 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmcp"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"rand 0.10.2",
|
||||
"schemars 1.2.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sse-stream",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmp"
|
||||
version = "0.8.15"
|
||||
@@ -4931,12 +4996,26 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"schemars_derive",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars_derive"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_derive_internals",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
@@ -5027,6 +5106,17 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive_internals"
|
||||
version = "0.29.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
@@ -5670,6 +5760,19 @@ dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-stream"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
|
||||
@@ -112,6 +112,7 @@ pingora-http = { version = "0.8.1", default-features = false }
|
||||
pingora-proxy = { version = "0.8.1", default-features = false }
|
||||
rand_core = "0.6"
|
||||
reqwest = { version = "0.12", default-features = false }
|
||||
rmcp = { version = "=2.2.0", default-features = false }
|
||||
ring = "0.17"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -16,6 +16,7 @@ hex = { workspace = true }
|
||||
image = { workspace = true, features = ["jpeg", "png", "webp"] }
|
||||
http-body-util = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
|
||||
rmcp = { workspace = true, features = ["server", "transport-streamable-http-server"] }
|
||||
webp = { workspace = true }
|
||||
module-ai = { workspace = true }
|
||||
module-assets = { workspace = true, features = ["server-service"] }
|
||||
@@ -48,6 +49,7 @@ tokio-stream = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
time = { workspace = true, features = ["formatting"] }
|
||||
tower-http = { workspace = true, features = ["trace"] }
|
||||
tower = { workspace = true, features = ["util"] }
|
||||
tracing = { workspace = true }
|
||||
opentelemetry = { workspace = true }
|
||||
url = { workspace = true }
|
||||
@@ -62,4 +64,3 @@ windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_
|
||||
base64 = { workspace = true }
|
||||
http-body-util = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
|
||||
tower = { workspace = true, features = ["util"] }
|
||||
|
||||
@@ -48,7 +48,7 @@ use shared_contracts::assets::{
|
||||
use shared_contracts::assets::{
|
||||
CharacterRoleAssetWorkflowResolveRequest, CharacterRoleAssetWorkflowResponse,
|
||||
};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
use spacetime_client::{ExternalGenerationJobRecord, SpacetimeClientError};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
@@ -61,7 +61,7 @@ use crate::{
|
||||
editor_generation_queue::{
|
||||
EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND, EDITOR_VIDEO_GENERATION_JOB_KIND,
|
||||
EditorGenerationQueuedResponse, editor_generation_queue_state,
|
||||
editor_generation_source_entity_id, enqueue_editor_generation_job,
|
||||
editor_generation_source_entity_id, enqueue_editor_generation_job_for_caller,
|
||||
},
|
||||
editor_green_screen::{
|
||||
EditorScreenBackgroundColor, editor_green_screen_character_prompt_clause,
|
||||
@@ -578,38 +578,14 @@ pub async fn generate_editor_character_animation(
|
||||
));
|
||||
}
|
||||
if !state.config.external_generation_mode.is_inline() {
|
||||
let pricing = state.editor_generation_pricing().await.map_err(|error| {
|
||||
character_animation_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "editor-generation-pricing",
|
||||
"message": error.to_string(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
// 队列路径只取定价,背景色决策留到实际执行时再做,这里用默认色占位。
|
||||
let normalized = normalize_editor_character_animation_request_with_pricing(
|
||||
payload.clone(),
|
||||
&pricing,
|
||||
crate::editor_green_screen::default_editor_screen_background_color(),
|
||||
)
|
||||
.map_err(|error| character_animation_error_response(&request_context, error))?;
|
||||
let source_entity_id = editor_generation_source_entity_id(
|
||||
payload.project_id.as_deref(),
|
||||
payload.source_layer_id.as_str(),
|
||||
);
|
||||
let queue_job = enqueue_editor_generation_job(
|
||||
let queue_job = enqueue_editor_character_animation_for_owner(
|
||||
&state,
|
||||
&request_context,
|
||||
owner_user_id.as_str(),
|
||||
EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND,
|
||||
source_entity_id,
|
||||
"图片画布生成角色动作",
|
||||
u64::from(normalized.price_mud_points),
|
||||
&payload,
|
||||
payload,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
.await?;
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorGenerationQueuedResponse {
|
||||
@@ -627,6 +603,56 @@ pub async fn generate_editor_character_animation(
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn enqueue_editor_character_animation_for_owner(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
payload: EditorCharacterAnimationGenerateRequest,
|
||||
external_idempotency_key: Option<&str>,
|
||||
) -> Result<ExternalGenerationJobRecord, Response> {
|
||||
if matches_inline_media_source(payload.source_image_src.as_str()) {
|
||||
return Err(character_animation_error_response(
|
||||
request_context,
|
||||
editor_character_animation_bad_request(
|
||||
"sourceImageSrc 必须先上传 OSS,并使用 objectKey 或画板资源引用。",
|
||||
),
|
||||
));
|
||||
}
|
||||
let pricing = state.editor_generation_pricing().await.map_err(|error| {
|
||||
character_animation_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "editor-generation-pricing",
|
||||
"message": error.to_string(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
// 队列路径只取定价,背景色决策留到实际执行时再做,这里用默认色占位。
|
||||
let normalized = normalize_editor_character_animation_request_with_pricing(
|
||||
payload.clone(),
|
||||
&pricing,
|
||||
crate::editor_green_screen::default_editor_screen_background_color(),
|
||||
)
|
||||
.map_err(|error| character_animation_error_response(request_context, error))?;
|
||||
let source_entity_id = editor_generation_source_entity_id(
|
||||
payload.project_id.as_deref(),
|
||||
payload.source_layer_id.as_str(),
|
||||
);
|
||||
enqueue_editor_generation_job_for_caller(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id,
|
||||
EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND,
|
||||
source_entity_id,
|
||||
"图片画布生成角色动作",
|
||||
u64::from(normalized.price_mud_points),
|
||||
&payload,
|
||||
external_idempotency_key,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.into_response_with_context(Some(request_context)))
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_editor_character_animation_for_owner(
|
||||
state: AppState,
|
||||
request_context: RequestContext,
|
||||
@@ -970,31 +996,14 @@ pub async fn generate_editor_video(
|
||||
})?;
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
if !state.config.external_generation_mode.is_inline() {
|
||||
let pricing = state.editor_generation_pricing().await.map_err(|error| {
|
||||
editor_video_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "editor-generation-pricing",
|
||||
"message": error.to_string(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let normalized = normalize_editor_video_request_with_pricing(payload.clone(), &pricing)
|
||||
.map_err(|error| editor_video_error_response(&request_context, error))?;
|
||||
let source_entity_id =
|
||||
editor_generation_source_entity_id(payload.project_id.as_deref(), "editor-video");
|
||||
let queue_job = enqueue_editor_generation_job(
|
||||
let queue_job = enqueue_editor_video_generation_for_owner(
|
||||
&state,
|
||||
&request_context,
|
||||
owner_user_id.as_str(),
|
||||
EDITOR_VIDEO_GENERATION_JOB_KIND,
|
||||
source_entity_id,
|
||||
"图片画布生成视频",
|
||||
u64::from(normalized.price_mud_points),
|
||||
&payload,
|
||||
payload,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
.await?;
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorGenerationQueuedResponse {
|
||||
@@ -1005,6 +1014,41 @@ pub async fn generate_editor_video(
|
||||
generate_editor_video_for_owner(state, request_context, owner_user_id, Ok(Json(payload))).await
|
||||
}
|
||||
|
||||
pub(crate) async fn enqueue_editor_video_generation_for_owner(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
payload: EditorVideoGenerateRequest,
|
||||
external_idempotency_key: Option<&str>,
|
||||
) -> Result<ExternalGenerationJobRecord, Response> {
|
||||
let pricing = state.editor_generation_pricing().await.map_err(|error| {
|
||||
editor_video_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "editor-generation-pricing",
|
||||
"message": error.to_string(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let normalized = normalize_editor_video_request_with_pricing(payload.clone(), &pricing)
|
||||
.map_err(|error| editor_video_error_response(request_context, error))?;
|
||||
let source_entity_id =
|
||||
editor_generation_source_entity_id(payload.project_id.as_deref(), "editor-video");
|
||||
enqueue_editor_generation_job_for_caller(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id,
|
||||
EDITOR_VIDEO_GENERATION_JOB_KIND,
|
||||
source_entity_id,
|
||||
"图片画布生成视频",
|
||||
u64::from(normalized.price_mud_points),
|
||||
&payload,
|
||||
external_idempotency_key,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.into_response_with_context(Some(request_context)))
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_editor_video_for_owner(
|
||||
state: AppState,
|
||||
request_context: RequestContext,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use axum::http::StatusCode;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use shared_contracts::external_generation::{
|
||||
ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord,
|
||||
};
|
||||
@@ -26,6 +27,7 @@ pub(crate) const EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND: &str =
|
||||
pub(crate) const EDITOR_GENERATION_QUEUE_SOURCE_MODULE: &str = "editor-canvas";
|
||||
const EDITOR_GENERATION_QUEUE_PROVIDER: &str = "editor-generation-worker";
|
||||
const MAX_EDITOR_GENERATION_JOB_PAYLOAD_BYTES: usize = 512 * 1024;
|
||||
const EXTERNAL_API_GENERATION_DEDUPE_PREFIX: &str = "external-api-generation";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -77,6 +79,132 @@ where
|
||||
T: Serialize,
|
||||
{
|
||||
let request_payload_json = serialize_editor_generation_job_payload(payload)?;
|
||||
enqueue_serialized_editor_generation_job_with_identity(
|
||||
state,
|
||||
owner_user_id,
|
||||
job_kind,
|
||||
source_entity_id,
|
||||
request_label,
|
||||
price_mud_points,
|
||||
request_payload_json,
|
||||
job_id,
|
||||
dedupe_key,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn enqueue_external_api_editor_generation_job<T>(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
job_kind: &str,
|
||||
source_entity_id: impl Into<String>,
|
||||
request_label: impl Into<String>,
|
||||
price_mud_points: u64,
|
||||
payload: &T,
|
||||
idempotency_key: &str,
|
||||
) -> Result<ExternalGenerationJobRecord, AppError>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
let request_payload_json = serialize_editor_generation_job_payload(payload)?;
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(owner_user_id.trim().as_bytes());
|
||||
hasher.update(b"\0");
|
||||
hasher.update(job_kind.trim().as_bytes());
|
||||
hasher.update(b"\0");
|
||||
hasher.update(idempotency_key.as_bytes());
|
||||
let dedupe_key = format!(
|
||||
"{EXTERNAL_API_GENERATION_DEDUPE_PREFIX}:{job_kind}:{:x}",
|
||||
hasher.finalize()
|
||||
);
|
||||
let requested_job_id = build_prefixed_uuid_id("task-");
|
||||
let job = enqueue_serialized_editor_generation_job_with_identity(
|
||||
state,
|
||||
owner_user_id,
|
||||
job_kind,
|
||||
source_entity_id,
|
||||
request_label,
|
||||
price_mud_points,
|
||||
request_payload_json.clone(),
|
||||
requested_job_id.clone(),
|
||||
dedupe_key,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if job.job_kind != job_kind
|
||||
|| job.owner_user_id != owner_user_id
|
||||
|| job.request_payload_json != request_payload_json
|
||||
{
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
|
||||
"provider": EDITOR_GENERATION_QUEUE_PROVIDER,
|
||||
"message": "Idempotency-Key 已用于不同的生成请求,请复用原请求参数或更换幂等键。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn enqueue_editor_generation_job_for_caller<T>(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
job_kind: &str,
|
||||
source_entity_id: impl Into<String>,
|
||||
request_label: impl Into<String>,
|
||||
price_mud_points: u64,
|
||||
payload: &T,
|
||||
external_idempotency_key: Option<&str>,
|
||||
) -> Result<ExternalGenerationJobRecord, AppError>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
let source_entity_id = source_entity_id.into();
|
||||
let request_label = request_label.into();
|
||||
match external_idempotency_key {
|
||||
Some(idempotency_key) => {
|
||||
enqueue_external_api_editor_generation_job(
|
||||
state,
|
||||
owner_user_id,
|
||||
job_kind,
|
||||
source_entity_id,
|
||||
request_label,
|
||||
price_mud_points,
|
||||
payload,
|
||||
idempotency_key,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
enqueue_editor_generation_job(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id,
|
||||
job_kind,
|
||||
source_entity_id,
|
||||
request_label,
|
||||
price_mud_points,
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn enqueue_serialized_editor_generation_job_with_identity(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
job_kind: &str,
|
||||
source_entity_id: impl Into<String>,
|
||||
request_label: impl Into<String>,
|
||||
price_mud_points: u64,
|
||||
request_payload_json: String,
|
||||
job_id: String,
|
||||
dedupe_key: String,
|
||||
) -> Result<ExternalGenerationJobRecord, AppError> {
|
||||
let now_micros = current_utc_micros();
|
||||
state
|
||||
.spacetime_client()
|
||||
@@ -164,12 +292,21 @@ fn is_inline_media_reference(value: &str) -> bool {
|
||||
pub(crate) fn editor_generation_queue_state(
|
||||
job: ExternalGenerationJobRecord,
|
||||
) -> ExternalGenerationJobStatusRecord {
|
||||
let (status, phase_detail, progress) = match job.status.as_str() {
|
||||
"completed" => (ExternalGenerationJobStatus::Completed, "生成已完成。", 100),
|
||||
"running" if job.phase.as_deref() == Some("processing") => {
|
||||
(ExternalGenerationJobStatus::Running, "正在处理。", 70)
|
||||
}
|
||||
"running" => (ExternalGenerationJobStatus::Running, "正在生成。", 35),
|
||||
"failed" | "cancelled" => (ExternalGenerationJobStatus::Failed, "生成失败。", 0),
|
||||
_ => (ExternalGenerationJobStatus::Queued, "排队中。", 8),
|
||||
};
|
||||
ExternalGenerationJobStatusRecord {
|
||||
operation_id: job.job_id,
|
||||
status: ExternalGenerationJobStatus::Queued,
|
||||
status,
|
||||
phase_label: job.request_label,
|
||||
phase_detail: "排队中。".to_string(),
|
||||
progress: 8,
|
||||
phase_detail: phase_detail.to_string(),
|
||||
progress,
|
||||
error: job.last_error_message,
|
||||
updated_at_micros: job.updated_at_micros,
|
||||
}
|
||||
@@ -194,6 +331,38 @@ fn current_utc_micros() -> i64 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn queue_job_fixture(status: &str, phase: Option<&str>) -> ExternalGenerationJobRecord {
|
||||
ExternalGenerationJobRecord {
|
||||
job_id: "task-queue-test".to_string(),
|
||||
dedupe_key: "editor-canvas:test:task-queue-test".to_string(),
|
||||
job_kind: EDITOR_IMAGE_GENERATION_JOB_KIND.to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
source_module: EDITOR_GENERATION_QUEUE_SOURCE_MODULE.to_string(),
|
||||
source_entity_id: "project-1".to_string(),
|
||||
request_label: "图片画布生成图片".to_string(),
|
||||
request_payload_json: "{}".to_string(),
|
||||
status: status.to_string(),
|
||||
attempt: 0,
|
||||
max_attempts: 1,
|
||||
last_error_message: None,
|
||||
worker_id: None,
|
||||
lease_expires_at: None,
|
||||
available_at: "2026-07-31T00:00:00Z".to_string(),
|
||||
result_payload_json: None,
|
||||
created_at: "2026-07-31T00:00:00Z".to_string(),
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
updated_at: "2026-07-31T00:00:00Z".to_string(),
|
||||
updated_at_micros: 1_785_456_000_000_000,
|
||||
lease_token: None,
|
||||
price_mud_points: 2,
|
||||
refund_ledger_id: None,
|
||||
notification_acknowledged_at: None,
|
||||
notification_acknowledged_at_micros: None,
|
||||
phase: phase.map(ToOwned::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_payload_accepts_persistable_media_references() {
|
||||
let payload = json!({
|
||||
@@ -253,4 +422,72 @@ mod tests {
|
||||
assert_eq!(error.status_code().as_u16(), 413);
|
||||
assert!(error.body_text().contains("超过持久化上限"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_state_maps_idempotent_replays_to_the_persisted_status() {
|
||||
let cases = [
|
||||
(
|
||||
"pending",
|
||||
None,
|
||||
ExternalGenerationJobStatus::Queued,
|
||||
"排队中。",
|
||||
8,
|
||||
),
|
||||
(
|
||||
"running",
|
||||
Some("generating"),
|
||||
ExternalGenerationJobStatus::Running,
|
||||
"正在生成。",
|
||||
35,
|
||||
),
|
||||
(
|
||||
"running",
|
||||
Some("processing"),
|
||||
ExternalGenerationJobStatus::Running,
|
||||
"正在处理。",
|
||||
70,
|
||||
),
|
||||
(
|
||||
"completed",
|
||||
None,
|
||||
ExternalGenerationJobStatus::Completed,
|
||||
"生成已完成。",
|
||||
100,
|
||||
),
|
||||
(
|
||||
"failed",
|
||||
None,
|
||||
ExternalGenerationJobStatus::Failed,
|
||||
"生成失败。",
|
||||
0,
|
||||
),
|
||||
];
|
||||
|
||||
for (persisted_status, phase, expected_status, expected_detail, expected_progress) in cases
|
||||
{
|
||||
let state = editor_generation_queue_state(queue_job_fixture(persisted_status, phase));
|
||||
|
||||
assert_eq!(state.operation_id, "task-queue-test");
|
||||
assert_eq!(state.status, expected_status, "status={persisted_status}");
|
||||
assert_eq!(
|
||||
state.phase_detail, expected_detail,
|
||||
"status={persisted_status}"
|
||||
);
|
||||
assert_eq!(
|
||||
state.progress, expected_progress,
|
||||
"status={persisted_status}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_state_keeps_failed_error_for_idempotent_replay() {
|
||||
let mut job = queue_job_fixture("failed", None);
|
||||
job.last_error_message = Some("生成失败摘要".to_string());
|
||||
|
||||
let state = editor_generation_queue_state(job);
|
||||
|
||||
assert_eq!(state.status, ExternalGenerationJobStatus::Failed);
|
||||
assert_eq!(state.error.as_deref(), Some("生成失败摘要"));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,13 @@
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::{HeaderMap, StatusCode, header::AUTHORIZATION},
|
||||
http::{
|
||||
HeaderMap, HeaderValue, StatusCode,
|
||||
header::{AUTHORIZATION, WWW_AUTHENTICATE},
|
||||
},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use serde_json::json;
|
||||
use spacetime_client::ExternalApiKeyAuthenticateRecordInput;
|
||||
use tracing::warn;
|
||||
|
||||
@@ -74,6 +78,71 @@ pub async fn require_external_api_key(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn require_external_mcp_api_key(
|
||||
State(state): State<AppState>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let request_context = request.extensions().get::<RequestContext>().cloned();
|
||||
match require_external_api_key(State(state), request, next).await {
|
||||
Ok(response) => Ok(response),
|
||||
Err(error) if error.status_code() == StatusCode::UNAUTHORIZED => {
|
||||
Ok(map_external_mcp_authentication_error(error)
|
||||
.into_response_with_context(request_context.as_ref()))
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_external_mcp_authentication_error(error: AppError) -> AppError {
|
||||
debug_assert_eq!(error.status_code(), StatusCode::UNAUTHORIZED);
|
||||
external_mcp_authentication_guide_error()
|
||||
}
|
||||
|
||||
fn external_mcp_authentication_guide_error() -> AppError {
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||
.with_message("连接陶泥儿托管 MCP 需要开发者 API Key")
|
||||
.with_details(json!({
|
||||
"guide": {
|
||||
"reason": "MCP_AUTHENTICATION_REQUIRED",
|
||||
"action": "CONFIGURE_BEARER_API_KEY",
|
||||
"authentication": {
|
||||
"scheme": "Bearer",
|
||||
"header": "Authorization",
|
||||
"valueFormat": "Bearer <tnr_sk_...>"
|
||||
},
|
||||
"keyManagement": {
|
||||
"navigationLabel": "开发者 API Key",
|
||||
"rawKeyShownOnce": true
|
||||
},
|
||||
"retry": {
|
||||
"method": "POST",
|
||||
"path": "/api/external/v1/mcp",
|
||||
"rpcMethod": "initialize"
|
||||
},
|
||||
"steps": [
|
||||
"登录陶泥儿,在「开发者 API Key」中创建密钥;原始密钥只显示一次",
|
||||
"把密钥配置为 MCP 连接的 Bearer token;不要粘贴到聊天或写入仓库",
|
||||
"使用相同 MCP URL 重新发送 initialize"
|
||||
],
|
||||
"credentialSafety": {
|
||||
"rawKeyShownOnce": true,
|
||||
"neverPasteIntoChat": true,
|
||||
"neverStoreInRepository": true
|
||||
},
|
||||
"publicDiscovery": {
|
||||
"manifest": "/api/external/v1/agent-integration.json",
|
||||
"skill": "/api/external/v1/skill/SKILL.md",
|
||||
"openapi": "/api/external/v1/openapi.json"
|
||||
}
|
||||
}
|
||||
}))
|
||||
.with_header(
|
||||
WWW_AUTHENTICATE.as_str(),
|
||||
HeaderValue::from_static("Bearer realm=\"genarrative-external-editor\""),
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_external_api_bearer(headers: &HeaderMap) -> Result<String, AppError> {
|
||||
let authorization = headers
|
||||
.get(AUTHORIZATION)
|
||||
@@ -89,3 +158,32 @@ fn extract_external_api_bearer(headers: &HeaderMap) -> Result<String, AppError>
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| AppError::from_status(StatusCode::UNAUTHORIZED))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mcp_authentication_guide_replaces_sensitive_key_diagnostics() {
|
||||
let error = AppError::from_status(StatusCode::UNAUTHORIZED).with_details(json!({
|
||||
"provider": "external-api-key",
|
||||
"message": "SENSITIVE_KEY_LURE 不存在或已失效"
|
||||
}));
|
||||
|
||||
let mapped = map_external_mcp_authentication_error(error);
|
||||
let serialized = serde_json::to_string(
|
||||
mapped
|
||||
.details()
|
||||
.expect("mapped authentication error should contain guide details"),
|
||||
)
|
||||
.expect("guide should serialize");
|
||||
assert_eq!(mapped.status_code(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(mapped.message(), "连接陶泥儿托管 MCP 需要开发者 API Key");
|
||||
assert!(serialized.contains("MCP_AUTHENTICATION_REQUIRED"));
|
||||
assert!(serialized.contains("CONFIGURE_BEARER_API_KEY"));
|
||||
assert!(!serialized.contains("SENSITIVE_KEY_LURE"));
|
||||
assert!(!serialized.contains("provider"));
|
||||
assert!(!serialized.contains("不存在"));
|
||||
assert!(!serialized.contains("已失效"));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -217,7 +217,7 @@ fn user_visible_external_generation_error(job_kind: &str, error: Option<String>)
|
||||
error
|
||||
}
|
||||
|
||||
fn map_external_generation_job_status_detail(
|
||||
pub(crate) fn map_external_generation_job_status_detail(
|
||||
job: ExternalGenerationJobSummaryRecord,
|
||||
) -> ExternalGenerationJobStatusDetailRecord {
|
||||
let warning = job.warning_message.clone();
|
||||
|
||||
@@ -1213,6 +1213,14 @@ fn editor_generation_result_payload_json(
|
||||
compact_editor_generation_result(response.clone()),
|
||||
);
|
||||
}
|
||||
if is_external_api_generation_job(job)
|
||||
&& let Some(object) = payload.as_object_mut()
|
||||
{
|
||||
object.insert(
|
||||
"result".to_string(),
|
||||
compact_external_api_generation_result(response.clone()),
|
||||
);
|
||||
}
|
||||
if let Some(warning) = extract_editor_generation_warning(response)
|
||||
&& let Some(object) = payload.as_object_mut()
|
||||
{
|
||||
@@ -1221,6 +1229,12 @@ fn editor_generation_result_payload_json(
|
||||
payload.to_string()
|
||||
}
|
||||
|
||||
fn is_external_api_generation_job(job: &ExternalGenerationJobRecord) -> bool {
|
||||
job.dedupe_key
|
||||
.trim()
|
||||
.starts_with("external-api-generation:")
|
||||
}
|
||||
|
||||
fn is_editor_agent_generation_job(job: &ExternalGenerationJobRecord) -> bool {
|
||||
serde_json::from_str::<Value>(job.request_payload_json.as_str())
|
||||
.ok()
|
||||
@@ -1289,6 +1303,177 @@ fn compact_editor_generation_result(mut result: Value) -> Value {
|
||||
result
|
||||
}
|
||||
|
||||
fn compact_external_api_generation_result(result: Value) -> Value {
|
||||
let mut result = result.get("data").cloned().unwrap_or(result);
|
||||
let Some(object) = result.as_object_mut() else {
|
||||
return Value::Null;
|
||||
};
|
||||
object.retain(|key, _| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"ok" | "imageSrc"
|
||||
| "videoSrc"
|
||||
| "audioSrc"
|
||||
| "previewVideoPath"
|
||||
| "thumbnailSrc"
|
||||
| "objectKey"
|
||||
| "assetObjectId"
|
||||
| "width"
|
||||
| "height"
|
||||
| "sourceType"
|
||||
| "model"
|
||||
| "taskId"
|
||||
| "durationSeconds"
|
||||
| "resolution"
|
||||
| "priceMudPoints"
|
||||
| "audioKind"
|
||||
| "spritesheetImageSrc"
|
||||
| "spritesheetWidth"
|
||||
| "spritesheetHeight"
|
||||
| "iconImageSrcs"
|
||||
| "frames"
|
||||
| "frameCount"
|
||||
| "frameWidth"
|
||||
| "frameHeight"
|
||||
| "fps"
|
||||
| "resource"
|
||||
| "asset"
|
||||
| "spritesheetResource"
|
||||
| "spritesheetAsset"
|
||||
| "warning"
|
||||
| "sliceWarning"
|
||||
)
|
||||
});
|
||||
for field in ["resource", "spritesheetResource"] {
|
||||
if let Some(resource) = object.get_mut(field).and_then(Value::as_object_mut) {
|
||||
compact_external_generation_resource(resource);
|
||||
}
|
||||
}
|
||||
for field in ["asset", "spritesheetAsset"] {
|
||||
if let Some(asset) = object.get_mut(field).and_then(Value::as_object_mut) {
|
||||
compact_external_generation_asset(asset);
|
||||
}
|
||||
}
|
||||
if let Some(icons) = object
|
||||
.get_mut("iconImageSrcs")
|
||||
.and_then(Value::as_array_mut)
|
||||
{
|
||||
for icon in icons {
|
||||
let Some(icon) = icon.as_object_mut() else {
|
||||
continue;
|
||||
};
|
||||
icon.retain(|key, _| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"name" | "imageSrc" | "objectKey" | "width" | "height" | "resource" | "asset"
|
||||
)
|
||||
});
|
||||
if let Some(resource) = icon.get_mut("resource").and_then(Value::as_object_mut) {
|
||||
compact_external_generation_resource(resource);
|
||||
}
|
||||
if let Some(asset) = icon.get_mut("asset").and_then(Value::as_object_mut) {
|
||||
compact_external_generation_asset(asset);
|
||||
}
|
||||
remove_unstable_external_generation_media_fields(icon);
|
||||
}
|
||||
}
|
||||
if let Some(frames) = object.get_mut("frames").and_then(Value::as_array_mut) {
|
||||
for frame in frames {
|
||||
let Some(frame) = frame.as_object_mut() else {
|
||||
continue;
|
||||
};
|
||||
frame.retain(|key, _| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"frameIndex" | "imageSrc" | "objectKey" | "width" | "height"
|
||||
)
|
||||
});
|
||||
remove_unstable_external_generation_media_fields(frame);
|
||||
}
|
||||
}
|
||||
for field in ["warning", "sliceWarning"] {
|
||||
if let Some(warning) = object.get_mut(field).and_then(Value::as_object_mut)
|
||||
&& let Some(reason) = warning.get_mut("reason")
|
||||
&& let Some(value) = reason.as_str()
|
||||
{
|
||||
*reason = Value::String(normalize_editor_generation_warning_reason(value));
|
||||
}
|
||||
}
|
||||
remove_unstable_external_generation_media_fields(object);
|
||||
result
|
||||
}
|
||||
|
||||
fn compact_external_generation_resource(resource: &mut serde_json::Map<String, Value>) {
|
||||
resource.retain(|key, _| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"resourceId"
|
||||
| "projectId"
|
||||
| "objectKey"
|
||||
| "assetObjectId"
|
||||
| "imageSrc"
|
||||
| "width"
|
||||
| "height"
|
||||
| "sourceType"
|
||||
| "assetKind"
|
||||
| "taskId"
|
||||
)
|
||||
});
|
||||
remove_unstable_external_generation_media_fields(resource);
|
||||
}
|
||||
|
||||
fn compact_external_generation_asset(asset: &mut serde_json::Map<String, Value>) {
|
||||
asset.retain(|key, _| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"assetId"
|
||||
| "folderId"
|
||||
| "objectKey"
|
||||
| "assetObjectId"
|
||||
| "imageSrc"
|
||||
| "thumbnailSrc"
|
||||
| "width"
|
||||
| "height"
|
||||
| "sourceType"
|
||||
| "assetKind"
|
||||
| "taskId"
|
||||
)
|
||||
});
|
||||
remove_unstable_external_generation_media_fields(asset);
|
||||
}
|
||||
|
||||
fn remove_unstable_external_generation_media_fields(object: &mut serde_json::Map<String, Value>) {
|
||||
object.retain(|key, value| {
|
||||
if !matches!(
|
||||
key.as_str(),
|
||||
"imageSrc"
|
||||
| "videoSrc"
|
||||
| "audioSrc"
|
||||
| "previewVideoPath"
|
||||
| "thumbnailSrc"
|
||||
| "spritesheetImageSrc"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
value
|
||||
.as_str()
|
||||
.is_some_and(is_stable_external_generation_media_reference)
|
||||
});
|
||||
}
|
||||
|
||||
fn is_stable_external_generation_media_reference(value: &str) -> bool {
|
||||
let value = value.trim();
|
||||
!value.is_empty()
|
||||
&& value.starts_with('/')
|
||||
&& !value.starts_with("//")
|
||||
&& !value.contains('?')
|
||||
&& !value.contains('#')
|
||||
&& !value.to_ascii_lowercase().starts_with("data:")
|
||||
&& !value.to_ascii_lowercase().starts_with("blob:")
|
||||
&& !value.to_ascii_lowercase().starts_with("http://")
|
||||
&& !value.to_ascii_lowercase().starts_with("https://")
|
||||
}
|
||||
|
||||
fn is_editor_internal_processing_model(model: &str) -> bool {
|
||||
matches!(
|
||||
model.trim().to_ascii_lowercase().as_str(),
|
||||
@@ -1348,7 +1533,13 @@ fn extract_editor_generation_warning(response: &Value) -> Option<Value> {
|
||||
|
||||
fn normalize_editor_generation_warning_reason(reason: &str) -> String {
|
||||
let normalized = reason.to_ascii_lowercase();
|
||||
if normalized.contains("data:") || normalized.contains("blob:") {
|
||||
if normalized.contains("data:")
|
||||
|| normalized.contains("blob:")
|
||||
|| normalized.contains("http://")
|
||||
|| normalized.contains("https://")
|
||||
|| normalized.contains("x-amz-")
|
||||
|| normalized.contains("signature=")
|
||||
{
|
||||
return EDITOR_GENERATION_WARNING_REDACTED_MESSAGE.to_string();
|
||||
}
|
||||
let mut chars = reason.chars();
|
||||
@@ -2051,6 +2242,135 @@ mod tests {
|
||||
assert!(!payload.to_string().contains("data:image"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_api_result_keeps_stable_artifacts_and_removes_unstable_media() {
|
||||
let mut job = external_generation_job_record_fixture(Some("lease-1"));
|
||||
job.dedupe_key = "external-api-generation:editor_image_generation:fingerprint".to_string();
|
||||
let response = json!({
|
||||
"data": {
|
||||
"imageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST",
|
||||
"videoSrc": "blob:https://example.test/video",
|
||||
"audioSrc": "https://cdn.example.test/audio.mp3?X-Amz-Signature=secret",
|
||||
"previewVideoPath": "https://cdn.example.test/stable-looking-but-external.mp4",
|
||||
"thumbnailSrc": "/api/assets/object/thumbnail.png?expires=1&signature=secret",
|
||||
"objectKey": "users/user-1/generated/main.png",
|
||||
"assetObjectId": "asset-object-main",
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"provider": "internal-provider-must-not-persist",
|
||||
"resource": {
|
||||
"resourceId": "resource-main",
|
||||
"projectId": "project-1",
|
||||
"objectKey": "users/user-1/generated/main.png",
|
||||
"assetObjectId": "asset-object-main",
|
||||
"imageSrc": "https://cdn.example.test/main.png?signature=secret",
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"prompt": "不应复制完整资源元数据"
|
||||
},
|
||||
"asset": {
|
||||
"assetId": "asset-main",
|
||||
"folderId": "folder-1",
|
||||
"objectKey": "users/user-1/generated/main.png",
|
||||
"assetObjectId": "asset-object-main",
|
||||
"imageSrc": "/api/assets/object/main.png",
|
||||
"thumbnailSrc": "https://cdn.example.test/thumb.png?signature=secret",
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"generationInputs": {"private": true}
|
||||
},
|
||||
"project": {
|
||||
"projectId": "project-1",
|
||||
"canvas": {"layers": ["large-layout-must-not-persist"]}
|
||||
},
|
||||
"warning": {
|
||||
"code": "dimension-restore-fallback",
|
||||
"reason": "已保留 provider 实际输出尺寸。"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"requestId": "worker-envelope-must-not-persist"
|
||||
}
|
||||
});
|
||||
|
||||
let payload: Value =
|
||||
serde_json::from_str(&editor_generation_result_payload_json(&job, &response))
|
||||
.expect("外部生成结果应是合法 JSON");
|
||||
let result = &payload["result"];
|
||||
|
||||
assert!(result.get("project").is_none());
|
||||
assert!(result.get("provider").is_none());
|
||||
for unstable_field in [
|
||||
"imageSrc",
|
||||
"videoSrc",
|
||||
"audioSrc",
|
||||
"previewVideoPath",
|
||||
"thumbnailSrc",
|
||||
] {
|
||||
assert!(
|
||||
result.get(unstable_field).is_none(),
|
||||
"不稳定媒体字段 {unstable_field} 不得持久化"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
result["objectKey"],
|
||||
json!("users/user-1/generated/main.png")
|
||||
);
|
||||
assert_eq!(result["assetObjectId"], json!("asset-object-main"));
|
||||
assert_eq!(result["resource"]["resourceId"], json!("resource-main"));
|
||||
assert_eq!(
|
||||
result["resource"]["objectKey"],
|
||||
json!("users/user-1/generated/main.png")
|
||||
);
|
||||
assert!(result["resource"].get("imageSrc").is_none());
|
||||
assert!(result["resource"].get("prompt").is_none());
|
||||
assert_eq!(result["asset"]["assetId"], json!("asset-main"));
|
||||
assert_eq!(
|
||||
result["asset"]["imageSrc"],
|
||||
json!("/api/assets/object/main.png")
|
||||
);
|
||||
assert!(result["asset"].get("thumbnailSrc").is_none());
|
||||
assert!(result["asset"].get("generationInputs").is_none());
|
||||
assert_eq!(
|
||||
result["warning"],
|
||||
json!({
|
||||
"code": "dimension-restore-fallback",
|
||||
"reason": "已保留 provider 实际输出尺寸。"
|
||||
})
|
||||
);
|
||||
assert_eq!(payload["warning"], result["warning"]);
|
||||
assert!(result.get("prompt").is_none());
|
||||
assert!(result.get("actualPrompt").is_none());
|
||||
let serialized = payload.to_string().to_ascii_lowercase();
|
||||
for forbidden in [
|
||||
"data:",
|
||||
"blob:",
|
||||
"x-amz-signature",
|
||||
"?signature=",
|
||||
"large-layout",
|
||||
] {
|
||||
assert!(
|
||||
!serialized.contains(forbidden),
|
||||
"compact result 不应包含 {forbidden}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_external_job_does_not_publish_query_result() {
|
||||
let job = external_generation_job_record_fixture(Some("lease-1"));
|
||||
let payload: Value = serde_json::from_str(&editor_generation_result_payload_json(
|
||||
&job,
|
||||
&json!({
|
||||
"objectKey": "users/user-1/generated/main.png",
|
||||
"resource": {"resourceId": "resource-main"}
|
||||
}),
|
||||
))
|
||||
.expect("普通编辑器任务结果应为合法 JSON");
|
||||
|
||||
assert!(payload.get("result").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_job_timeout_uses_long_budget_for_image_and_video_jobs() {
|
||||
let config = AppConfig {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
body::Body,
|
||||
http::{
|
||||
HeaderValue, StatusCode,
|
||||
header::{CONTENT_DISPOSITION, CONTENT_TYPE},
|
||||
},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use zip::{ZipWriter, write::SimpleFileOptions};
|
||||
|
||||
use crate::http_error::AppError;
|
||||
|
||||
const SKILL_ROOT: &str = "genarrative-external-editor-api";
|
||||
const SKILL_FILES: [(&str, &str); 7] = [
|
||||
(
|
||||
"SKILL.md",
|
||||
include_str!("../../../../.codex/skills/genarrative-external-editor-api/SKILL.md"),
|
||||
),
|
||||
(
|
||||
"references/capability-routing.md",
|
||||
include_str!(
|
||||
"../../../../.codex/skills/genarrative-external-editor-api/references/capability-routing.md"
|
||||
),
|
||||
),
|
||||
(
|
||||
"references/api-operations.md",
|
||||
include_str!(
|
||||
"../../../../.codex/skills/genarrative-external-editor-api/references/api-operations.md"
|
||||
),
|
||||
),
|
||||
(
|
||||
"references/authentication-and-safety.md",
|
||||
include_str!(
|
||||
"../../../../.codex/skills/genarrative-external-editor-api/references/authentication-and-safety.md"
|
||||
),
|
||||
),
|
||||
(
|
||||
"references/requests-and-outputs.md",
|
||||
include_str!(
|
||||
"../../../../.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md"
|
||||
),
|
||||
),
|
||||
(
|
||||
"scripts/genarrative_external_api.py",
|
||||
include_str!(
|
||||
"../../../../.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py"
|
||||
),
|
||||
),
|
||||
(
|
||||
"agents/openai.yaml",
|
||||
include_str!(
|
||||
"../../../../.codex/skills/genarrative-external-editor-api/agents/openai.yaml"
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
pub async fn get_external_skill_entry() -> Response {
|
||||
let mut response = Body::from(SKILL_FILES[0].1).into_response();
|
||||
response.headers_mut().insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/markdown; charset=utf-8"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
pub async fn download_external_skill_archive() -> Result<Response, AppError> {
|
||||
let bytes = build_external_skill_archive()?;
|
||||
let mut response = Body::from(bytes).into_response();
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CONTENT_TYPE, HeaderValue::from_static("application/zip"));
|
||||
response.headers_mut().insert(
|
||||
CONTENT_DISPOSITION,
|
||||
HeaderValue::from_static(
|
||||
"attachment; filename=\"genarrative-external-editor-api.skill.zip\"",
|
||||
),
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn get_external_agent_integration_manifest() -> Result<Json<Value>, AppError> {
|
||||
let archive = build_external_skill_archive()?;
|
||||
let sha256 = format!("{:x}", Sha256::digest(&archive));
|
||||
Ok(Json(json!({
|
||||
"name": SKILL_ROOT,
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"mcp": {
|
||||
"transport": "streamable-http",
|
||||
"url": "/api/external/v1/mcp",
|
||||
"authentication": "bearer-api-key",
|
||||
"credentialSetup": {
|
||||
"action": "CONFIGURE_BEARER_API_KEY",
|
||||
"authorizationValueFormat": "Bearer <tnr_sk_...>",
|
||||
"navigationLabel": "开发者 API Key",
|
||||
"guide": "/api/external/v1/skill/SKILL.md"
|
||||
}
|
||||
},
|
||||
"openapi": "/api/external/v1/openapi.json",
|
||||
"skill": {
|
||||
"entry": "/api/external/v1/skill/SKILL.md",
|
||||
"archive": "/api/external/v1/skill.zip",
|
||||
"archiveSha256": sha256,
|
||||
"files": SKILL_FILES.map(|(path, _)| format!("{SKILL_ROOT}/{path}")),
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
fn build_external_skill_archive() -> Result<Vec<u8>, AppError> {
|
||||
let cursor = Cursor::new(Vec::new());
|
||||
let mut archive = ZipWriter::new(cursor);
|
||||
let options = SimpleFileOptions::default().unix_permissions(0o644);
|
||||
for (path, contents) in SKILL_FILES {
|
||||
archive
|
||||
.start_file(format!("{SKILL_ROOT}/{path}"), options)
|
||||
.map_err(skill_archive_error)?;
|
||||
archive
|
||||
.write_all(contents.as_bytes())
|
||||
.map_err(skill_archive_error)?;
|
||||
}
|
||||
archive
|
||||
.finish()
|
||||
.map(Cursor::into_inner)
|
||||
.map_err(skill_archive_error)
|
||||
}
|
||||
|
||||
fn skill_archive_error(error: impl std::fmt::Display) -> AppError {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "external-skill-archive",
|
||||
"message": format!("构建外部 Skill 包失败:{error}"),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn archive_contains_complete_skill_bundle() {
|
||||
let bytes = build_external_skill_archive().expect("skill archive should build");
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).expect("archive should parse");
|
||||
assert_eq!(archive.len(), SKILL_FILES.len());
|
||||
for (path, contents) in SKILL_FILES {
|
||||
let name = format!("{SKILL_ROOT}/{path}");
|
||||
let mut file = archive.by_name(&name).expect("skill file should exist");
|
||||
let mut actual = String::new();
|
||||
std::io::Read::read_to_string(&mut file, &mut actual).expect("skill file should read");
|
||||
assert_eq!(actual, contents);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn integration_manifest_matches_complete_skill_archive() {
|
||||
let bytes = build_external_skill_archive().expect("skill archive should build");
|
||||
let Json(manifest) = get_external_agent_integration_manifest()
|
||||
.await
|
||||
.expect("integration manifest should build");
|
||||
let expected_files = SKILL_FILES
|
||||
.map(|(path, _)| format!("{SKILL_ROOT}/{path}"))
|
||||
.to_vec();
|
||||
assert_eq!(manifest["skill"]["files"], json!(expected_files));
|
||||
assert_eq!(
|
||||
manifest["mcp"]["credentialSetup"],
|
||||
json!({
|
||||
"action": "CONFIGURE_BEARER_API_KEY",
|
||||
"authorizationValueFormat": "Bearer <tnr_sk_...>",
|
||||
"navigationLabel": "开发者 API Key",
|
||||
"guide": "/api/external/v1/skill/SKILL.md"
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
manifest["skill"]["archiveSha256"],
|
||||
json!(format!("{:x}", Sha256::digest(&bytes)))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@ mod external_editor_api;
|
||||
mod external_generation;
|
||||
mod external_generation_worker;
|
||||
mod external_generation_worker_controller;
|
||||
mod external_mcp;
|
||||
mod external_skill_api;
|
||||
mod frontend_runtime_config;
|
||||
mod generated_image_assets;
|
||||
mod health;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use axum::{
|
||||
Router,
|
||||
Extension, Router,
|
||||
extract::DefaultBodyLimit,
|
||||
middleware,
|
||||
routing::{get, patch, post},
|
||||
@@ -7,7 +7,7 @@ use axum::{
|
||||
|
||||
use crate::{
|
||||
editor_project::EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES,
|
||||
external_api_auth::require_external_api_key,
|
||||
external_api_auth::{require_external_api_key, require_external_mcp_api_key},
|
||||
external_assets_api::{
|
||||
confirm_external_asset_object, create_external_direct_upload_ticket,
|
||||
get_external_asset_read_url,
|
||||
@@ -21,17 +21,43 @@ use crate::{
|
||||
generate_external_editor_character_animation, generate_external_editor_icon_spritesheet,
|
||||
generate_external_editor_image, generate_external_editor_sound_effect,
|
||||
generate_external_editor_video, get_external_editor_asset_library,
|
||||
get_external_editor_project, list_external_editor_projects,
|
||||
load_recent_external_editor_project, openapi_json, rename_external_editor_project,
|
||||
save_external_editor_canvas, update_external_editor_asset,
|
||||
get_external_editor_generation_job, get_external_editor_project,
|
||||
list_external_editor_projects, load_recent_external_editor_project, openapi_json,
|
||||
rename_external_editor_project, save_external_editor_canvas, update_external_editor_asset,
|
||||
update_external_editor_asset_folder,
|
||||
},
|
||||
external_mcp,
|
||||
external_skill_api::{
|
||||
download_external_skill_archive, get_external_agent_integration_manifest,
|
||||
get_external_skill_entry,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub fn router(state: AppState) -> Router<AppState> {
|
||||
let mcp_router = Router::new()
|
||||
.nest_service("/api/external/v1/mcp", external_mcp::service())
|
||||
.layer(Extension(state.clone()))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_external_mcp_api_key,
|
||||
));
|
||||
|
||||
Router::new()
|
||||
.merge(mcp_router)
|
||||
.route("/api/external/v1/openapi.json", get(openapi_json))
|
||||
.route(
|
||||
"/api/external/v1/agent-integration.json",
|
||||
get(get_external_agent_integration_manifest),
|
||||
)
|
||||
.route(
|
||||
"/api/external/v1/skill/SKILL.md",
|
||||
get(get_external_skill_entry),
|
||||
)
|
||||
.route(
|
||||
"/api/external/v1/skill.zip",
|
||||
get(download_external_skill_archive),
|
||||
)
|
||||
.route(
|
||||
"/api/external/v1/assets/direct-upload-tickets",
|
||||
post(create_external_direct_upload_ticket).route_layer(middleware::from_fn_with_state(
|
||||
@@ -139,6 +165,13 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_external_api_key,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/external/v1/generations/{operation_id}",
|
||||
get(get_external_editor_generation_job).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_external_api_key,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/external/v1/editor/images/generations",
|
||||
post(generate_external_editor_image).route_layer(middleware::from_fn_with_state(
|
||||
|
||||
@@ -6,7 +6,9 @@ mod publish;
|
||||
mod settings;
|
||||
mod types;
|
||||
|
||||
pub use generation::{generate_editor_background_music, generate_editor_sound_effect};
|
||||
pub(crate) use generation::{
|
||||
generate_editor_background_music_for_owner, generate_editor_sound_effect_for_owner,
|
||||
enqueue_editor_background_music_generation_for_owner,
|
||||
enqueue_editor_sound_effect_generation_for_owner, generate_editor_background_music_for_owner,
|
||||
generate_editor_sound_effect_for_owner,
|
||||
};
|
||||
pub use generation::{generate_editor_background_music, generate_editor_sound_effect};
|
||||
|
||||
@@ -9,6 +9,7 @@ use platform_oss::LegacyAssetPrefix;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use shared_contracts::assets;
|
||||
use spacetime_client::ExternalGenerationJobRecord;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
@@ -17,7 +18,7 @@ use crate::{
|
||||
editor_generation_queue::{
|
||||
EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND,
|
||||
EditorGenerationQueuedResponse, editor_generation_queue_state,
|
||||
editor_generation_source_entity_id, enqueue_editor_generation_job,
|
||||
editor_generation_source_entity_id, enqueue_editor_generation_job_for_caller,
|
||||
},
|
||||
editor_project::{
|
||||
EditorCanvasGeneratedLayerInput, PersistEditorGeneratedAssetRequest,
|
||||
@@ -142,33 +143,14 @@ pub async fn generate_editor_sound_effect(
|
||||
let Json(payload) = parse_json_payload(&request_context, payload)?;
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
if !state.config.external_generation_mode.is_inline() {
|
||||
let pricing = state.editor_generation_pricing().await.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_details(json!({
|
||||
"provider": "editor-generation-pricing",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
.into_response_with_context(Some(&request_context))
|
||||
})?;
|
||||
let normalized =
|
||||
normalize_editor_sound_effect_request_with_pricing(payload.clone(), &pricing)
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
let source_entity_id = editor_generation_source_entity_id(
|
||||
payload.project_id.as_deref(),
|
||||
"editor-sound-effect",
|
||||
);
|
||||
let queue_job = enqueue_editor_generation_job(
|
||||
let queue_job = enqueue_editor_sound_effect_generation_for_owner(
|
||||
&state,
|
||||
&request_context,
|
||||
owner_user_id.as_str(),
|
||||
EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND,
|
||||
source_entity_id,
|
||||
"图片画布生成音效",
|
||||
u64::from(normalized.price_mud_points),
|
||||
&payload,
|
||||
payload,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
.await?;
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorGenerationQueuedResponse {
|
||||
@@ -180,6 +162,40 @@ pub async fn generate_editor_sound_effect(
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn enqueue_editor_sound_effect_generation_for_owner(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
payload: assets::EditorSoundEffectGenerateRequest,
|
||||
external_idempotency_key: Option<&str>,
|
||||
) -> Result<ExternalGenerationJobRecord, Response> {
|
||||
let pricing = state.editor_generation_pricing().await.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_details(json!({
|
||||
"provider": "editor-generation-pricing",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
.into_response_with_context(Some(request_context))
|
||||
})?;
|
||||
let normalized = normalize_editor_sound_effect_request_with_pricing(payload.clone(), &pricing)
|
||||
.map_err(|error| error.into_response_with_context(Some(request_context)))?;
|
||||
let source_entity_id =
|
||||
editor_generation_source_entity_id(payload.project_id.as_deref(), "editor-sound-effect");
|
||||
enqueue_editor_generation_job_for_caller(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id,
|
||||
EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND,
|
||||
source_entity_id,
|
||||
"图片画布生成音效",
|
||||
u64::from(normalized.price_mud_points),
|
||||
&payload,
|
||||
external_idempotency_key,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.into_response_with_context(Some(request_context)))
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_editor_sound_effect_for_owner(
|
||||
state: AppState,
|
||||
request_context: RequestContext,
|
||||
@@ -358,33 +374,14 @@ pub async fn generate_editor_background_music(
|
||||
let Json(payload) = parse_json_payload(&request_context, payload)?;
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
if !state.config.external_generation_mode.is_inline() {
|
||||
let pricing = state.editor_generation_pricing().await.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_details(json!({
|
||||
"provider": "editor-generation-pricing",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
.into_response_with_context(Some(&request_context))
|
||||
})?;
|
||||
let normalized =
|
||||
normalize_editor_background_music_request_with_pricing(payload.clone(), &pricing)
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
let source_entity_id = editor_generation_source_entity_id(
|
||||
payload.project_id.as_deref(),
|
||||
"editor-background-music",
|
||||
);
|
||||
let queue_job = enqueue_editor_generation_job(
|
||||
let queue_job = enqueue_editor_background_music_generation_for_owner(
|
||||
&state,
|
||||
&request_context,
|
||||
owner_user_id.as_str(),
|
||||
EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND,
|
||||
source_entity_id,
|
||||
"图片画布生成背景音乐",
|
||||
u64::from(normalized.price_mud_points),
|
||||
&payload,
|
||||
payload,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
.await?;
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorGenerationQueuedResponse {
|
||||
@@ -401,6 +398,43 @@ pub async fn generate_editor_background_music(
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn enqueue_editor_background_music_generation_for_owner(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
payload: assets::EditorBackgroundMusicGenerateRequest,
|
||||
external_idempotency_key: Option<&str>,
|
||||
) -> Result<ExternalGenerationJobRecord, Response> {
|
||||
let pricing = state.editor_generation_pricing().await.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_details(json!({
|
||||
"provider": "editor-generation-pricing",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
.into_response_with_context(Some(request_context))
|
||||
})?;
|
||||
let normalized =
|
||||
normalize_editor_background_music_request_with_pricing(payload.clone(), &pricing)
|
||||
.map_err(|error| error.into_response_with_context(Some(request_context)))?;
|
||||
let source_entity_id = editor_generation_source_entity_id(
|
||||
payload.project_id.as_deref(),
|
||||
"editor-background-music",
|
||||
);
|
||||
enqueue_editor_generation_job_for_caller(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id,
|
||||
EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND,
|
||||
source_entity_id,
|
||||
"图片画布生成背景音乐",
|
||||
u64::from(normalized.price_mud_points),
|
||||
&payload,
|
||||
external_idempotency_key,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.into_response_with_context(Some(request_context)))
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_editor_background_music_for_owner(
|
||||
state: AppState,
|
||||
request_context: RequestContext,
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `OpenAiChat` | `tools[].type=function`,函数内为 `name` / `description` / `parameters` / `strict` | `"auto"` / `"required"` | `choices[0].message.tool_calls` | `delta.tool_calls[].index`;首片提供 id/name,后续拼接 arguments |
|
||||
| `OpenAiResponses` | `tools[].type=function`,函数内为 `name` / `description` / `parameters` / `strict` | `"auto"` / `"required"` | `output[].type=function_call` | `output_index`;`output_item.added` 提供身份,`function_call_arguments.delta` 拼接,`.done` 覆盖完整参数 |
|
||||
| `Anthropic` | 顶层 `tools[]` 为 `name` / `description` / `input_schema`,没有 `function` 包装层和 `strict` | `{ "type": "auto" }` / `{ "type": "any" }`;`Required` 映射为 `any` | `content[].type=tool_use`,`input` 序列化为 `arguments` | content block `index`;`content_block_start` 提供身份,`input_json_delta` 拼接参数 |
|
||||
| `Anthropic` | 顶层 `tools[]` 为 `name` / `description` / `input_schema`,没有 `function` 包装层;只有 endpoint/model 配置显式声明支持且 schema / 请求复杂度满足 Anthropic 当前边界时才发送 `strict: true`。strict 传输 schema 会剥离不支持的约束,调用方原 schema 保持不变;最后一项带 ephemeral cache breakpoint | `{ "type": "auto" }` / `{ "type": "any" }`;`Required` 映射为 `any` | `content[].type=tool_use`,`input` 序列化为 `arguments` | content block `index`;`content_block_start` 提供身份,`input_json_delta` 拼接参数;`message_start/message_delta` 合并 cache/input/output usage |
|
||||
|
||||
Responses 如果只发送 `response.completed` 或 `response.incomplete`,解析器会从其中的 `response.output[]` 恢复 `function_call`;恢复时使用 output 数组下标作为 slot。`response.incomplete` 表示上游没有完成本轮生成:其中的工具调用即使参数是完整 JSON 也返回 `Deserialize`,纯正文则保留为可用的降级结果。三种协议的并行工具调用只在平台层做 slot 聚合,不代表工具会在平台层并发执行。
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,10 @@
|
||||
//! 仓库根目录没有 Cargo.toml,必须显式指定 workspace manifest:
|
||||
//!
|
||||
//! ```powershell
|
||||
//! $env:PLATFORM_LLM_LIVE_BASE_URL = 'https://api.minimaxi.com/anthropic'
|
||||
//! $env:PLATFORM_LLM_LIVE_BASE_URL = 'https://api.anthropic.com'
|
||||
//! $env:PLATFORM_LLM_LIVE_API_KEY = '...'
|
||||
//! $env:PLATFORM_LLM_LIVE_MODEL = 'MiniMax-M3'
|
||||
//! $env:PLATFORM_LLM_LIVE_API_KIND = 'anthropic' # 或 openai_chat / openai_responses
|
||||
//! $env:PLATFORM_LLM_LIVE_MODEL = '<当前支持 strict tool use 的 Claude 模型>'
|
||||
//! $env:PLATFORM_LLM_LIVE_API_KIND = 'anthropic'
|
||||
//! cargo test -p platform-llm --manifest-path server-rs/Cargo.toml --test live_stream_tool_calls -- --ignored --nocapture
|
||||
//! ```
|
||||
//!
|
||||
@@ -107,7 +107,8 @@ async fn live_stream_run_returns_native_tool_calls() {
|
||||
0,
|
||||
1_000,
|
||||
)
|
||||
.expect("live config should be valid");
|
||||
.expect("live config should be valid")
|
||||
.with_anthropic_strict_tool_support(api_kind == LlmApiKind::Anthropic);
|
||||
let client = LlmClient::new(config).expect("live client should be created");
|
||||
|
||||
let request = LlmRunRequest::new(vec![
|
||||
@@ -116,15 +117,27 @@ async fn live_stream_run_returns_native_tool_calls() {
|
||||
])
|
||||
.with_api_kind(api_kind)
|
||||
.with_max_output_tokens(512)
|
||||
.with_function_tools(vec![LlmFunctionTool::new(
|
||||
"get_weather",
|
||||
"查询指定城市的当前天气。",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": { "city": { "type": "string" } },
|
||||
"required": ["city"]
|
||||
}),
|
||||
)])
|
||||
.with_function_tools(vec![
|
||||
LlmFunctionTool::new(
|
||||
"get_weather",
|
||||
"查询指定城市的当前天气。",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"$defs": {
|
||||
"WeatherRequest": {
|
||||
"type": "object",
|
||||
"properties": { "city": { "type": "string" } },
|
||||
"required": ["city"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"properties": { "request": { "$ref": "#/$defs/WeatherRequest" } },
|
||||
"required": ["request"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
)
|
||||
.with_strict(true),
|
||||
])
|
||||
.with_tool_choice(LlmToolChoice::Required);
|
||||
|
||||
let mut streamed_chars = 0usize;
|
||||
@@ -157,7 +170,10 @@ async fn live_stream_run_returns_native_tool_calls() {
|
||||
let arguments: serde_json::Value =
|
||||
serde_json::from_str(&call.arguments).expect("参数必须是完整 JSON");
|
||||
assert!(
|
||||
arguments.get("city").is_some(),
|
||||
"参数应包含 city,实际为 {arguments}"
|
||||
arguments
|
||||
.get("request")
|
||||
.and_then(|request| request.get("city"))
|
||||
.is_some(),
|
||||
"参数应包含 request.city,实际为 {arguments}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
@@ -51,6 +52,37 @@ pub struct ExternalGenerationJobStatusResponse {
|
||||
pub job: ExternalGenerationJobStatusDetailRecord,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalEditorGenerationSubmissionResponse {
|
||||
pub operation_id: String,
|
||||
pub kind: String,
|
||||
pub status: ExternalGenerationJobStatus,
|
||||
pub status_url: String,
|
||||
pub poll_after_ms: u64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalEditorGenerationJobResponse {
|
||||
pub operation_id: String,
|
||||
pub kind: String,
|
||||
pub status: ExternalGenerationJobStatus,
|
||||
pub phase_label: String,
|
||||
pub phase_detail: String,
|
||||
pub progress: u8,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub warning: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub poll_after_ms: Option<u64>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalGenerationTaskRecord {
|
||||
|
||||
Reference in New Issue
Block a user