Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
391 changes: 255 additions & 136 deletions crates/agent-gateway/internal/proto/v2/gateway.pb.go

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions crates/agent-gateway/proto/v2/gateway.proto
Original file line number Diff line number Diff line change
Expand Up @@ -1336,6 +1336,18 @@ message ErrorResponse {
string message = 2;
}

message ProviderCustomHeader {
string name = 1;
string value = 2;
}

// 包一层 message 只为拿到字段存在性:repeated 无法区分「草稿没带」与「草稿把头
// 清空了」,而这两种情况在已保存供应商分支上的处理相反(前者沿用落库的头,
// 后者按空集发请求)。与 is_full_url 的 optional 同一套三态语义。
message ProviderCustomHeaders {
repeated ProviderCustomHeader headers = 1;
}

message ProviderModelsRequest {
string provider_type = 1;
string base_url = 2;
Expand All @@ -1347,6 +1359,9 @@ message ProviderModelsRequest {
string provider_id = 6;
// 当前草稿是否把 base_url 作为完整聊天端点解释;未提供时沿用已保存配置。
optional bool is_full_url = 7;
// 用户在供应商设置里显式配置的自定义请求头;未提供时沿用已保存配置。
// 鉴权头与 host/content-length 等仍由落地侧的保留头名单兜底,不可被覆盖。
ProviderCustomHeaders custom_headers = 8;
}

message ProviderModelsResponse {
Expand Down
1 change: 1 addition & 0 deletions crates/agent-gateway/web/src/lib/gatewaySocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@ export type GatewayWebSocketClientLike = {
modelsUrl?: string,
providerId?: string,
isFullUrl?: boolean,
customHeaders?: readonly { key: string; value: string }[],
): Promise<unknown>;
providerUsageQuery<T = unknown>(providerId: string, refresh: boolean): Promise<T>;
providerUsageTest<T = unknown>(providerId: string, configJson: string): Promise<T>;
Expand Down
2 changes: 2 additions & 0 deletions crates/agent-gateway/web/src/lib/gatewaySocketRpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1379,6 +1379,7 @@ export class GatewayWebSocketRpcClient extends GatewayWebSocketTransport {
modelsUrl = "",
providerId = "",
isFullUrl?: boolean,
customHeaders?: readonly { key: string; value: string }[],
): Promise<unknown> {
return this.requestWithRecovery("provider.models", {
type,
Expand All @@ -1388,6 +1389,7 @@ export class GatewayWebSocketRpcClient extends GatewayWebSocketTransport {
models_url: modelsUrl,
provider_id: providerId,
is_full_url: isFullUrl,
custom_headers: customHeaders,
});
}

Expand Down
14 changes: 14 additions & 0 deletions crates/agent-gateway/web/src/lib/gatewaySocketV2/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ import {
InstalledAppsListRequestSchema,
ManagedProcessRequestSchema,
MemoryManageRequestSchema,
ProviderCustomHeaderSchema,
ProviderCustomHeadersSchema,
ProviderListRequestSchema,
ProviderModelsRequestSchema,
ProviderUsageRequestSchema,
Expand Down Expand Up @@ -621,6 +623,18 @@ function agentRequestPayload(type: string, body: J): GatewayEnvelope["payload"]
modelsUrl: trimStr(body.models_url),
providerId: trimStr(body.provider_id),
isFullUrl: typeof body.is_full_url === "boolean" ? body.is_full_url : undefined,
// 字段存在性即语义:调用方没带 custom_headers 才回落到落库配置,带了空
// 数组表示草稿把头清空了,桌面端必须按空集发。
customHeaders: Array.isArray(body.custom_headers)
? create(ProviderCustomHeadersSchema, {
headers: body.custom_headers.map((header) =>
create(ProviderCustomHeaderSchema, {
name: trimStr((header as { key?: unknown } | null)?.key),
value: str((header as { value?: unknown } | null)?.value),
}),
),
})
: undefined,
}),
};
case "provider.usage.query":
Expand Down
103 changes: 77 additions & 26 deletions crates/agent-gateway/web/src/lib/proto/gen/proto/v2/gateway_pb.ts

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions crates/agent-gateway/web/src/shims/tauriCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ export async function invoke<T>(command: string, args?: Record<string, unknown>)
String(args?.models_url ?? ""),
String(args?.provider_id ?? ""),
typeof args?.is_full_url === "boolean" ? args.is_full_url : undefined,
Array.isArray(args?.custom_headers) ? args.custom_headers : undefined,
)) as T;
case "settings_reset_ssh_known_host": {
const host = String(args?.host ?? "").trim();
Expand Down
103 changes: 99 additions & 4 deletions crates/agent-gui/src-tauri/src/services/gateway_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -511,16 +511,27 @@ pub async fn handle_provider_models(
) -> Result<proto::ProviderModelsResponse, String> {
let provider_type = request.provider_type.trim().to_string();
let request_api_key = request.api_key.trim().to_string();
// message 字段带存在性:未设置=草稿没带头,沿用落库配置;设置了(哪怕是空
// 列表)=草稿的头就是权威值。
let request_custom_headers = request.custom_headers.as_ref().map(|headers| {
headers
.headers
.iter()
.map(|header| (header.name.clone(), header.value.clone()))
.collect::<Vec<_>>()
});
let config = if request_api_key.is_empty() {
let provider_id = request.provider_id.trim().to_string();
let expected_provider_type = provider_type.clone();
let is_full_url = request.is_full_url;
let custom_headers = request_custom_headers.clone();
tauri::async_runtime::spawn_blocking(move || {
let conn = open_db()?;
resolve_stored_provider_models_config(
&provider_id,
&expected_provider_type,
is_full_url,
custom_headers,
load_providers(&conn)?,
)
})
Expand All @@ -535,6 +546,7 @@ pub async fn handle_provider_models(
models_url: Some(request.models_url.trim().to_string())
.filter(|value| !value.is_empty()),
is_full_url: request.is_full_url.unwrap_or(false),
custom_headers: request_custom_headers.unwrap_or_default(),
}
};
let models_json = crate::services::provider_models::fetch_provider_models(
Expand All @@ -544,6 +556,7 @@ pub async fn handle_provider_models(
config.use_system_proxy,
config.models_url.as_deref(),
config.is_full_url,
&config.custom_headers,
)
.await?;
Ok(proto::ProviderModelsResponse { models_json })
Expand All @@ -557,12 +570,14 @@ struct ProviderModelsRequestConfig {
use_system_proxy: bool,
models_url: Option<String>,
is_full_url: bool,
custom_headers: Vec<(String, String)>,
}

fn resolve_stored_provider_models_config(
provider_id: &str,
expected_provider_type: &str,
is_full_url: Option<bool>,
custom_headers: Option<Vec<(String, String)>>,
providers: Option<Value>,
) -> Result<ProviderModelsRequestConfig, String> {
let provider_id = provider_id.trim();
Expand Down Expand Up @@ -618,6 +633,22 @@ fn resolve_stored_provider_models_config(
.and_then(Value::as_bool)
.unwrap_or(false)
}),
custom_headers: custom_headers.unwrap_or_else(|| {
provider
.get("customHeaders")
.and_then(Value::as_array)
.map(|headers| {
headers
.iter()
.filter_map(|header| {
let key = header.get("key").and_then(Value::as_str)?.trim();
let value = header.get("value").and_then(Value::as_str)?;
(!key.is_empty()).then(|| (key.to_string(), value.to_string()))
})
.collect()
})
.unwrap_or_default()
}),
})
}

Expand Down Expand Up @@ -1915,19 +1946,76 @@ mod tests {
"useSystemProxy": true
}]);
assert_eq!(
resolve_stored_provider_models_config("provider-a", "codex", None, Some(providers))
.expect("stored provider config"),
resolve_stored_provider_models_config(
"provider-a",
"codex",
None,
None,
Some(providers),
)
.expect("stored provider config"),
super::ProviderModelsRequestConfig {
provider_type: "codex".to_string(),
base_url: "https://stored.example.com/v1/responses".to_string(),
api_key: "stored-secret".to_string(),
use_system_proxy: true,
models_url: Some("https://stored.example.com/models".to_string()),
is_full_url: true,
custom_headers: Vec::new(),
}
);
}

#[test]
fn provider_models_custom_headers_fall_back_to_stored_only_when_draft_omits_them() {
let providers = json!([{
"id": "provider-a",
"type": "codex",
"baseUrl": "https://stored.example.com",
"apiKey": "stored-secret",
"customHeaders": [{ "key": "User-Agent", "value": "stored-cli/1.0" }]
}]);

// 草稿没带请求头(proto 的 custom_headers 缺省)→ 沿用落库配置。
let inherited = resolve_stored_provider_models_config(
"provider-a",
"codex",
None,
None,
Some(providers.clone()),
)
.expect("stored provider config");
assert_eq!(
inherited.custom_headers,
vec![("User-Agent".to_string(), "stored-cli/1.0".to_string())]
);

// 草稿把请求头清空了 → 按空集发,绝不回落到落库配置(否则用户删不掉伪装头)。
let cleared = resolve_stored_provider_models_config(
"provider-a",
"codex",
None,
Some(Vec::new()),
Some(providers.clone()),
)
.expect("stored provider config");
assert!(cleared.custom_headers.is_empty());

// 草稿显式给了头 → 覆盖落库配置。
let overridden = resolve_stored_provider_models_config(
"provider-a",
"codex",
None,
Some(vec![("User-Agent".to_string(), "draft-cli/2.0".to_string())]),
Some(providers),
)
.expect("stored provider config");
assert_eq!(
overridden.custom_headers,
vec![("User-Agent".to_string(), "draft-cli/2.0".to_string())]
);
}

#[test]
fn provider_models_applies_webui_full_url_mode_to_stored_endpoint() {
let providers = json!([{
Expand All @@ -1941,6 +2029,7 @@ mod tests {
"provider-a",
"codex",
Some(true),
None,
Some(providers),
)
.expect("stored provider config with draft full URL mode");
Expand All @@ -1958,8 +2047,14 @@ mod tests {
"apiKey": "stored-secret"
}]);
assert_eq!(
resolve_stored_provider_models_config("provider-a", "codex", None, Some(providers))
.expect_err("provider type mismatch"),
resolve_stored_provider_models_config(
"provider-a",
"codex",
None,
None,
Some(providers),
)
.expect_err("provider type mismatch"),
"供应商类型与已保存配置不匹配"
);
}
Expand Down
Loading
Loading