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:
2026-10-03 18:03:58 +08:00
parent f3f89efddd
commit 9437b71aee
@@ -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");