feat(AGC): 模型目录 wire 复用 shared-contracts DTO
- llm_catalog.rs 删掉手写 WireModelCatalog/WireModelSummary 与 map_catalog,直接反序列化 LlmModelsResponse - 旧服务端缺 agentMode/protocol/unavailableModels 时按 Codex + OpenAI Responses + 空桶兼容 - 新增未知 reason 收口为 unknown 且 agentMode/protocol 解析为枚举的单测
This commit is contained in:
@@ -6,40 +6,12 @@ use futures::StreamExt;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use shared_contracts::api::API_RESPONSE_ENVELOPE_HEADER;
|
||||
use shared_contracts::llm::{LlmModelSummary, LlmModelsResponse};
|
||||
use shared_contracts::llm::LlmModelsResponse;
|
||||
use std::time::Duration;
|
||||
|
||||
const API_RESPONSE_ENVELOPE_VERSION: &str = "v1";
|
||||
const MODEL_CATALOG_MAX_BYTES: usize = 1024 * 1024;
|
||||
|
||||
fn default_codex_agent_mode() -> String {
|
||||
"codex".to_string()
|
||||
}
|
||||
|
||||
fn default_model_protocol() -> String {
|
||||
"openai_responses".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WireModelSummary {
|
||||
id: String,
|
||||
display_name: String,
|
||||
#[serde(default = "default_codex_agent_mode")]
|
||||
agent_mode: String,
|
||||
/// 线协议与执行器正交;旧服务端不下发时按 OpenAI Responses 兼容。
|
||||
#[serde(default = "default_model_protocol")]
|
||||
protocol: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WireModelCatalog {
|
||||
default_model_id: String,
|
||||
models: Vec<WireModelSummary>,
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiErrorPayload {
|
||||
code: Option<String>,
|
||||
@@ -49,27 +21,12 @@ struct ApiErrorPayload {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiEnvelope {
|
||||
ok: bool,
|
||||
data: Option<WireModelCatalog>,
|
||||
/// 直接复用 shared-contracts 的目录 DTO:`agentMode` / `protocol` / `unavailableModels`
|
||||
/// 都带 `#[serde(default)]`,旧服务端缺字段时按 Codex + OpenAI Responses + 空桶兼容。
|
||||
data: Option<LlmModelsResponse>,
|
||||
error: Option<ApiErrorPayload>,
|
||||
}
|
||||
|
||||
fn map_catalog(catalog: WireModelCatalog) -> LlmModelsResponse {
|
||||
LlmModelsResponse {
|
||||
default_model_id: catalog.default_model_id,
|
||||
models: catalog
|
||||
.models
|
||||
.into_iter()
|
||||
.map(|model| LlmModelSummary {
|
||||
id: model.id,
|
||||
display_name: model.display_name,
|
||||
agent_mode: model.agent_mode,
|
||||
protocol: model.protocol,
|
||||
})
|
||||
.collect(),
|
||||
revision: catalog.revision,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_catalog_payload(bytes: &[u8]) -> Result<LlmModelsResponse, String> {
|
||||
let value: Value = serde_json::from_slice(bytes)
|
||||
.map_err(|_| "模型列表响应不是有效 JSON,请稍后重试".to_string())?;
|
||||
@@ -98,12 +55,10 @@ fn parse_catalog_payload(bytes: &[u8]) -> Result<LlmModelsResponse, String> {
|
||||
}
|
||||
return envelope
|
||||
.data
|
||||
.map(map_catalog)
|
||||
.ok_or_else(|| "模型列表响应缺少 data,请稍后重试".to_string());
|
||||
}
|
||||
|
||||
serde_json::from_value::<WireModelCatalog>(value)
|
||||
.map(map_catalog)
|
||||
serde_json::from_value::<LlmModelsResponse>(value)
|
||||
.map_err(|_| "模型列表响应格式无效,请稍后重试".to_string())
|
||||
}
|
||||
|
||||
@@ -293,6 +248,48 @@ mod tests {
|
||||
.contains("x-genarrative-response-envelope: v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_maps_unknown_unavailable_reason_to_unknown() {
|
||||
let body = serde_json::json!({
|
||||
"ok": true,
|
||||
"data": {
|
||||
"defaultModelId": "quality",
|
||||
"models": [{
|
||||
"id": "quality",
|
||||
"displayName": "高质量",
|
||||
"agentMode": "codex",
|
||||
"protocol": "openai_responses"
|
||||
}],
|
||||
"unavailableModels": [{
|
||||
"id": "pro",
|
||||
"displayName": "专业版",
|
||||
"agentMode": "cc",
|
||||
"protocol": "anthropic",
|
||||
"reason": "some_future_reason"
|
||||
}],
|
||||
"revision": 8
|
||||
},
|
||||
"error": null
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let catalog = parse_catalog_payload(body.as_bytes()).expect("catalog should parse");
|
||||
assert_eq!(catalog.models.len(), 1);
|
||||
assert_eq!(catalog.unavailable_models.len(), 1);
|
||||
assert_eq!(
|
||||
catalog.unavailable_models[0].reason,
|
||||
shared_contracts::llm::LlmModelUnavailableReason::Unknown
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.unavailable_models[0].agent_mode,
|
||||
shared_contracts::llm::AgcAgentMode::Cc
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.unavailable_models[0].protocol,
|
||||
shared_contracts::llm::AgcModelProtocol::Anthropic
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn official_catalog_fails_closed_on_http_error_without_accepting_error_as_data() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture");
|
||||
|
||||
Reference in New Issue
Block a user