mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
Merge remote-tracking branch 'origin/pr/498'
This commit is contained in:
@@ -9,6 +9,7 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -548,16 +549,69 @@ fn admin_monitoring_trace_response_data(
|
||||
return None;
|
||||
}
|
||||
|
||||
let body = admin_monitoring_trace_response_body(headers, body);
|
||||
Some(json!({
|
||||
"source": source,
|
||||
"status_code": status_code,
|
||||
"headers": headers.cloned().unwrap_or(Value::Null),
|
||||
"body": body.cloned().unwrap_or(Value::Null),
|
||||
"body": body.unwrap_or(Value::Null),
|
||||
"body_ref": body_ref,
|
||||
"body_state": body_state.map(|state| state.as_str()),
|
||||
}))
|
||||
}
|
||||
|
||||
fn admin_monitoring_trace_response_body(
|
||||
headers: Option<&Value>,
|
||||
body: Option<&Value>,
|
||||
) -> Option<Value> {
|
||||
let body = body?;
|
||||
admin_monitoring_decode_connect_json_error_body(headers, body).or_else(|| Some(body.clone()))
|
||||
}
|
||||
|
||||
fn admin_monitoring_decode_connect_json_error_body(
|
||||
headers: Option<&Value>,
|
||||
body: &Value,
|
||||
) -> Option<Value> {
|
||||
if !admin_monitoring_headers_indicate_connect_json(headers) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let body_base64 = match body {
|
||||
Value::String(value) => Some(value.as_str()),
|
||||
Value::Object(object) => object
|
||||
.get("encoding")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("base64"))
|
||||
.then(|| object.get("data").and_then(Value::as_str))
|
||||
.flatten(),
|
||||
_ => None,
|
||||
}?
|
||||
.trim();
|
||||
if body_base64.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let body_bytes = BASE64_STANDARD.decode(body_base64).ok()?;
|
||||
aether_ai_formats::api::extract_provider_private_stream_error_body(None, &body_bytes)
|
||||
}
|
||||
|
||||
fn admin_monitoring_headers_indicate_connect_json(headers: Option<&Value>) -> bool {
|
||||
headers
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|object| {
|
||||
object.iter().find_map(|(key, value)| {
|
||||
key.eq_ignore_ascii_case("content-type")
|
||||
.then(|| value.as_str())
|
||||
.flatten()
|
||||
})
|
||||
})
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| {
|
||||
let value = value.to_ascii_lowercase();
|
||||
value.contains("application/connect+json") || value.contains("+connect+json")
|
||||
})
|
||||
}
|
||||
|
||||
fn merge_admin_monitoring_trace_response(
|
||||
extra_object: &mut serde_json::Map<String, Value>,
|
||||
key: &str,
|
||||
|
||||
@@ -922,6 +922,317 @@ pub fn parse_kiro_usage_response(
|
||||
Some(serde_json::Value::Object(result))
|
||||
}
|
||||
|
||||
pub fn parse_windsurf_user_status_response(
|
||||
value: &serde_json::Value,
|
||||
updated_at_unix_secs: u64,
|
||||
) -> Option<serde_json::Value> {
|
||||
let user_status = value
|
||||
.get("userStatus")
|
||||
.or_else(|| value.get("user_status"))?;
|
||||
let plan_status = user_status
|
||||
.get("planStatus")
|
||||
.or_else(|| user_status.get("plan_status"))?;
|
||||
let plan_info = plan_status
|
||||
.get("planInfo")
|
||||
.or_else(|| plan_status.get("plan_info"));
|
||||
|
||||
let mut result = serde_json::Map::new();
|
||||
result.insert("updated_at".to_string(), json!(updated_at_unix_secs));
|
||||
|
||||
if let Some(plan_name) = plan_info
|
||||
.and_then(|value| {
|
||||
coerce_json_string(value.get("planName").or_else(|| value.get("plan_name")))
|
||||
})
|
||||
.or_else(|| {
|
||||
coerce_json_string(
|
||||
plan_status
|
||||
.get("planName")
|
||||
.or_else(|| plan_status.get("plan_name")),
|
||||
)
|
||||
})
|
||||
{
|
||||
result.insert("plan_name".to_string(), json!(plan_name));
|
||||
}
|
||||
if let Some(email) = coerce_json_string(user_status.get("email")) {
|
||||
result.insert("email".to_string(), json!(email));
|
||||
}
|
||||
if let Some(value) = plan_status
|
||||
.get("dailyQuotaRemainingPercent")
|
||||
.or_else(|| plan_status.get("daily_quota_remaining_percent"))
|
||||
.and_then(coerce_json_f64)
|
||||
{
|
||||
result.insert("daily_remaining_percent".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = plan_status
|
||||
.get("weeklyQuotaRemainingPercent")
|
||||
.or_else(|| plan_status.get("weekly_quota_remaining_percent"))
|
||||
.and_then(coerce_json_f64)
|
||||
{
|
||||
result.insert("weekly_remaining_percent".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = plan_status
|
||||
.get("dailyQuotaResetAtUnix")
|
||||
.or_else(|| plan_status.get("daily_quota_reset_at_unix"))
|
||||
.and_then(coerce_json_u64)
|
||||
{
|
||||
result.insert("daily_reset_at".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = plan_status
|
||||
.get("weeklyQuotaResetAtUnix")
|
||||
.or_else(|| plan_status.get("weekly_quota_reset_at_unix"))
|
||||
.and_then(coerce_json_u64)
|
||||
{
|
||||
result.insert("weekly_reset_at".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = plan_status
|
||||
.get("overageBalanceMicros")
|
||||
.or_else(|| plan_status.get("overage_balance_micros"))
|
||||
.and_then(coerce_json_f64)
|
||||
{
|
||||
result.insert("overage_balance".to_string(), json!(value / 1_000_000.0));
|
||||
}
|
||||
|
||||
let legacy_credit =
|
||||
|value: Option<&serde_json::Value>| value.and_then(coerce_json_f64).map(|n| n / 100.0);
|
||||
if let Some(value) = legacy_credit(
|
||||
plan_status
|
||||
.get("availablePromptCredits")
|
||||
.or_else(|| plan_status.get("available_prompt_credits")),
|
||||
) {
|
||||
result.insert("prompt_remaining".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = legacy_credit(
|
||||
plan_status
|
||||
.get("usedPromptCredits")
|
||||
.or_else(|| plan_status.get("used_prompt_credits")),
|
||||
) {
|
||||
result.insert("prompt_used".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = legacy_credit(plan_info.and_then(|plan_info| {
|
||||
plan_info
|
||||
.get("monthlyPromptCredits")
|
||||
.or_else(|| plan_info.get("monthly_prompt_credits"))
|
||||
})) {
|
||||
result.insert("prompt_limit".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = legacy_credit(
|
||||
plan_status
|
||||
.get("availableFlexCredits")
|
||||
.or_else(|| plan_status.get("available_flex_credits")),
|
||||
) {
|
||||
result.insert("flex_remaining".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = legacy_credit(
|
||||
plan_status
|
||||
.get("usedFlexCredits")
|
||||
.or_else(|| plan_status.get("used_flex_credits")),
|
||||
) {
|
||||
result.insert("flex_used".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = legacy_credit(plan_info.and_then(|plan_info| {
|
||||
plan_info
|
||||
.get("monthlyFlexCreditPurchaseAmount")
|
||||
.or_else(|| plan_info.get("monthly_flex_credit_purchase_amount"))
|
||||
})) {
|
||||
result.insert("flex_limit".to_string(), json!(value));
|
||||
}
|
||||
|
||||
let mut status_sources = vec![value, user_status, plan_status];
|
||||
if let Some(plan_info) = plan_info {
|
||||
status_sources.push(plan_info);
|
||||
}
|
||||
for (target, aliases) in [
|
||||
(
|
||||
"banned",
|
||||
&[
|
||||
"banned",
|
||||
"isBanned",
|
||||
"is_banned",
|
||||
"accountBanned",
|
||||
"account_banned",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"quarantined",
|
||||
&[
|
||||
"quarantined",
|
||||
"isQuarantined",
|
||||
"is_quarantined",
|
||||
"accountQuarantined",
|
||||
"account_quarantined",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"is_forbidden",
|
||||
&[
|
||||
"isForbidden",
|
||||
"is_forbidden",
|
||||
"forbidden",
|
||||
"accountForbidden",
|
||||
"account_forbidden",
|
||||
][..],
|
||||
),
|
||||
] {
|
||||
if let Some(found) = status_sources.iter().find_map(|source| {
|
||||
aliases
|
||||
.iter()
|
||||
.find_map(|alias| source.get(*alias).and_then(coerce_json_bool))
|
||||
}) {
|
||||
result.insert(target.to_string(), json!(found));
|
||||
}
|
||||
}
|
||||
for (target, aliases) in [
|
||||
(
|
||||
"ban_reason",
|
||||
&[
|
||||
"banReason",
|
||||
"ban_reason",
|
||||
"blockedReason",
|
||||
"blocked_reason",
|
||||
"reason",
|
||||
"message",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"quarantine_reason",
|
||||
&["quarantineReason", "quarantine_reason", "reason", "message"][..],
|
||||
),
|
||||
(
|
||||
"forbidden_reason",
|
||||
&["forbiddenReason", "forbidden_reason", "reason", "message"][..],
|
||||
),
|
||||
] {
|
||||
if let Some(found) = status_sources.iter().find_map(|source| {
|
||||
aliases
|
||||
.iter()
|
||||
.find_map(|alias| coerce_json_string(source.get(*alias)))
|
||||
}) {
|
||||
result.insert(target.to_string(), json!(found));
|
||||
}
|
||||
}
|
||||
|
||||
Some(serde_json::Value::Object(result))
|
||||
}
|
||||
|
||||
pub fn parse_windsurf_model_configs_response(
|
||||
value: &serde_json::Value,
|
||||
updated_at_unix_secs: u64,
|
||||
) -> Option<serde_json::Value> {
|
||||
let configs = value
|
||||
.get("clientModelConfigs")
|
||||
.or_else(|| value.get("client_model_configs"))
|
||||
.and_then(serde_json::Value::as_array)?;
|
||||
let mut models = Vec::new();
|
||||
for config in configs {
|
||||
let Some(model_uid) = coerce_json_string(
|
||||
config
|
||||
.get("modelUid")
|
||||
.or_else(|| config.get("model_uid"))
|
||||
.or_else(|| config.get("id"))
|
||||
.or_else(|| config.get("name")),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let mut model = serde_json::Map::new();
|
||||
model.insert("model_uid".to_string(), json!(model_uid));
|
||||
if let Some(label) = coerce_json_string(
|
||||
config
|
||||
.get("label")
|
||||
.or_else(|| config.get("displayName"))
|
||||
.or_else(|| config.get("display_name")),
|
||||
) {
|
||||
model.insert("label".to_string(), json!(label));
|
||||
}
|
||||
if let Some(provider) = coerce_json_string(config.get("provider")) {
|
||||
model.insert("provider".to_string(), json!(provider));
|
||||
}
|
||||
if let Some(value) = config
|
||||
.get("supportsImages")
|
||||
.or_else(|| config.get("supports_images"))
|
||||
.and_then(coerce_json_bool)
|
||||
{
|
||||
model.insert("supports_images".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = config
|
||||
.get("creditMultiplier")
|
||||
.or_else(|| config.get("credit_multiplier"))
|
||||
.and_then(coerce_json_f64)
|
||||
{
|
||||
model.insert("credit_multiplier".to_string(), json!(value));
|
||||
}
|
||||
models.push(serde_json::Value::Object(model));
|
||||
}
|
||||
|
||||
let mut result = serde_json::Map::new();
|
||||
result.insert("updated_at".to_string(), json!(updated_at_unix_secs));
|
||||
result.insert(
|
||||
"allowed_models_count".to_string(),
|
||||
json!(models.len() as u64),
|
||||
);
|
||||
result.insert("models".to_string(), serde_json::Value::Array(models));
|
||||
if let Some(default_model_uid) = value
|
||||
.get("defaultOverrideModelConfig")
|
||||
.or_else(|| value.get("default_override_model_config"))
|
||||
.and_then(|default_config| {
|
||||
coerce_json_string(
|
||||
default_config
|
||||
.get("modelUid")
|
||||
.or_else(|| default_config.get("model_uid")),
|
||||
)
|
||||
})
|
||||
{
|
||||
result.insert("default_model_uid".to_string(), json!(default_model_uid));
|
||||
}
|
||||
|
||||
Some(serde_json::Value::Object(result))
|
||||
}
|
||||
|
||||
pub fn parse_windsurf_rate_limit_response(
|
||||
value: &serde_json::Value,
|
||||
updated_at_unix_secs: u64,
|
||||
) -> Option<serde_json::Value> {
|
||||
let root = value.as_object()?;
|
||||
if root.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let has_capacity = value
|
||||
.get("hasCapacity")
|
||||
.or_else(|| value.get("has_capacity"))
|
||||
.and_then(coerce_json_bool)
|
||||
.unwrap_or(true);
|
||||
let messages_remaining = value
|
||||
.get("messagesRemaining")
|
||||
.or_else(|| value.get("messages_remaining"))
|
||||
.and_then(coerce_json_f64);
|
||||
let max_messages = value
|
||||
.get("maxMessages")
|
||||
.or_else(|| value.get("max_messages"))
|
||||
.and_then(coerce_json_f64);
|
||||
let retry_after_ms = value
|
||||
.get("retryAfterMs")
|
||||
.or_else(|| value.get("retry_after_ms"))
|
||||
.and_then(coerce_json_u64);
|
||||
|
||||
let limited = !has_capacity || messages_remaining.is_some_and(|value| value <= 0.0);
|
||||
let mut rate_limit = serde_json::Map::new();
|
||||
rate_limit.insert("limited".to_string(), json!(limited));
|
||||
rate_limit.insert("has_capacity".to_string(), json!(has_capacity));
|
||||
if let Some(value) = messages_remaining {
|
||||
rate_limit.insert("messages_remaining".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = max_messages {
|
||||
rate_limit.insert("max_messages".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = retry_after_ms {
|
||||
rate_limit.insert("retry_after_ms".to_string(), json!(value));
|
||||
}
|
||||
|
||||
Some(json!({
|
||||
"updated_at": updated_at_unix_secs,
|
||||
"rate_limit": rate_limit,
|
||||
}))
|
||||
}
|
||||
|
||||
fn chatgpt_web_quota_feature_name(value: &serde_json::Value) -> Option<String> {
|
||||
coerce_json_string(
|
||||
value
|
||||
@@ -1141,8 +1452,10 @@ mod tests {
|
||||
use super::{
|
||||
codex_build_invalid_state, codex_runtime_invalid_reason,
|
||||
parse_chatgpt_web_conversation_init_response, parse_codex_backend_me_response,
|
||||
parse_codex_wham_usage_response, OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_EXPIRED_PREFIX,
|
||||
OAUTH_REFRESH_FAILED_PREFIX, OAUTH_REQUEST_FAILED_PREFIX,
|
||||
parse_codex_wham_usage_response, parse_windsurf_model_configs_response,
|
||||
parse_windsurf_rate_limit_response, parse_windsurf_user_status_response,
|
||||
OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_EXPIRED_PREFIX, OAUTH_REFRESH_FAILED_PREFIX,
|
||||
OAUTH_REQUEST_FAILED_PREFIX,
|
||||
};
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
||||
use serde_json::json;
|
||||
@@ -1504,6 +1817,118 @@ mod tests {
|
||||
assert!(parsed.get("secondary_used_percent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_windsurf_user_status_response() {
|
||||
let parsed = parse_windsurf_user_status_response(
|
||||
&json!({
|
||||
"userStatus": {
|
||||
"email": "windsurf@example.com",
|
||||
"isQuarantined": true,
|
||||
"quarantineReason": "quota review",
|
||||
"planStatus": {
|
||||
"dailyQuotaRemainingPercent": 45.5,
|
||||
"weeklyQuotaRemainingPercent": 80,
|
||||
"dailyQuotaResetAtUnix": "1775553285",
|
||||
"weeklyQuotaResetAtUnix": 1776158085u64,
|
||||
"availablePromptCredits": 900,
|
||||
"usedPromptCredits": 100,
|
||||
"availableFlexCredits": 250,
|
||||
"usedFlexCredits": 50,
|
||||
"overageBalanceMicros": 1250000,
|
||||
"planInfo": {
|
||||
"planName": "Pro",
|
||||
"monthlyPromptCredits": 1000,
|
||||
"monthlyFlexCreditPurchaseAmount": 300
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
1_770_000_000,
|
||||
)
|
||||
.expect("windsurf user status should parse");
|
||||
|
||||
assert_eq!(parsed.get("plan_name"), Some(&json!("Pro")));
|
||||
assert_eq!(parsed.get("daily_remaining_percent"), Some(&json!(45.5)));
|
||||
assert_eq!(parsed.get("weekly_remaining_percent"), Some(&json!(80.0)));
|
||||
assert_eq!(parsed.get("daily_reset_at"), Some(&json!(1_775_553_285u64)));
|
||||
assert_eq!(
|
||||
parsed.get("weekly_reset_at"),
|
||||
Some(&json!(1_776_158_085u64))
|
||||
);
|
||||
assert_eq!(parsed.get("prompt_remaining"), Some(&json!(9.0)));
|
||||
assert_eq!(parsed.get("prompt_used"), Some(&json!(1.0)));
|
||||
assert_eq!(parsed.get("prompt_limit"), Some(&json!(10.0)));
|
||||
assert_eq!(parsed.get("flex_remaining"), Some(&json!(2.5)));
|
||||
assert_eq!(parsed.get("flex_used"), Some(&json!(0.5)));
|
||||
assert_eq!(parsed.get("flex_limit"), Some(&json!(3.0)));
|
||||
assert_eq!(parsed.get("overage_balance"), Some(&json!(1.25)));
|
||||
assert_eq!(parsed.get("email"), Some(&json!("windsurf@example.com")));
|
||||
assert_eq!(parsed.get("quarantined"), Some(&json!(true)));
|
||||
assert_eq!(
|
||||
parsed.get("quarantine_reason"),
|
||||
Some(&json!("quota review"))
|
||||
);
|
||||
assert_eq!(parsed.get("updated_at"), Some(&json!(1_770_000_000u64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_windsurf_model_configs_response() {
|
||||
let parsed = parse_windsurf_model_configs_response(
|
||||
&json!({
|
||||
"clientModelConfigs": [
|
||||
{
|
||||
"modelUid": "claude-sonnet-4-5",
|
||||
"label": "Claude Sonnet 4.5",
|
||||
"provider": "anthropic",
|
||||
"supportsImages": true,
|
||||
"creditMultiplier": 2
|
||||
},
|
||||
{
|
||||
"modelUid": "gpt-5-mini",
|
||||
"label": "GPT-5 mini"
|
||||
}
|
||||
],
|
||||
"defaultOverrideModelConfig": {
|
||||
"modelUid": "claude-sonnet-4-5"
|
||||
}
|
||||
}),
|
||||
1_770_000_100,
|
||||
)
|
||||
.expect("windsurf model configs should parse");
|
||||
|
||||
assert_eq!(parsed.get("allowed_models_count"), Some(&json!(2u64)));
|
||||
assert_eq!(
|
||||
parsed.get("default_model_uid"),
|
||||
Some(&json!("claude-sonnet-4-5"))
|
||||
);
|
||||
assert_eq!(parsed.get("updated_at"), Some(&json!(1_770_000_100u64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_windsurf_rate_limit_response() {
|
||||
let parsed = parse_windsurf_rate_limit_response(
|
||||
&json!({
|
||||
"hasCapacity": false,
|
||||
"messagesRemaining": 0,
|
||||
"maxMessages": 25,
|
||||
"retryAfterMs": 45000
|
||||
}),
|
||||
1_770_000_200,
|
||||
)
|
||||
.expect("windsurf rate limit should parse");
|
||||
|
||||
assert_eq!(parsed.get("updated_at"), Some(&json!(1_770_000_200u64)));
|
||||
assert_eq!(parsed.pointer("/rate_limit/limited"), Some(&json!(true)));
|
||||
assert_eq!(
|
||||
parsed.pointer("/rate_limit/messages_remaining"),
|
||||
Some(&json!(0.0))
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.pointer("/rate_limit/retry_after_ms"),
|
||||
Some(&json!(45000u64))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_chatgpt_web_image_quota_from_conversation_init() {
|
||||
let parsed = parse_chatgpt_web_conversation_init_response(
|
||||
|
||||
@@ -37,9 +37,27 @@ pub fn provider_oauth_pkce_s256(verifier: &str) -> String {
|
||||
|
||||
pub fn parse_provider_oauth_callback_params(callback_url: &str) -> BTreeMap<String, String> {
|
||||
let mut merged = BTreeMap::new();
|
||||
let Ok(url) = Url::parse(callback_url.trim()) else {
|
||||
let raw_callback_url = callback_url.trim();
|
||||
let parsed_url = Url::parse(raw_callback_url).or_else(|_| {
|
||||
Url::parse(&format!(
|
||||
"https://aether.local/{}",
|
||||
raw_callback_url.trim_start_matches('/')
|
||||
))
|
||||
});
|
||||
let Ok(url) = parsed_url else {
|
||||
return merged;
|
||||
};
|
||||
if url.query().is_none()
|
||||
&& url.fragment().is_none()
|
||||
&& raw_callback_url.contains('=')
|
||||
&& !raw_callback_url.contains("://")
|
||||
{
|
||||
for (key, value) in
|
||||
form_urlencoded::parse(raw_callback_url.trim_start_matches('?').as_bytes())
|
||||
{
|
||||
merged.insert(key.into_owned(), value.into_owned());
|
||||
}
|
||||
}
|
||||
for (key, value) in form_urlencoded::parse(url.query().unwrap_or_default().as_bytes()) {
|
||||
merged.insert(key.into_owned(), value.into_owned());
|
||||
}
|
||||
@@ -377,6 +395,28 @@ mod tests {
|
||||
assert_eq!(params.get("state").map(String::as_str), Some("nonce-value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_provider_oauth_callback_params_reads_relative_show_auth_token_url() {
|
||||
let params = parse_provider_oauth_callback_params(
|
||||
"show-auth-token?token=firebase-id-token&state=session-1&provider=google",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
params.get("token").map(String::as_str),
|
||||
Some("firebase-id-token")
|
||||
);
|
||||
assert_eq!(params.get("state").map(String::as_str), Some("session-1"));
|
||||
assert_eq!(params.get("provider").map(String::as_str), Some("google"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_provider_oauth_callback_params_reads_raw_query_string() {
|
||||
let params = parse_provider_oauth_callback_params("token=raw-token&state=session-raw");
|
||||
|
||||
assert_eq!(params.get("token").map(String::as_str), Some("raw-token"));
|
||||
assert_eq!(params.get("state").map(String::as_str), Some("session-raw"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatgpt_web_enrichment_extracts_identity_from_openai_claims() {
|
||||
let access_token = sample_unsigned_jwt(json!({
|
||||
|
||||
@@ -40,6 +40,7 @@ const AUTO_REMOVABLE_ACCOUNT_STATE_CODES: &[&str] = &[
|
||||
"account_banned",
|
||||
"account_suspended",
|
||||
"account_disabled",
|
||||
"account_quarantined",
|
||||
"workspace_deactivated",
|
||||
"account_forbidden",
|
||||
];
|
||||
@@ -275,7 +276,7 @@ fn resolve_from_metadata(
|
||||
upstream_metadata: Option<&Value>,
|
||||
) -> Option<PoolAccountState> {
|
||||
for source in metadata_sources(provider_type, upstream_metadata) {
|
||||
if json_bool(source.get("is_banned")) {
|
||||
if json_bool(source.get("is_banned")) || json_bool(source.get("banned")) {
|
||||
let reason = extract_reason(
|
||||
source,
|
||||
&["ban_reason", "forbidden_reason", "reason", "message"],
|
||||
@@ -290,6 +291,18 @@ fn resolve_from_metadata(
|
||||
recoverable: false,
|
||||
});
|
||||
}
|
||||
if json_bool(source.get("is_quarantined")) || json_bool(source.get("quarantined")) {
|
||||
let reason = extract_reason(source, &["quarantine_reason", "reason", "message"])
|
||||
.unwrap_or_else(|| "账号处于隔离状态".to_string());
|
||||
return Some(PoolAccountState {
|
||||
blocked: true,
|
||||
code: Some("account_quarantined".to_string()),
|
||||
label: Some("账号隔离".to_string()),
|
||||
reason: Some(reason),
|
||||
source: Some("metadata".to_string()),
|
||||
recoverable: false,
|
||||
});
|
||||
}
|
||||
if json_bool(source.get("is_forbidden")) || json_bool(source.get("account_disabled")) {
|
||||
let reason = extract_reason(
|
||||
source,
|
||||
@@ -574,6 +587,35 @@ mod tests {
|
||||
assert!(!should_auto_remove_account_state(&state));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_windsurf_banned_and_quarantined_metadata_aliases() {
|
||||
let banned = resolve_pool_account_state(
|
||||
Some("windsurf"),
|
||||
Some(&json!({
|
||||
"windsurf": {
|
||||
"banned": true,
|
||||
"reason": "forbidden"
|
||||
}
|
||||
})),
|
||||
None,
|
||||
);
|
||||
assert!(banned.blocked);
|
||||
assert_eq!(banned.code.as_deref(), Some("account_banned"));
|
||||
|
||||
let quarantined = resolve_pool_account_state(
|
||||
Some("windsurf"),
|
||||
Some(&json!({
|
||||
"windsurf": {
|
||||
"quarantined": true
|
||||
}
|
||||
})),
|
||||
None,
|
||||
);
|
||||
assert!(quarantined.blocked);
|
||||
assert_eq!(quarantined.code.as_deref(), Some("account_quarantined"));
|
||||
assert!(should_auto_remove_account_state(&quarantined));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_snapshot_ignores_refresh_failed_as_account_block() {
|
||||
let snapshot = resolve_account_status_snapshot(
|
||||
|
||||
@@ -211,10 +211,10 @@ pub use crate::provider_compat::kiro_stream::{
|
||||
KiroToClaudeCliStreamState, KIRO_MAX_THINKING_BUFFER,
|
||||
};
|
||||
pub use crate::provider_compat::private_envelope::{
|
||||
maybe_build_provider_private_stream_normalizer, normalize_provider_private_report_context,
|
||||
normalize_provider_private_response_value, provider_private_response_allows_sync_finalize,
|
||||
stream_body_contains_error_event, transform_provider_private_stream_line,
|
||||
ProviderPrivateStreamNormalizer,
|
||||
extract_provider_private_stream_error_body, maybe_build_provider_private_stream_normalizer,
|
||||
normalize_provider_private_report_context, normalize_provider_private_response_value,
|
||||
provider_private_response_allows_sync_finalize, stream_body_contains_error_event,
|
||||
transform_provider_private_stream_line, ProviderPrivateStreamNormalizer,
|
||||
};
|
||||
pub use crate::provider_compat::surfaces::{
|
||||
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_anchor_api_format,
|
||||
|
||||
@@ -1299,6 +1299,7 @@ struct OpenAIResponsesClientToolState {
|
||||
name: String,
|
||||
arguments: String,
|
||||
output_index: Option<usize>,
|
||||
web_search: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
@@ -1310,6 +1311,23 @@ struct OpenAIResponsesClientToolResultState {
|
||||
item_started: bool,
|
||||
}
|
||||
|
||||
fn is_responses_web_search_tool(name: &str) -> bool {
|
||||
matches!(name, "web_search" | "web_search_preview")
|
||||
}
|
||||
|
||||
fn web_search_query_from_arguments(arguments: &str) -> String {
|
||||
serde_json::from_str::<Value>(arguments)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("query")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| value.as_str().map(ToOwned::to_owned))
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct OpenAIResponsesClientEmitter {
|
||||
response_id: Option<String>,
|
||||
@@ -1985,6 +2003,26 @@ impl OpenAIResponsesClientEmitter {
|
||||
} else {
|
||||
state.name.clone()
|
||||
};
|
||||
if state.web_search {
|
||||
out.extend(self.encode_response_event(
|
||||
"response.output_item.done",
|
||||
json!({
|
||||
"type": "response.output_item.done",
|
||||
"response_id": self.response_id(),
|
||||
"output_index": output_index,
|
||||
"item": {
|
||||
"type": "web_search_call",
|
||||
"id": item_id,
|
||||
"status": "completed",
|
||||
"action": {
|
||||
"type": "search",
|
||||
"query": web_search_query_from_arguments(&state.arguments),
|
||||
},
|
||||
}
|
||||
}),
|
||||
)?);
|
||||
continue;
|
||||
}
|
||||
out.extend(self.encode_response_event(
|
||||
"response.function_call_arguments.done",
|
||||
json!({
|
||||
@@ -2143,20 +2181,32 @@ impl OpenAIResponsesClientEmitter {
|
||||
}
|
||||
for (index, state) in &self.tool_calls {
|
||||
if let Some(output_index) = state.output_index {
|
||||
let item_id = if state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(*index)
|
||||
} else {
|
||||
state.call_id.clone()
|
||||
};
|
||||
if state.web_search {
|
||||
ordered_output.push((
|
||||
output_index,
|
||||
json!({
|
||||
"type": "web_search_call",
|
||||
"id": item_id,
|
||||
"status": "completed",
|
||||
"action": {
|
||||
"type": "search",
|
||||
"query": web_search_query_from_arguments(&state.arguments),
|
||||
},
|
||||
}),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
ordered_output.push((
|
||||
output_index,
|
||||
json!({
|
||||
"type": "function_call",
|
||||
"id": if state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(*index)
|
||||
} else {
|
||||
state.call_id.clone()
|
||||
},
|
||||
"call_id": if state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(*index)
|
||||
} else {
|
||||
state.call_id.clone()
|
||||
},
|
||||
"id": item_id.clone(),
|
||||
"call_id": item_id,
|
||||
"name": if state.name.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
@@ -2322,22 +2372,36 @@ impl OpenAIResponsesClientEmitter {
|
||||
let state = self.tool_calls.entry(index).or_default();
|
||||
state.call_id = call_id.clone();
|
||||
state.name = name.clone();
|
||||
state.web_search = is_responses_web_search_tool(&name);
|
||||
let emitted_call_id = state.call_id.clone();
|
||||
let emitted_name = state.name.clone();
|
||||
let item = if state.web_search {
|
||||
json!({
|
||||
"type": "web_search_call",
|
||||
"id": emitted_call_id,
|
||||
"status": "in_progress",
|
||||
"action": {
|
||||
"type": "search",
|
||||
"query": "",
|
||||
},
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"type": "function_call",
|
||||
"id": call_id,
|
||||
"call_id": emitted_call_id,
|
||||
"name": emitted_name,
|
||||
"arguments": "",
|
||||
"status": "in_progress",
|
||||
})
|
||||
};
|
||||
out.extend(self.encode_response_event(
|
||||
"response.output_item.added",
|
||||
json!({
|
||||
"type": "response.output_item.added",
|
||||
"response_id": response_id,
|
||||
"output_index": output_index,
|
||||
"item": {
|
||||
"type": "function_call",
|
||||
"id": call_id,
|
||||
"call_id": emitted_call_id,
|
||||
"name": emitted_name,
|
||||
"arguments": "",
|
||||
"status": "in_progress",
|
||||
}
|
||||
"item": item
|
||||
}),
|
||||
)?);
|
||||
Ok(out)
|
||||
@@ -2348,6 +2412,9 @@ impl OpenAIResponsesClientEmitter {
|
||||
let response_id = self.response_id().to_string();
|
||||
let state = self.tool_calls.entry(index).or_default();
|
||||
state.arguments.push_str(&arguments);
|
||||
if state.web_search {
|
||||
return Ok(out);
|
||||
}
|
||||
let item_id = if state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(index)
|
||||
} else {
|
||||
@@ -3167,6 +3234,56 @@ mod tests {
|
||||
assert!(sse.contains("\"output\":\"{\\\"ok\\\":true}\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_responses_client_emitter_emits_web_search_call_item() {
|
||||
let mut emitter = OpenAIResponsesClientEmitter::default();
|
||||
let mut bytes = emitter
|
||||
.emit(CanonicalStreamFrame {
|
||||
id: "resp_123".to_string(),
|
||||
model: "gpt-5-5-low".to_string(),
|
||||
event: CanonicalStreamEvent::ToolCallStart {
|
||||
index: 0,
|
||||
call_id: "call_ws_1".to_string(),
|
||||
name: "web_search".to_string(),
|
||||
},
|
||||
})
|
||||
.expect("tool start should encode");
|
||||
bytes.extend(
|
||||
emitter
|
||||
.emit(CanonicalStreamFrame {
|
||||
id: "resp_123".to_string(),
|
||||
model: "gpt-5-5-low".to_string(),
|
||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
||||
index: 0,
|
||||
arguments: r#"{"query":"today tech"}"#.to_string(),
|
||||
},
|
||||
})
|
||||
.expect("arguments should encode"),
|
||||
);
|
||||
bytes.extend(
|
||||
emitter
|
||||
.emit(CanonicalStreamFrame {
|
||||
id: "resp_123".to_string(),
|
||||
model: "gpt-5-5-low".to_string(),
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason: Some("tool_calls".to_string()),
|
||||
usage: None,
|
||||
},
|
||||
})
|
||||
.expect("finish should encode"),
|
||||
);
|
||||
|
||||
let sse = String::from_utf8(bytes).expect("sse should be utf8");
|
||||
assert!(sse.contains("event: response.output_item.added\n"));
|
||||
assert!(sse.contains(r#""type":"web_search_call""#));
|
||||
assert!(sse.contains(r#""status":"in_progress""#));
|
||||
assert!(sse.contains(r#""query":"""#));
|
||||
assert!(sse.contains(r#""type":"search""#));
|
||||
assert!(sse.contains("event: response.output_item.done\n"));
|
||||
assert!(sse.contains(r#""query":"today tech""#));
|
||||
assert!(!sse.contains("response.function_call_arguments.delta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_responses_provider_state_accepts_legacy_outtext_delta_alias() {
|
||||
let mut state = OpenAIResponsesProviderState::default();
|
||||
|
||||
@@ -166,13 +166,25 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: b
|
||||
&response_id,
|
||||
&mut message_index,
|
||||
);
|
||||
output.push(json!({
|
||||
"type": "function_call",
|
||||
"id": id,
|
||||
"call_id": id,
|
||||
"name": name,
|
||||
"arguments": canonicalize_tool_arguments(input),
|
||||
}));
|
||||
if is_responses_web_search_tool(name) {
|
||||
output.push(json!({
|
||||
"type": "web_search_call",
|
||||
"id": id,
|
||||
"status": "completed",
|
||||
"action": {
|
||||
"type": "search",
|
||||
"query": web_search_query_from_value(input),
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
output.push(json!({
|
||||
"type": "function_call",
|
||||
"id": id,
|
||||
"call_id": id,
|
||||
"name": name,
|
||||
"arguments": canonicalize_tool_arguments(input),
|
||||
}));
|
||||
}
|
||||
}
|
||||
CanonicalContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
@@ -325,3 +337,73 @@ fn openai_responses_output_format_from_mime_type(mime_type: &str) -> String {
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn is_responses_web_search_tool(name: &str) -> bool {
|
||||
matches!(name, "web_search" | "web_search_preview")
|
||||
}
|
||||
|
||||
fn web_search_query_from_value(input: &Value) -> String {
|
||||
input
|
||||
.get("query")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| input.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn responses_response_builder_emits_web_search_call_for_web_search_tool_use() {
|
||||
let response = CanonicalResponse {
|
||||
id: "resp_test".to_string(),
|
||||
model: "gpt-5-5-low".to_string(),
|
||||
content: vec![CanonicalContentBlock::ToolUse {
|
||||
id: "call_ws_1".to_string(),
|
||||
name: "web_search".to_string(),
|
||||
input: json!({"query": "today tech"}),
|
||||
extensions: BTreeMap::new(),
|
||||
}],
|
||||
outputs: Vec::new(),
|
||||
stop_reason: Some(CanonicalStopReason::ToolUse),
|
||||
usage: None,
|
||||
extensions: BTreeMap::new(),
|
||||
};
|
||||
|
||||
let body = to_raw(&response, &json!({}), false);
|
||||
|
||||
assert_eq!(body["output"][0]["type"], "web_search_call");
|
||||
assert_eq!(body["output"][0]["id"], "call_ws_1");
|
||||
assert_eq!(body["output"][0]["status"], "completed");
|
||||
assert_eq!(body["output"][0]["action"]["type"], "search");
|
||||
assert_eq!(body["output"][0]["action"]["query"], "today tech");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_response_parser_reads_web_search_call_as_tool_use() {
|
||||
let body = json!({
|
||||
"id": "resp_test",
|
||||
"model": "gpt-5-5-low",
|
||||
"status": "incomplete",
|
||||
"output": [{
|
||||
"type": "web_search_call",
|
||||
"id": "call_ws_1",
|
||||
"status": "completed",
|
||||
"action": {"type": "search", "query": "today tech"}
|
||||
}]
|
||||
});
|
||||
|
||||
let canonical = from_raw(&body).expect("response should parse");
|
||||
|
||||
assert!(
|
||||
matches!(canonical.content.first(), Some(CanonicalContentBlock::ToolUse {
|
||||
id,
|
||||
name,
|
||||
input,
|
||||
..
|
||||
}) if id == "call_ws_1" && name == "web_search" && input["query"] == "today tech")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1556,6 +1556,39 @@ pub(crate) fn openai_responses_input_to_canonical_messages(
|
||||
extensions: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
"web_search_call" => {
|
||||
let id = item_object
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| {
|
||||
let generated =
|
||||
format!("call_auto_{next_generated_tool_call_index}");
|
||||
next_generated_tool_call_index += 1;
|
||||
generated
|
||||
});
|
||||
let query = item_object
|
||||
.get("action")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|action| action.get("query"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
messages.push(CanonicalMessage {
|
||||
role: CanonicalRole::Assistant,
|
||||
content: vec![CanonicalContentBlock::ToolUse {
|
||||
id,
|
||||
name: "web_search".to_string(),
|
||||
input: json!({ "query": query }),
|
||||
extensions: openai_responses_extensions(
|
||||
item_object,
|
||||
&["type", "id", "status", "action"],
|
||||
),
|
||||
}],
|
||||
extensions: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
"function_call_output" => {
|
||||
let id = item_object
|
||||
.get("call_id")
|
||||
@@ -1729,6 +1762,30 @@ pub(crate) fn openai_responses_output_to_canonical_blocks(
|
||||
),
|
||||
});
|
||||
}
|
||||
"web_search_call" => {
|
||||
let id = item_object
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("call_auto_{index}"));
|
||||
let query = item_object
|
||||
.get("action")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|action| action.get("query"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
blocks.push(CanonicalContentBlock::ToolUse {
|
||||
id,
|
||||
name: "web_search".to_string(),
|
||||
input: json!({ "query": query }),
|
||||
extensions: openai_responses_extensions(
|
||||
item_object,
|
||||
&["type", "id", "status", "action"],
|
||||
),
|
||||
});
|
||||
}
|
||||
"function_call_output" => {
|
||||
let id = item_object
|
||||
.get("call_id")
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::provider_compat::kiro_stream::KiroToClaudeCliStreamState;
|
||||
use super::surfaces::{
|
||||
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_descriptor_for_envelope,
|
||||
provider_adaptation_should_unwrap_stream_envelope, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
|
||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, KIRO_ENVELOPE_NAME,
|
||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, KIRO_ENVELOPE_NAME, WINDSURF_ENVELOPE_NAME,
|
||||
};
|
||||
|
||||
pub fn provider_private_response_allows_sync_finalize(report_context: &Value) -> bool {
|
||||
@@ -92,6 +92,7 @@ pub fn normalize_provider_private_response_value(
|
||||
data
|
||||
}
|
||||
}
|
||||
Some(WINDSURF_ENVELOPE_NAME) => normalize_windsurf_sync_response_value(data)?,
|
||||
_ => return None,
|
||||
};
|
||||
postprocess_private_response_value(&mut unwrapped, report_context);
|
||||
@@ -101,14 +102,28 @@ pub fn normalize_provider_private_response_value(
|
||||
pub fn transform_provider_private_stream_line(
|
||||
report_context: &Value,
|
||||
line: Vec<u8>,
|
||||
) -> Result<Vec<u8>, serde_json::Error> {
|
||||
transform_provider_private_stream_line_with_event_state(report_context, line, &mut None)
|
||||
}
|
||||
|
||||
fn transform_provider_private_stream_line_with_event_state(
|
||||
report_context: &Value,
|
||||
line: Vec<u8>,
|
||||
current_event_type: &mut Option<String>,
|
||||
) -> Result<Vec<u8>, serde_json::Error> {
|
||||
let Ok(text) = std::str::from_utf8(&line) else {
|
||||
return Ok(line);
|
||||
};
|
||||
let trimmed = text.trim_matches('\r').trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with(':') || trimmed.starts_with("event:") {
|
||||
if trimmed.is_empty() || trimmed.starts_with(':') {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if let Some(event_name) = trimmed.strip_prefix("event:") {
|
||||
let event_name = event_name.trim().to_string();
|
||||
let is_error = event_name.eq_ignore_ascii_case("error");
|
||||
*current_event_type = (!event_name.is_empty()).then_some(event_name);
|
||||
return if is_error { Ok(line) } else { Ok(Vec::new()) };
|
||||
}
|
||||
let Some(data_line) = trimmed.strip_prefix("data:") else {
|
||||
return Ok(line);
|
||||
};
|
||||
@@ -121,6 +136,13 @@ pub fn transform_provider_private_stream_line(
|
||||
Ok(value) => value,
|
||||
Err(_) => return Ok(line),
|
||||
};
|
||||
let event_is_error = current_event_type
|
||||
.as_deref()
|
||||
.is_some_and(|event| event.eq_ignore_ascii_case("error"));
|
||||
*current_event_type = None;
|
||||
if event_is_error {
|
||||
return Ok(line);
|
||||
}
|
||||
|
||||
let envelope_name = report_context
|
||||
.get("envelope_name")
|
||||
@@ -133,6 +155,9 @@ pub fn transform_provider_private_stream_line(
|
||||
if !provider_adaptation_should_unwrap_stream_envelope(envelope_name, provider_api_format) {
|
||||
return Ok(line);
|
||||
}
|
||||
if envelope_name == WINDSURF_ENVELOPE_NAME && looks_like_windsurf_error(&body) {
|
||||
return Ok(line);
|
||||
}
|
||||
let unwrapped = match envelope_name {
|
||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME => body.get("response").cloned().unwrap_or(body),
|
||||
ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME => {
|
||||
@@ -147,6 +172,7 @@ pub fn transform_provider_private_stream_line(
|
||||
inject_antigravity_stream_tool_ids(&mut response);
|
||||
response
|
||||
}
|
||||
WINDSURF_ENVELOPE_NAME => normalize_windsurf_stream_event_value(&body).unwrap_or(body),
|
||||
_ => body,
|
||||
};
|
||||
|
||||
@@ -156,6 +182,96 @@ pub fn transform_provider_private_stream_line(
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
const CONNECT_FRAME_HEADER_BYTES: usize = 5;
|
||||
const MAX_CONNECT_JSON_FRAME_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
fn report_context_is_windsurf_envelope(report_context: &Value) -> bool {
|
||||
report_context
|
||||
.get("envelope_name")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case(WINDSURF_ENVELOPE_NAME))
|
||||
}
|
||||
|
||||
fn buffer_looks_like_connect_frame(buffer: &[u8]) -> bool {
|
||||
let Some(flags) = buffer.first().copied() else {
|
||||
return false;
|
||||
};
|
||||
if flags & !0x03 != 0 {
|
||||
return false;
|
||||
}
|
||||
if buffer.len() < CONNECT_FRAME_HEADER_BYTES {
|
||||
return true;
|
||||
}
|
||||
let len = u32::from_be_bytes([buffer[1], buffer[2], buffer[3], buffer[4]]) as usize;
|
||||
len <= MAX_CONNECT_JSON_FRAME_BYTES
|
||||
}
|
||||
|
||||
fn drain_windsurf_connect_json_frames(
|
||||
buffer: &mut Vec<u8>,
|
||||
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
let mut output = Vec::new();
|
||||
while buffer.len() >= CONNECT_FRAME_HEADER_BYTES {
|
||||
let flags = buffer[0];
|
||||
if flags & !0x03 != 0 {
|
||||
return Err(AiSurfaceFinalizeError::new(format!(
|
||||
"invalid Connect frame flags: {flags}"
|
||||
)));
|
||||
}
|
||||
let len = u32::from_be_bytes([buffer[1], buffer[2], buffer[3], buffer[4]]) as usize;
|
||||
if len > MAX_CONNECT_JSON_FRAME_BYTES {
|
||||
return Err(AiSurfaceFinalizeError::new(format!(
|
||||
"Connect frame size {len} exceeds {MAX_CONNECT_JSON_FRAME_BYTES}"
|
||||
)));
|
||||
}
|
||||
if buffer.len() < CONNECT_FRAME_HEADER_BYTES + len {
|
||||
break;
|
||||
}
|
||||
let payload = buffer[CONNECT_FRAME_HEADER_BYTES..CONNECT_FRAME_HEADER_BYTES + len].to_vec();
|
||||
buffer.drain(..CONNECT_FRAME_HEADER_BYTES + len);
|
||||
|
||||
if flags & 0x01 != 0 {
|
||||
return Err(AiSurfaceFinalizeError::new(
|
||||
"compressed Connect JSON frames are not supported for Windsurf chat",
|
||||
));
|
||||
}
|
||||
if payload.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let body: Value = serde_json::from_slice(&payload)?;
|
||||
if flags & 0x02 != 0 {
|
||||
if let Some(error) = body.get("error") {
|
||||
output.extend_from_slice(b"event: error\n");
|
||||
output.extend_from_slice(b"data: ");
|
||||
output.extend(serde_json::to_vec(error)?);
|
||||
output.extend_from_slice(b"\n\n");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if looks_like_windsurf_error(&body) {
|
||||
output.extend_from_slice(b"event: error\n");
|
||||
output.extend_from_slice(b"data: ");
|
||||
output.extend(serde_json::to_vec(&body)?);
|
||||
output.extend_from_slice(b"\n\n");
|
||||
continue;
|
||||
}
|
||||
let unwrapped = normalize_windsurf_stream_event_value(&body).unwrap_or(body);
|
||||
let mut line = b"data: ".to_vec();
|
||||
line.extend(serde_json::to_vec(&unwrapped)?);
|
||||
line.extend_from_slice(b"\n\n");
|
||||
output.extend(line);
|
||||
}
|
||||
|
||||
if !buffer.is_empty()
|
||||
&& buffer.len() < CONNECT_FRAME_HEADER_BYTES
|
||||
&& !buffer_looks_like_connect_frame(buffer)
|
||||
{
|
||||
return Err(AiSurfaceFinalizeError::new(
|
||||
"invalid partial Connect JSON frame",
|
||||
));
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
enum ProviderPrivateStreamNormalizeMode {
|
||||
EnvelopeUnwrap,
|
||||
KiroToClaudeCli(Box<KiroToClaudeCliStreamState>),
|
||||
@@ -164,6 +280,7 @@ enum ProviderPrivateStreamNormalizeMode {
|
||||
pub struct ProviderPrivateStreamNormalizer<'a> {
|
||||
report_context: &'a Value,
|
||||
buffered: Vec<u8>,
|
||||
current_event_type: Option<String>,
|
||||
mode: ProviderPrivateStreamNormalizeMode,
|
||||
}
|
||||
|
||||
@@ -203,10 +320,24 @@ pub fn maybe_build_provider_private_stream_normalizer<'a>(
|
||||
Some(ProviderPrivateStreamNormalizer {
|
||||
report_context,
|
||||
buffered: Vec::new(),
|
||||
current_event_type: None,
|
||||
mode,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn extract_provider_private_stream_error_body(
|
||||
report_context: Option<&Value>,
|
||||
body: &[u8],
|
||||
) -> Option<Value> {
|
||||
if report_context.is_none_or(report_context_is_windsurf_envelope) {
|
||||
if let Some(error_body) = extract_windsurf_connect_json_error_body(body) {
|
||||
return Some(error_body);
|
||||
}
|
||||
}
|
||||
|
||||
extract_stream_error_event_body(body)
|
||||
}
|
||||
|
||||
impl ProviderPrivateStreamNormalizer<'_> {
|
||||
pub fn push_chunk(&mut self, chunk: &[u8]) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
match &mut self.mode {
|
||||
@@ -215,12 +346,21 @@ impl ProviderPrivateStreamNormalizer<'_> {
|
||||
}
|
||||
ProviderPrivateStreamNormalizeMode::EnvelopeUnwrap => {
|
||||
self.buffered.extend_from_slice(chunk);
|
||||
if report_context_is_windsurf_envelope(self.report_context)
|
||||
&& buffer_looks_like_connect_frame(&self.buffered)
|
||||
{
|
||||
return drain_windsurf_connect_json_frames(&mut self.buffered);
|
||||
}
|
||||
let mut output = Vec::new();
|
||||
while let Some(line_end) = self.buffered.iter().position(|byte| *byte == b'\n') {
|
||||
let line = self.buffered.drain(..=line_end).collect::<Vec<_>>();
|
||||
output.extend(
|
||||
transform_provider_private_stream_line(self.report_context, line)
|
||||
.map_err(AiSurfaceFinalizeError::from)?,
|
||||
transform_provider_private_stream_line_with_event_state(
|
||||
self.report_context,
|
||||
line,
|
||||
&mut self.current_event_type,
|
||||
)
|
||||
.map_err(AiSurfaceFinalizeError::from)?,
|
||||
);
|
||||
}
|
||||
Ok(output)
|
||||
@@ -237,18 +377,235 @@ impl ProviderPrivateStreamNormalizer<'_> {
|
||||
if self.buffered.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if report_context_is_windsurf_envelope(self.report_context)
|
||||
&& buffer_looks_like_connect_frame(&self.buffered)
|
||||
{
|
||||
return drain_windsurf_connect_json_frames(&mut self.buffered);
|
||||
}
|
||||
let line = std::mem::take(&mut self.buffered);
|
||||
transform_provider_private_stream_line(self.report_context, line)
|
||||
.map_err(AiSurfaceFinalizeError::from)
|
||||
transform_provider_private_stream_line_with_event_state(
|
||||
self.report_context,
|
||||
line,
|
||||
&mut self.current_event_type,
|
||||
)
|
||||
.map_err(AiSurfaceFinalizeError::from)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stream_body_contains_error_event(body: &[u8]) -> bool {
|
||||
let Ok(text) = std::str::from_utf8(body) else {
|
||||
fn normalize_windsurf_sync_response_value(data: Value) -> Option<Value> {
|
||||
if looks_like_openai_chat_response(&data) {
|
||||
return Some(data);
|
||||
}
|
||||
if looks_like_windsurf_error(&data) {
|
||||
return None;
|
||||
}
|
||||
if let Some(response) = data
|
||||
.get("response")
|
||||
.or_else(|| data.get("message"))
|
||||
.or_else(|| data.get("chatMessage"))
|
||||
.cloned()
|
||||
{
|
||||
if looks_like_openai_chat_response(&response) {
|
||||
return Some(response);
|
||||
}
|
||||
if let Some(text) = extract_windsurf_text(&response) {
|
||||
return Some(build_openai_chat_response_from_text(&data, text));
|
||||
}
|
||||
}
|
||||
extract_windsurf_text(&data).map(|text| build_openai_chat_response_from_text(&data, text))
|
||||
}
|
||||
|
||||
fn normalize_windsurf_stream_event_value(data: &Value) -> Option<Value> {
|
||||
if looks_like_openai_chat_stream_event(data) {
|
||||
return Some(data.clone());
|
||||
}
|
||||
if looks_like_windsurf_error(data) {
|
||||
return None;
|
||||
}
|
||||
let response = data
|
||||
.get("response")
|
||||
.or_else(|| data.get("message"))
|
||||
.or_else(|| data.get("chatMessage"))
|
||||
.unwrap_or(data);
|
||||
if looks_like_openai_chat_stream_event(response) {
|
||||
return Some(response.clone());
|
||||
}
|
||||
extract_windsurf_text(response).map(|text| {
|
||||
serde_json::json!({
|
||||
"id": windsurf_response_id(data),
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {"content": text},
|
||||
"finish_reason": null
|
||||
}]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn looks_like_openai_chat_response(value: &Value) -> bool {
|
||||
value
|
||||
.get("choices")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|choices| !choices.is_empty())
|
||||
}
|
||||
|
||||
fn looks_like_openai_chat_stream_event(value: &Value) -> bool {
|
||||
value
|
||||
.get("choices")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|choices| choices.first())
|
||||
.and_then(Value::as_object)
|
||||
.is_some_and(|choice| choice.contains_key("delta"))
|
||||
}
|
||||
|
||||
fn looks_like_windsurf_error(value: &Value) -> bool {
|
||||
let Some(object) = value.as_object() else {
|
||||
return false;
|
||||
};
|
||||
if object.contains_key("error") {
|
||||
return true;
|
||||
}
|
||||
if object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("error"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if object.contains_key("code") || object.contains_key("status") {
|
||||
return object
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty());
|
||||
}
|
||||
object
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
&& !object.contains_key("response")
|
||||
&& !object.contains_key("chatMessage")
|
||||
&& !object.contains_key("choices")
|
||||
&& !object.contains_key("text")
|
||||
&& !object.contains_key("content")
|
||||
&& !object.contains_key("assistantMessage")
|
||||
&& !object.contains_key("assistant_message")
|
||||
}
|
||||
|
||||
fn extract_windsurf_text(value: &Value) -> Option<String> {
|
||||
if let Some(text) = value
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Some(text.to_string());
|
||||
}
|
||||
let object = value.as_object()?;
|
||||
for key in [
|
||||
"text",
|
||||
"content",
|
||||
"message",
|
||||
"answer",
|
||||
"completion",
|
||||
"assistantMessage",
|
||||
"assistant_message",
|
||||
] {
|
||||
if let Some(text) = object
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Some(text.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn windsurf_response_id(value: &Value) -> String {
|
||||
value
|
||||
.get("id")
|
||||
.or_else(|| value.get("responseId"))
|
||||
.or_else(|| value.get("messageId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("windsurf-cascade")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn build_openai_chat_response_from_text(source: &Value, text: String) -> Value {
|
||||
serde_json::json!({
|
||||
"id": windsurf_response_id(source),
|
||||
"object": "chat.completion",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": text},
|
||||
"finish_reason": "stop"
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
pub fn stream_body_contains_error_event(body: &[u8]) -> bool {
|
||||
if extract_windsurf_connect_json_error_body(body).is_some() {
|
||||
return true;
|
||||
}
|
||||
extract_stream_error_event_body(body).is_some()
|
||||
}
|
||||
|
||||
fn extract_windsurf_connect_json_error_body(body: &[u8]) -> Option<Value> {
|
||||
if !buffer_looks_like_connect_frame(body) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut offset = 0usize;
|
||||
while body.len().saturating_sub(offset) >= CONNECT_FRAME_HEADER_BYTES {
|
||||
let flags = body[offset];
|
||||
if flags & !0x03 != 0 {
|
||||
return None;
|
||||
}
|
||||
let len = u32::from_be_bytes([
|
||||
body[offset + 1],
|
||||
body[offset + 2],
|
||||
body[offset + 3],
|
||||
body[offset + 4],
|
||||
]) as usize;
|
||||
if len > MAX_CONNECT_JSON_FRAME_BYTES {
|
||||
return None;
|
||||
}
|
||||
let frame_end = offset + CONNECT_FRAME_HEADER_BYTES + len;
|
||||
if body.len() < frame_end {
|
||||
return None;
|
||||
}
|
||||
if flags & 0x01 != 0 {
|
||||
return None;
|
||||
}
|
||||
let payload = &body[offset + CONNECT_FRAME_HEADER_BYTES..frame_end];
|
||||
offset = frame_end;
|
||||
if payload.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parsed: Value = serde_json::from_slice(payload).ok()?;
|
||||
if flags & 0x02 != 0 {
|
||||
if let Some(error) = parsed.get("error").filter(|value| !value.is_null()) {
|
||||
return Some(normalize_provider_private_error_body(error.clone()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if looks_like_windsurf_error(&parsed) {
|
||||
return Some(normalize_provider_private_error_body(parsed));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_stream_error_event_body(body: &[u8]) -> Option<Value> {
|
||||
let Ok(text) = std::str::from_utf8(body) else {
|
||||
return None;
|
||||
};
|
||||
let mut current_event_type: Option<String> = None;
|
||||
for raw_line in text.lines() {
|
||||
let line = raw_line.trim_matches('\r').trim();
|
||||
@@ -282,11 +639,35 @@ pub fn stream_body_contains_error_event(body: &[u8]) -> bool {
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("error"))
|
||||
{
|
||||
return true;
|
||||
return Some(normalize_provider_private_error_body(event));
|
||||
}
|
||||
current_event_type = None;
|
||||
}
|
||||
false
|
||||
None
|
||||
}
|
||||
|
||||
fn normalize_provider_private_error_body(error: Value) -> Value {
|
||||
let mut error = if error.get("error").is_some_and(|value| !value.is_null()) {
|
||||
error
|
||||
} else {
|
||||
serde_json::json!({ "error": error })
|
||||
};
|
||||
|
||||
if let Some(error_object) = error.get_mut("error").and_then(Value::as_object_mut) {
|
||||
if !error_object.contains_key("type") {
|
||||
if let Some(kind) = error_object
|
||||
.get("code")
|
||||
.or_else(|| error_object.get("status"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
error_object.insert("type".to_string(), Value::String(kind.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
error
|
||||
}
|
||||
|
||||
fn clear_private_envelope_context(report_context: &Value) -> Value {
|
||||
@@ -427,9 +808,9 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
maybe_build_provider_private_stream_normalizer, normalize_provider_private_report_context,
|
||||
normalize_provider_private_response_value, stream_body_contains_error_event,
|
||||
transform_provider_private_stream_line,
|
||||
extract_provider_private_stream_error_body, maybe_build_provider_private_stream_normalizer,
|
||||
normalize_provider_private_report_context, normalize_provider_private_response_value,
|
||||
stream_body_contains_error_event, transform_provider_private_stream_line,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -497,6 +878,104 @@ mod tests {
|
||||
assert!(output_text.contains("\"id\":\"call_get_weather_0\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_windsurf_sync_text_response_to_openai_chat() {
|
||||
let report_context = json!({
|
||||
"has_envelope": true,
|
||||
"envelope_name": "windsurf:GetChatMessage",
|
||||
"provider_api_format": "openai:chat",
|
||||
});
|
||||
let normalized = normalize_provider_private_response_value(
|
||||
json!({
|
||||
"responseId": "ws-1",
|
||||
"response": {"text": "hello from cascade"}
|
||||
}),
|
||||
&report_context,
|
||||
)
|
||||
.expect("windsurf response should normalize");
|
||||
|
||||
assert_eq!(normalized["id"], json!("ws-1"));
|
||||
assert_eq!(
|
||||
normalized["choices"][0]["message"]["content"],
|
||||
json!("hello from cascade")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwraps_windsurf_stream_text_event() {
|
||||
let report_context = json!({
|
||||
"has_envelope": true,
|
||||
"envelope_name": "windsurf:GetChatMessage",
|
||||
"provider_api_format": "openai:chat",
|
||||
});
|
||||
let output = transform_provider_private_stream_line(
|
||||
&report_context,
|
||||
br#"data: {"responseId":"ws-2","response":{"text":"chunk"}}"#.to_vec(),
|
||||
)
|
||||
.expect("windsurf stream line should transform");
|
||||
let text = String::from_utf8(output).expect("utf8");
|
||||
|
||||
assert!(text.contains(r#""object":"chat.completion.chunk""#));
|
||||
assert!(text.contains(r#""content":"chunk""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwraps_windsurf_connect_json_stream_frames() {
|
||||
let report_context = json!({
|
||||
"has_envelope": true,
|
||||
"envelope_name": "windsurf:GetChatMessage",
|
||||
"provider_api_format": "openai:chat",
|
||||
});
|
||||
let mut normalizer = maybe_build_provider_private_stream_normalizer(Some(&report_context))
|
||||
.expect("normalizer should exist");
|
||||
let mut framed = connect_json_frame(
|
||||
0,
|
||||
br#"{"responseId":"ws-3","response":{"text":"frame chunk"}}"#,
|
||||
);
|
||||
framed.extend(connect_json_frame(2, b"{}"));
|
||||
|
||||
let mut output = normalizer
|
||||
.push_chunk(&framed)
|
||||
.expect("connect frame should normalize");
|
||||
output.extend(normalizer.finish().expect("finish should succeed"));
|
||||
let text = String::from_utf8(output).expect("utf8");
|
||||
|
||||
assert!(text.contains(r#""object":"chat.completion.chunk""#));
|
||||
assert!(text.contains(r#""content":"frame chunk""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_windsurf_connect_json_trailer_error_frame() {
|
||||
let framed = connect_json_frame(
|
||||
2,
|
||||
br#"{"error":{"code":"resource_exhausted","message":"quota exhausted"}}"#,
|
||||
);
|
||||
|
||||
assert!(stream_body_contains_error_event(&framed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_connect_json_trailer_error_without_report_context() {
|
||||
let framed = connect_json_frame(
|
||||
2,
|
||||
br#"{"error":{"code":"resource_exhausted","message":"quota exhausted"}}"#,
|
||||
);
|
||||
|
||||
let body = extract_provider_private_stream_error_body(None, &framed)
|
||||
.expect("Connect trailer error should decode without report context");
|
||||
|
||||
assert_eq!(body["error"]["code"], json!("resource_exhausted"));
|
||||
assert_eq!(body["error"]["message"], json!("quota exhausted"));
|
||||
}
|
||||
|
||||
fn connect_json_frame(flags: u8, payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(5 + payload.len());
|
||||
out.push(flags);
|
||||
out.extend_from_slice(&(payload.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(payload);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_stream_normalizer_unwraps_antigravity_stream() {
|
||||
let report_context = json!({
|
||||
@@ -526,4 +1005,39 @@ data: {"message":"bad"}
|
||||
"#;
|
||||
assert!(stream_body_contains_error_event(body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_sync_error_message_is_not_normalized_as_success() {
|
||||
let report_context = json!({
|
||||
"has_envelope": true,
|
||||
"envelope_name": "windsurf:GetChatMessage",
|
||||
"provider_api_format": "openai:chat",
|
||||
});
|
||||
|
||||
let normalized = normalize_provider_private_response_value(
|
||||
json!({"message": "rate limited"}),
|
||||
&report_context,
|
||||
);
|
||||
|
||||
assert!(normalized.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_stream_error_event_is_preserved() {
|
||||
let report_context = json!({
|
||||
"has_envelope": true,
|
||||
"envelope_name": "windsurf:GetChatMessage",
|
||||
"provider_api_format": "openai:chat",
|
||||
});
|
||||
let mut normalizer = maybe_build_provider_private_stream_normalizer(Some(&report_context))
|
||||
.expect("normalizer should exist");
|
||||
let output = normalizer
|
||||
.push_chunk(b"event: error\ndata: {\"message\":\"rate limited\"}\n\n")
|
||||
.expect("normalizer should preserve error event");
|
||||
let output_text = String::from_utf8(output).expect("utf8");
|
||||
|
||||
assert!(output_text.contains("event: error"));
|
||||
assert!(output_text.contains("\"message\":\"rate limited\""));
|
||||
assert!(!output_text.contains("chat.completion.chunk"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
pub const ANTIGRAVITY_PROVIDER_TYPE: &str = "antigravity";
|
||||
pub const KIRO_PROVIDER_TYPE: &str = "kiro";
|
||||
pub const WINDSURF_PROVIDER_TYPE: &str = "windsurf";
|
||||
pub const KIRO_ENVELOPE_NAME: &str = "kiro:generateAssistantResponse";
|
||||
pub const ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME: &str = "antigravity:v1internal";
|
||||
pub const GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME: &str = "gemini_cli:v1internal";
|
||||
pub const WINDSURF_ENVELOPE_NAME: &str = "windsurf:GetChatMessage";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProviderAdaptationSurface {
|
||||
@@ -10,6 +12,7 @@ pub enum ProviderAdaptationSurface {
|
||||
AntigravityGeminiCli,
|
||||
GeminiCliV1Internal,
|
||||
KiroClaudeCli,
|
||||
WindsurfCascade,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -70,6 +73,17 @@ const PROVIDER_ADAPTATION_SURFACES: &[ProviderAdaptationDescriptor] = &[
|
||||
requires_eventstream_accept: true,
|
||||
unwraps_response_envelope: false,
|
||||
},
|
||||
ProviderAdaptationDescriptor {
|
||||
surface: ProviderAdaptationSurface::WindsurfCascade,
|
||||
provider_type: Some(WINDSURF_PROVIDER_TYPE),
|
||||
envelope_name: WINDSURF_ENVELOPE_NAME,
|
||||
anchor_api_format: "openai:chat",
|
||||
supports_request_bridge: true,
|
||||
supports_sync_finalize_bridge: true,
|
||||
supports_stream_bridge: true,
|
||||
requires_eventstream_accept: false,
|
||||
unwraps_response_envelope: true,
|
||||
},
|
||||
];
|
||||
|
||||
pub fn provider_adaptation_descriptor_for_envelope(
|
||||
@@ -141,7 +155,7 @@ mod tests {
|
||||
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_anchor_api_format,
|
||||
provider_adaptation_requires_eventstream_accept,
|
||||
provider_adaptation_should_unwrap_stream_envelope, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
|
||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, KIRO_ENVELOPE_NAME,
|
||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, KIRO_ENVELOPE_NAME, WINDSURF_ENVELOPE_NAME,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -164,6 +178,10 @@ mod tests {
|
||||
provider_adaptation_anchor_api_format(KIRO_ENVELOPE_NAME, "claude:messages"),
|
||||
Some("claude:messages")
|
||||
);
|
||||
assert_eq!(
|
||||
provider_adaptation_anchor_api_format(WINDSURF_ENVELOPE_NAME, "openai:chat"),
|
||||
Some("openai:chat")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -184,5 +202,9 @@ mod tests {
|
||||
Some(ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME),
|
||||
"gemini:generate_content"
|
||||
));
|
||||
assert!(provider_adaptation_should_unwrap_stream_envelope(
|
||||
WINDSURF_ENVELOPE_NAME,
|
||||
"openai:chat"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,6 +323,10 @@ fn key_auth_channel_matches(row: &StoredMinimalCandidateSelectionRow, api_format
|
||||
"openai:chat" | "openai:responses" | "claude:messages" | "openai:image"
|
||||
)
|
||||
}
|
||||
"windsurf" => {
|
||||
matches!(auth_type.as_str(), "oauth" | "api_key" | "bearer")
|
||||
&& api_format == "openai:chat"
|
||||
}
|
||||
"vertex_ai" => {
|
||||
(auth_type == "api_key"
|
||||
&& matches!(
|
||||
@@ -557,6 +561,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allows_windsurf_managed_keys_for_openai_chat_only() {
|
||||
let mut oauth = sample_row("windsurf-oauth", "openai:chat", "gpt-5", 10);
|
||||
oauth.provider_type = "windsurf".to_string();
|
||||
oauth.key_auth_type = "oauth".to_string();
|
||||
let mut api_key = sample_row("windsurf-api-key", "openai:chat", "gpt-5", 20);
|
||||
api_key.provider_type = "windsurf".to_string();
|
||||
api_key.key_auth_type = "api_key".to_string();
|
||||
let mut responses = sample_row("windsurf-responses", "openai:responses", "gpt-5", 30);
|
||||
responses.provider_type = "windsurf".to_string();
|
||||
responses.key_auth_type = "oauth".to_string();
|
||||
|
||||
let repository =
|
||||
InMemoryMinimalCandidateSelectionReadRepository::seed(vec![oauth, api_key, responses]);
|
||||
|
||||
let rows = repository
|
||||
.list_for_exact_api_format_and_requested_model("openai:chat", "gpt-5")
|
||||
.await
|
||||
.expect("list should succeed");
|
||||
|
||||
assert_eq!(
|
||||
rows.iter()
|
||||
.map(|row| row.provider_id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["windsurf-oauth", "windsurf-api-key"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filters_by_exact_api_format_only() {
|
||||
let repository = InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
|
||||
|
||||
@@ -452,6 +452,10 @@ fn key_auth_channel_matches(row: &CandidateSelectionRow, api_format: &str) -> bo
|
||||
"openai:chat" | "openai:responses" | "claude:messages" | "openai:image"
|
||||
)
|
||||
}
|
||||
"windsurf" => {
|
||||
matches!(auth_type.as_str(), "oauth" | "api_key" | "bearer")
|
||||
&& api_format == "openai:chat"
|
||||
}
|
||||
"vertex_ai" => {
|
||||
(auth_type == "api_key"
|
||||
&& matches!(
|
||||
|
||||
@@ -113,6 +113,11 @@ WHERE p.is_active = TRUE
|
||||
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
AND LOWER($3) = 'gemini:generate_content'
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'windsurf'
|
||||
AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'api_key', 'bearer')
|
||||
AND LOWER($3) = 'openai:chat'
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'vertex_ai'
|
||||
AND (
|
||||
@@ -135,7 +140,8 @@ WHERE p.is_active = TRUE
|
||||
'grok',
|
||||
'vertex_ai',
|
||||
'antigravity',
|
||||
'kiro'
|
||||
'kiro',
|
||||
'windsurf'
|
||||
)
|
||||
AND LOWER(BTRIM(pak.auth_type)) <> 'oauth'
|
||||
)
|
||||
@@ -302,6 +308,11 @@ WHERE p.is_active = TRUE
|
||||
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
AND LOWER($4) = 'gemini:generate_content'
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'windsurf'
|
||||
AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'api_key', 'bearer')
|
||||
AND LOWER($4) = 'openai:chat'
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'vertex_ai'
|
||||
AND (
|
||||
@@ -324,7 +335,8 @@ WHERE p.is_active = TRUE
|
||||
'grok',
|
||||
'vertex_ai',
|
||||
'antigravity',
|
||||
'kiro'
|
||||
'kiro',
|
||||
'windsurf'
|
||||
)
|
||||
AND LOWER(BTRIM(pak.auth_type)) <> 'oauth'
|
||||
)
|
||||
@@ -490,6 +502,11 @@ WHERE p.is_active = TRUE
|
||||
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
AND LOWER($6) = 'gemini:generate_content'
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'windsurf'
|
||||
AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'api_key', 'bearer')
|
||||
AND LOWER($6) = 'openai:chat'
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'vertex_ai'
|
||||
AND (
|
||||
@@ -512,7 +529,8 @@ WHERE p.is_active = TRUE
|
||||
'grok',
|
||||
'vertex_ai',
|
||||
'antigravity',
|
||||
'kiro'
|
||||
'kiro',
|
||||
'windsurf'
|
||||
)
|
||||
AND LOWER(BTRIM(pak.auth_type)) <> 'oauth'
|
||||
)
|
||||
@@ -1322,6 +1340,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_selection_sql_allows_windsurf_openai_chat_managed_keys() {
|
||||
let requested_model_sql = requested_model_selection_sql();
|
||||
for sql in [
|
||||
LIST_FOR_EXACT_API_FORMAT_SQL,
|
||||
LIST_FOR_EXACT_API_FORMAT_AND_GLOBAL_MODEL_SQL,
|
||||
LIST_POOL_KEYS_FOR_GROUP_SQL,
|
||||
requested_model_sql.as_str(),
|
||||
] {
|
||||
assert!(sql.contains("LOWER(BTRIM(p.provider_type)) = 'windsurf'"));
|
||||
assert!(
|
||||
sql.contains("LOWER($3) = 'openai:chat'")
|
||||
|| sql.contains("LOWER($4) = 'openai:chat'")
|
||||
|| sql.contains("LOWER($6) = 'openai:chat'")
|
||||
);
|
||||
assert!(sql.contains("LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'api_key', 'bearer')"));
|
||||
assert!(sql.contains("'windsurf'"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_selection_sql_allows_vertex_embedding_auth() {
|
||||
let requested_model_sql = requested_model_selection_sql();
|
||||
|
||||
@@ -831,6 +831,10 @@ fn key_auth_channel_matches(row: &CandidateSelectionRow, api_format: &str) -> bo
|
||||
"openai:chat" | "openai:responses" | "claude:messages" | "openai:image"
|
||||
)
|
||||
}
|
||||
"windsurf" => {
|
||||
matches!(auth_type.as_str(), "oauth" | "api_key" | "bearer")
|
||||
&& api_format == "openai:chat"
|
||||
}
|
||||
"vertex_ai" => {
|
||||
(auth_type == "api_key"
|
||||
&& matches!(
|
||||
|
||||
@@ -14,7 +14,8 @@ pub use logic::{
|
||||
aggregate_models_for_cache, apply_model_filters, build_models_fetch_url,
|
||||
endpoint_supports_rust_models_fetch, extract_error_message, json_string_list,
|
||||
merge_upstream_metadata, parse_models_response, parse_models_response_page,
|
||||
preset_models_for_provider, provider_type_uses_preset_models, select_models_fetch_endpoint,
|
||||
parse_windsurf_model_configs_response, preset_models_for_provider,
|
||||
provider_type_uses_preset_models, select_models_fetch_endpoint,
|
||||
selected_models_fetch_endpoints, ModelFetchRunSummary, ModelsFetchPage, ModelsFetchSuccess,
|
||||
};
|
||||
pub use strategy::{
|
||||
@@ -25,5 +26,5 @@ pub use transport::{
|
||||
build_antigravity_fetch_available_models_plan, build_gemini_cli_load_code_assist_plan,
|
||||
build_kiro_list_available_models_plan, build_models_fetch_execution_plan,
|
||||
build_standard_models_fetch_execution_plan, build_vertex_models_fetch_execution_plan,
|
||||
ModelFetchTransportRuntime,
|
||||
build_windsurf_model_configs_execution_plan, ModelFetchTransportRuntime,
|
||||
};
|
||||
|
||||
@@ -166,6 +166,107 @@ pub fn parse_models_response_page(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse_windsurf_model_configs_response(
|
||||
body: &Value,
|
||||
updated_at_unix_secs: u64,
|
||||
) -> Result<(ModelsFetchSuccess, Value), String> {
|
||||
let configs = body
|
||||
.get("clientModelConfigs")
|
||||
.or_else(|| body.get("client_model_configs"))
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| {
|
||||
"windsurf model configs response is missing clientModelConfigs".to_string()
|
||||
})?;
|
||||
|
||||
let mut cached_models = Vec::new();
|
||||
let mut metadata_models = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
for config in configs {
|
||||
let Some(model_id) =
|
||||
windsurf_model_config_string(config, &["modelUid", "model_uid", "id", "name"])
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !seen.insert(model_id.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let label = windsurf_model_config_string(config, &["label", "displayName", "display_name"]);
|
||||
let provider = windsurf_model_config_string(config, &["provider"]);
|
||||
let supports_images = config
|
||||
.get("supportsImages")
|
||||
.or_else(|| config.get("supports_images"))
|
||||
.and_then(windsurf_json_bool);
|
||||
let credit_multiplier = config
|
||||
.get("creditMultiplier")
|
||||
.or_else(|| config.get("credit_multiplier"))
|
||||
.and_then(windsurf_json_f64);
|
||||
|
||||
let mut model = serde_json::Map::new();
|
||||
model.insert("id".to_string(), json!(model_id.clone()));
|
||||
model.insert("object".to_string(), json!("model"));
|
||||
model.insert("model_uid".to_string(), json!(model_id.clone()));
|
||||
model.insert(
|
||||
"display_name".to_string(),
|
||||
json!(label.as_deref().unwrap_or(model_id.as_str())),
|
||||
);
|
||||
model.insert(
|
||||
"owned_by".to_string(),
|
||||
json!(provider.as_deref().unwrap_or("windsurf")),
|
||||
);
|
||||
model.insert(
|
||||
"api_formats".to_string(),
|
||||
json!(["openai:chat", "openai:responses", "claude:messages"]),
|
||||
);
|
||||
if let Some(supports_images) = supports_images {
|
||||
model.insert("supports_images".to_string(), json!(supports_images));
|
||||
}
|
||||
if let Some(credit_multiplier) = credit_multiplier {
|
||||
model.insert("credit_multiplier".to_string(), json!(credit_multiplier));
|
||||
}
|
||||
cached_models.push(Value::Object(model));
|
||||
|
||||
let mut metadata_model = serde_json::Map::new();
|
||||
metadata_model.insert("model_uid".to_string(), json!(model_id));
|
||||
if let Some(label) = label {
|
||||
metadata_model.insert("label".to_string(), json!(label));
|
||||
}
|
||||
if let Some(provider) = provider {
|
||||
metadata_model.insert("provider".to_string(), json!(provider));
|
||||
}
|
||||
if let Some(supports_images) = supports_images {
|
||||
metadata_model.insert("supports_images".to_string(), json!(supports_images));
|
||||
}
|
||||
if let Some(credit_multiplier) = credit_multiplier {
|
||||
metadata_model.insert("credit_multiplier".to_string(), json!(credit_multiplier));
|
||||
}
|
||||
metadata_models.push(Value::Object(metadata_model));
|
||||
}
|
||||
|
||||
let mut windsurf_metadata = serde_json::Map::new();
|
||||
windsurf_metadata.insert("updated_at".to_string(), json!(updated_at_unix_secs));
|
||||
windsurf_metadata.insert(
|
||||
"allowed_models_count".to_string(),
|
||||
json!(metadata_models.len() as u64),
|
||||
);
|
||||
windsurf_metadata.insert("models".to_string(), Value::Array(metadata_models));
|
||||
if let Some(default_model_uid) = body
|
||||
.get("defaultOverrideModelConfig")
|
||||
.or_else(|| body.get("default_override_model_config"))
|
||||
.and_then(|config| windsurf_model_config_string(config, &["modelUid", "model_uid"]))
|
||||
{
|
||||
windsurf_metadata.insert("default_model_uid".to_string(), json!(default_model_uid));
|
||||
}
|
||||
|
||||
Ok((
|
||||
ModelsFetchSuccess {
|
||||
fetched_model_ids: collect_cached_model_ids(&cached_models),
|
||||
cached_models,
|
||||
},
|
||||
json!({ "windsurf": windsurf_metadata }),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn selected_models_fetch_endpoints(
|
||||
endpoints: &[StoredProviderCatalogEndpoint],
|
||||
key: &StoredProviderCatalogKey,
|
||||
@@ -395,6 +496,31 @@ pub fn json_string_list(value: Option<&Value>) -> Vec<String> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn api_format_priority(api_format: &str) -> Option<(usize, usize)> {
|
||||
MODEL_FETCH_FORMAT_PRIORITY
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(group_index, group)| {
|
||||
group
|
||||
.iter()
|
||||
.position(|candidate| candidate.eq_ignore_ascii_case(api_format))
|
||||
.map(|format_index| (group_index, format_index))
|
||||
})
|
||||
}
|
||||
|
||||
fn sorted_api_formats(formats: BTreeSet<String>) -> Vec<String> {
|
||||
let mut formats = formats.into_iter().collect::<Vec<_>>();
|
||||
formats.sort_by(
|
||||
|left, right| match (api_format_priority(left), api_format_priority(right)) {
|
||||
(Some(left_priority), Some(right_priority)) => left_priority.cmp(&right_priority),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => left.cmp(right),
|
||||
},
|
||||
);
|
||||
formats
|
||||
}
|
||||
|
||||
pub fn aggregate_models_for_cache(models: &[Value]) -> Vec<Value> {
|
||||
let mut aggregated = BTreeMap::<String, serde_json::Map<String, Value>>::new();
|
||||
|
||||
@@ -456,7 +582,7 @@ pub fn aggregate_models_for_cache(models: &[Value]) -> Vec<Value> {
|
||||
if let Some(api_format) = legacy_api_format {
|
||||
merged_formats.insert(api_format);
|
||||
}
|
||||
let merged_formats = merged_formats
|
||||
let merged_formats = sorted_api_formats(merged_formats)
|
||||
.into_iter()
|
||||
.map(Value::String)
|
||||
.collect::<Vec<_>>();
|
||||
@@ -561,6 +687,53 @@ fn model_id_from_openai_like_item(item: &Value) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn windsurf_model_config_string(value: &Value, fields: &[&str]) -> Option<String> {
|
||||
fields.iter().find_map(|field| {
|
||||
value
|
||||
.get(*field)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
}
|
||||
|
||||
fn windsurf_json_bool(value: &Value) -> Option<bool> {
|
||||
match value {
|
||||
Value::Bool(value) => Some(*value),
|
||||
Value::String(text) => match text.trim().to_ascii_lowercase().as_str() {
|
||||
"true" | "1" => Some(true),
|
||||
"false" | "0" => Some(false),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn windsurf_json_f64(value: &Value) -> Option<f64> {
|
||||
match value {
|
||||
Value::Number(number) => number.as_f64(),
|
||||
Value::String(text) => text.trim().parse::<f64>().ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_cached_model_ids(models: &[Value]) -> Vec<String> {
|
||||
let mut ids = Vec::new();
|
||||
for model in models {
|
||||
let Some(model_id) = model
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
ids.push(model_id.to_string());
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
fn split_url_query(base_url: &str) -> (&str, Option<&str>) {
|
||||
let trimmed = base_url.trim();
|
||||
trimmed
|
||||
@@ -729,6 +902,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_models_for_cache_orders_api_formats_by_canonical_priority() {
|
||||
let aggregated = aggregate_models_for_cache(&[
|
||||
json!({"id":"claude-sonnet-4-6","api_formats":["claude:messages"]}),
|
||||
json!({"id":"claude-sonnet-4-6","api_formats":["openai:responses"]}),
|
||||
json!({"id":"claude-sonnet-4-6","api_formats":["openai:chat"]}),
|
||||
]);
|
||||
assert_eq!(aggregated.len(), 1);
|
||||
assert_eq!(
|
||||
aggregated[0]["api_formats"],
|
||||
json!(["openai:chat", "openai:responses", "claude:messages"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_models_for_cache_preserves_legacy_api_format_field() {
|
||||
let aggregated = aggregate_models_for_cache(&[json!({
|
||||
|
||||
@@ -16,11 +16,15 @@ use rsa::RsaPrivateKey;
|
||||
use serde_json::{json, Value};
|
||||
use sha2::Sha256;
|
||||
|
||||
use crate::logic::{extract_error_message, parse_models_response_page, preset_models_for_provider};
|
||||
use crate::logic::{
|
||||
extract_error_message, parse_models_response_page, parse_windsurf_model_configs_response,
|
||||
preset_models_for_provider,
|
||||
};
|
||||
use crate::transport::{
|
||||
build_antigravity_fetch_available_models_plan, build_gemini_cli_load_code_assist_plan,
|
||||
build_kiro_list_available_models_plan, build_standard_models_fetch_execution_plan,
|
||||
build_vertex_models_fetch_execution_plan, ModelFetchTransportRuntime,
|
||||
build_vertex_models_fetch_execution_plan, build_windsurf_model_configs_execution_plan,
|
||||
ModelFetchTransportRuntime,
|
||||
};
|
||||
|
||||
const ANTIGRAVITY_SANDBOX_BASE_URL: &str = "https://daily-cloudcode-pa.sandbox.googleapis.com";
|
||||
@@ -51,6 +55,7 @@ pub enum ModelFetchStrategyKind {
|
||||
Antigravity,
|
||||
GeminiCliPreset,
|
||||
Kiro,
|
||||
Windsurf,
|
||||
}
|
||||
|
||||
pub trait ModelFetchStrategy {
|
||||
@@ -136,6 +141,7 @@ fn select_model_fetch_strategy(
|
||||
let kind = match provider_type.as_str() {
|
||||
"antigravity" => ModelFetchStrategyKind::Antigravity,
|
||||
"vertex_ai" => ModelFetchStrategyKind::Vertex,
|
||||
"windsurf" => ModelFetchStrategyKind::Windsurf,
|
||||
_ => ModelFetchStrategyKind::StandardTransport,
|
||||
};
|
||||
Ok(SelectedModelFetchStrategy {
|
||||
@@ -176,6 +182,7 @@ async fn execute_model_fetch_strategy(
|
||||
.await
|
||||
}
|
||||
ModelFetchStrategyKind::Kiro => fetch_kiro_models(runtime, first_transport).await,
|
||||
ModelFetchStrategyKind::Windsurf => fetch_windsurf_models(runtime, first_transport).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,6 +373,25 @@ async fn fetch_kiro_models(
|
||||
Ok(build_success_outcome(models, metadata, true))
|
||||
}
|
||||
|
||||
async fn fetch_windsurf_models(
|
||||
runtime: &(impl ModelFetchTransportRuntime + ?Sized),
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Result<ModelsFetchOutcome, String> {
|
||||
let plan = build_windsurf_model_configs_execution_plan(runtime, transport).await?;
|
||||
let result = runtime.execute_model_fetch_execution_plan(&plan).await?;
|
||||
if !(200..300).contains(&result.status_code) {
|
||||
return Err(execution_result_error_message(&result));
|
||||
}
|
||||
|
||||
let body_json = execution_result_json_body_allow_empty(&result)?;
|
||||
let (models, metadata) = parse_windsurf_model_configs_response(&body_json, now_unix_secs())?;
|
||||
Ok(build_success_outcome(
|
||||
models.cached_models,
|
||||
Some(metadata),
|
||||
true,
|
||||
))
|
||||
}
|
||||
|
||||
async fn fetch_vertex_models(
|
||||
runtime: &(impl ModelFetchTransportRuntime + ?Sized),
|
||||
transports: &[GatewayProviderTransportSnapshot],
|
||||
@@ -1413,6 +1439,22 @@ mod tests {
|
||||
transport
|
||||
}
|
||||
|
||||
fn sample_windsurf_transport() -> GatewayProviderTransportSnapshot {
|
||||
let mut transport = sample_custom_aiplatform_transport();
|
||||
transport.provider.provider_type = "windsurf".to_string();
|
||||
transport.provider.name = "Windsurf".to_string();
|
||||
transport.endpoint.api_format = "openai:chat".to_string();
|
||||
transport.endpoint.api_family = Some("openai".to_string());
|
||||
transport.endpoint.endpoint_kind = Some("chat".to_string());
|
||||
transport.endpoint.base_url = "https://server.codeium.com".to_string();
|
||||
transport.endpoint.custom_path = None;
|
||||
transport.key.auth_type = "oauth".to_string();
|
||||
transport.key.api_formats = Some(vec!["openai:chat".to_string()]);
|
||||
transport.key.decrypted_api_key = "devin-session-token$abc".to_string();
|
||||
transport.key.decrypted_auth_config = Some(r#"{"provider_type":"windsurf"}"#.to_string());
|
||||
transport
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strategy_selection_keeps_codex_on_standard_transport_fetch() {
|
||||
let strategy = select_model_fetch_strategy(&[sample_codex_transport()])
|
||||
@@ -1443,6 +1485,15 @@ mod tests {
|
||||
assert_eq!(strategy.kind(), ModelFetchStrategyKind::Kiro);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strategy_selection_uses_windsurf_model_configs_fetch() {
|
||||
let strategy = select_model_fetch_strategy(&[sample_windsurf_transport()])
|
||||
.expect("strategy should select");
|
||||
|
||||
assert_eq!(strategy.provider_id(), "windsurf");
|
||||
assert_eq!(strategy.kind(), ModelFetchStrategyKind::Windsurf);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_aiplatform_transport_uses_vertex_models_fetch_path_and_normalizes_chat_format()
|
||||
{
|
||||
@@ -1629,4 +1680,58 @@ mod tests {
|
||||
Some(&json!("auto"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn windsurf_transport_fetches_cascade_model_configs() {
|
||||
let executed_urls = Arc::new(Mutex::new(Vec::new()));
|
||||
let runtime = TestRuntime {
|
||||
executed_urls: Arc::clone(&executed_urls),
|
||||
response_body: json!({
|
||||
"clientModelConfigs": [
|
||||
{
|
||||
"modelUid": "claude-sonnet-4-6",
|
||||
"label": "Claude Sonnet 4.6",
|
||||
"provider": "anthropic",
|
||||
"supportsImages": true,
|
||||
"creditMultiplier": 4
|
||||
},
|
||||
{
|
||||
"modelUid": "gpt-5.4",
|
||||
"label": "GPT-5.4",
|
||||
"provider": "openai"
|
||||
}
|
||||
],
|
||||
"defaultOverrideModelConfig": {
|
||||
"modelUid": "claude-sonnet-4-6"
|
||||
}
|
||||
}),
|
||||
status_code: 200,
|
||||
};
|
||||
let outcome = fetch_models_from_transports(&runtime, &[sample_windsurf_transport()])
|
||||
.await
|
||||
.expect("models fetch should succeed");
|
||||
|
||||
let urls = executed_urls.lock().expect("executed_urls lock");
|
||||
assert_eq!(
|
||||
urls.as_slice(),
|
||||
&["https://server.codeium.com/exa.api_server_pb.ApiServerService/GetCascadeModelConfigs"]
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.fetched_model_ids,
|
||||
vec!["claude-sonnet-4-6".to_string(), "gpt-5.4".to_string()]
|
||||
);
|
||||
assert_eq!(outcome.cached_models.len(), 2);
|
||||
assert_eq!(
|
||||
outcome.cached_models[0]["api_formats"],
|
||||
json!(["openai:chat", "openai:responses", "claude:messages"])
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.upstream_metadata.as_ref().and_then(|value| {
|
||||
value
|
||||
.get("windsurf")
|
||||
.and_then(|value| value.get("allowed_models_count"))
|
||||
}),
|
||||
Some(&json!(2))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use aether_provider_transport::kiro::{
|
||||
resolve_local_kiro_request_auth,
|
||||
};
|
||||
use aether_provider_transport::vertex::resolve_local_vertex_api_key_query_auth;
|
||||
use aether_provider_transport::windsurf::resolve_windsurf_cascade_auth;
|
||||
use aether_provider_transport::{
|
||||
apply_local_header_rules, resolve_transport_execution_timeouts, resolve_transport_profile,
|
||||
GatewayProviderTransportSnapshot, LocalResolvedOAuthRequestAuth,
|
||||
@@ -30,6 +31,10 @@ const CLAUDE_VERSION_HEADER: &str = "2023-06-01";
|
||||
const ANTIGRAVITY_FETCH_PROVIDER_API_FORMAT: &str = "antigravity:fetch_available_models";
|
||||
const GEMINI_CLI_LOAD_CODE_ASSIST_PROVIDER_API_FORMAT: &str = "gemini_cli:load_code_assist";
|
||||
const KIRO_LIST_AVAILABLE_MODELS_PROVIDER_API_FORMAT: &str = "kiro:list_available_models";
|
||||
const WINDSURF_MODEL_CONFIGS_PROVIDER_API_FORMAT: &str = "windsurf:model_configs";
|
||||
const WINDSURF_MODEL_CONFIGS_PATH: &str =
|
||||
"/exa.api_server_pb.ApiServerService/GetCascadeModelConfigs";
|
||||
const WINDSURF_IDE_VERSION: &str = "1.9600.41";
|
||||
|
||||
const BROWSER_FINGERPRINT_HEADERS: &[(&str, &str)] = &[
|
||||
(
|
||||
@@ -295,6 +300,60 @@ pub async fn build_kiro_list_available_models_plan(
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn build_windsurf_model_configs_execution_plan(
|
||||
runtime: &(impl ModelFetchTransportRuntime + ?Sized),
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Result<ExecutionPlan, String> {
|
||||
let (_, auth_value) = resolve_windsurf_cascade_auth(transport)
|
||||
.or_else(|| resolve_local_openai_bearer_auth(transport))
|
||||
.ok_or_else(|| "Windsurf models fetch requires apiKey/sessionToken".to_string())?;
|
||||
let api_key = auth_secret_from_header_value(&auth_value);
|
||||
if api_key.is_empty() {
|
||||
return Err("Windsurf models fetch requires apiKey/sessionToken".to_string());
|
||||
}
|
||||
|
||||
let headers = BTreeMap::from([
|
||||
("content-type".to_string(), "application/json".to_string()),
|
||||
("accept".to_string(), "application/json".to_string()),
|
||||
("connect-protocol-version".to_string(), "1".to_string()),
|
||||
(
|
||||
"user-agent".to_string(),
|
||||
format!("windsurf/{WINDSURF_IDE_VERSION}"),
|
||||
),
|
||||
]);
|
||||
let headers = apply_fetch_header_rules(transport, headers, &[])?;
|
||||
let url = format!(
|
||||
"{}{}",
|
||||
transport.endpoint.base_url.trim_end_matches('/'),
|
||||
WINDSURF_MODEL_CONFIGS_PATH
|
||||
);
|
||||
|
||||
build_execution_plan(
|
||||
runtime,
|
||||
transport,
|
||||
ModelFetchExecutionPlanRequest {
|
||||
method: "POST".to_string(),
|
||||
url,
|
||||
headers,
|
||||
content_type: Some("application/json".to_string()),
|
||||
body: RequestBody::from_json(json!({
|
||||
"metadata": {
|
||||
"apiKey": api_key,
|
||||
"ideName": "windsurf",
|
||||
"ideVersion": WINDSURF_IDE_VERSION,
|
||||
"extensionName": "windsurf",
|
||||
"extensionVersion": WINDSURF_IDE_VERSION,
|
||||
"locale": "en",
|
||||
}
|
||||
})),
|
||||
client_api_format: "openai:chat".to_string(),
|
||||
provider_api_format: WINDSURF_MODEL_CONFIGS_PROVIDER_API_FORMAT.to_string(),
|
||||
model_name: Some("GetCascadeModelConfigs".to_string()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn build_vertex_models_fetch_execution_plan(
|
||||
runtime: &(impl ModelFetchTransportRuntime + ?Sized),
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
@@ -586,6 +645,16 @@ fn insert_non_empty_auth_header(
|
||||
headers.insert(name.to_string(), value.to_string());
|
||||
}
|
||||
|
||||
fn auth_secret_from_header_value(auth_value: &str) -> String {
|
||||
auth_value
|
||||
.trim()
|
||||
.strip_prefix("Bearer ")
|
||||
.or_else(|| auth_value.trim().strip_prefix("bearer "))
|
||||
.unwrap_or_else(|| auth_value.trim())
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use aether_contracts::{ExecutionPlan, ExecutionResult, ProxySnapshot};
|
||||
|
||||
@@ -2,6 +2,7 @@ mod antigravity;
|
||||
mod codex;
|
||||
mod generic;
|
||||
mod kiro;
|
||||
mod windsurf;
|
||||
|
||||
pub use antigravity::AntigravityProviderOAuthAdapter;
|
||||
pub use codex::CodexProviderOAuthAdapter;
|
||||
@@ -13,3 +14,7 @@ pub use kiro::{
|
||||
DEFAULT_KIRO_VERSION, DEFAULT_NODE_VERSION, DEFAULT_REGION, DEFAULT_SYSTEM_VERSION,
|
||||
KIRO_PROVIDER_TYPE,
|
||||
};
|
||||
pub use windsurf::{
|
||||
WindsurfProviderOAuthAdapter, WINDSURF_CLIENT_ID, WINDSURF_PROVIDER_TYPE,
|
||||
WINDSURF_SHOW_AUTH_TOKEN_REDIRECT, WINDSURF_SIGNIN_URL,
|
||||
};
|
||||
|
||||
1098
crates/aether-oauth/src/provider/providers/windsurf.rs
Normal file
1098
crates/aether-oauth/src/provider/providers/windsurf.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -19,13 +19,14 @@ impl ProviderOAuthService {
|
||||
pub fn with_builtin_adapters() -> Self {
|
||||
use super::providers::{
|
||||
AntigravityProviderOAuthAdapter, CodexProviderOAuthAdapter,
|
||||
GenericProviderOAuthAdapter, KiroProviderOAuthAdapter,
|
||||
GenericProviderOAuthAdapter, KiroProviderOAuthAdapter, WindsurfProviderOAuthAdapter,
|
||||
};
|
||||
|
||||
let mut service = Self::new()
|
||||
.with_adapter(Arc::new(KiroProviderOAuthAdapter::default()))
|
||||
.with_adapter(Arc::new(CodexProviderOAuthAdapter::default()))
|
||||
.with_adapter(Arc::new(AntigravityProviderOAuthAdapter::default()));
|
||||
.with_adapter(Arc::new(AntigravityProviderOAuthAdapter::default()))
|
||||
.with_adapter(Arc::new(WindsurfProviderOAuthAdapter));
|
||||
for provider_type in ["claude_code", "chatgpt_web", "gemini_cli"] {
|
||||
if let Some(adapter) = GenericProviderOAuthAdapter::for_provider_type(provider_type) {
|
||||
service = service.with_adapter(Arc::new(adapter));
|
||||
@@ -128,6 +129,7 @@ mod tests {
|
||||
"gemini_cli",
|
||||
"antigravity",
|
||||
"kiro",
|
||||
"windsurf",
|
||||
] {
|
||||
assert!(
|
||||
service.adapter(provider_type).is_ok(),
|
||||
|
||||
@@ -17,14 +17,19 @@ pub use provider::{ProviderPoolAdapter, ProviderPoolMemberInput};
|
||||
pub use providers::{
|
||||
build_antigravity_pool_quota_request, build_chatgpt_web_pool_quota_request,
|
||||
build_codex_pool_quota_request, build_kiro_pool_quota_request,
|
||||
enrich_chatgpt_web_quota_metadata, grok_mode_id_for_model, grok_pool_tier_from_quota_bucket,
|
||||
grok_quota_window_key_for_model, grok_supported_quota_windows_for_tier,
|
||||
normalize_chatgpt_web_image_quota_limit, AntigravityProviderPoolAdapter,
|
||||
ChatGptWebProviderPoolAdapter, CodexProviderPoolAdapter, DefaultProviderPoolAdapter,
|
||||
GrokProviderPoolAdapter, KiroPoolQuotaAuthInput, KiroProviderPoolAdapter,
|
||||
UnsupportedQuotaProviderPoolAdapter, ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH,
|
||||
CHATGPT_WEB_CONVERSATION_INIT_PATH, CHATGPT_WEB_DEFAULT_BASE_URL, CODEX_WHAM_USAGE_URL,
|
||||
KIRO_USAGE_LIMITS_PATH, KIRO_USAGE_SDK_VERSION,
|
||||
build_windsurf_pool_model_configs_request,
|
||||
build_windsurf_pool_model_configs_request_with_base_url, build_windsurf_pool_quota_request,
|
||||
build_windsurf_pool_quota_request_with_base_url, build_windsurf_pool_rate_limit_request,
|
||||
build_windsurf_pool_rate_limit_request_with_base_url, enrich_chatgpt_web_quota_metadata,
|
||||
grok_mode_id_for_model, grok_pool_tier_from_quota_bucket, grok_quota_window_key_for_model,
|
||||
grok_supported_quota_windows_for_tier, normalize_chatgpt_web_image_quota_limit,
|
||||
AntigravityProviderPoolAdapter, ChatGptWebProviderPoolAdapter, CodexProviderPoolAdapter,
|
||||
DefaultProviderPoolAdapter, GrokProviderPoolAdapter, KiroPoolQuotaAuthInput,
|
||||
KiroProviderPoolAdapter, UnsupportedQuotaProviderPoolAdapter,
|
||||
ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH, CHATGPT_WEB_CONVERSATION_INIT_PATH,
|
||||
CHATGPT_WEB_DEFAULT_BASE_URL, CODEX_WHAM_USAGE_URL, KIRO_USAGE_LIMITS_PATH,
|
||||
KIRO_USAGE_SDK_VERSION, WINDSURF_MODEL_CONFIGS_PATH, WINDSURF_RATE_LIMIT_PATH,
|
||||
WINDSURF_USER_STATUS_PATH,
|
||||
};
|
||||
pub use quota::{
|
||||
provider_pool_key_account_quota_exhausted, provider_pool_key_scheduling_label,
|
||||
@@ -69,7 +74,8 @@ mod tests {
|
||||
"gemini_cli",
|
||||
"grok",
|
||||
"kiro",
|
||||
"vertex_ai"
|
||||
"vertex_ai",
|
||||
"windsurf"
|
||||
]
|
||||
);
|
||||
assert!(service
|
||||
@@ -85,11 +91,19 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
service.provider_types_for_capability(ProviderPoolCapability::QuotaRefresh),
|
||||
["antigravity", "chatgpt_web", "codex", "grok", "kiro"]
|
||||
[
|
||||
"antigravity",
|
||||
"chatgpt_web",
|
||||
"codex",
|
||||
"grok",
|
||||
"kiro",
|
||||
"windsurf"
|
||||
]
|
||||
);
|
||||
assert!(service.supports_quota_refresh("codex"));
|
||||
assert!(service.supports_quota_refresh("antigravity"));
|
||||
assert!(service.supports_quota_refresh("grok"));
|
||||
assert!(service.supports_quota_refresh("windsurf"));
|
||||
assert!(!service.supports_quota_refresh("gemini_cli"));
|
||||
assert_eq!(
|
||||
service.quota_refresh_unsupported_message("claude_code"),
|
||||
@@ -238,6 +252,110 @@ mod tests {
|
||||
assert_eq!(metadata["image_quota_used"], json!(33.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_quota_request_uses_user_status_connect_rpc() {
|
||||
let spec = build_windsurf_pool_quota_request("key-ws", "session-token-123");
|
||||
|
||||
assert_eq!(spec.request_id, "windsurf-quota:key-ws");
|
||||
assert_eq!(spec.method, "POST");
|
||||
assert_eq!(
|
||||
spec.url,
|
||||
format!("https://server.codeium.com{WINDSURF_USER_STATUS_PATH}")
|
||||
);
|
||||
assert_eq!(spec.content_type.as_deref(), Some("application/json"));
|
||||
assert_eq!(
|
||||
spec.headers
|
||||
.get("connect-protocol-version")
|
||||
.map(String::as_str),
|
||||
Some("1")
|
||||
);
|
||||
assert_eq!(
|
||||
spec.json_body
|
||||
.as_ref()
|
||||
.and_then(|body| body.pointer("/metadata/apiKey"))
|
||||
.and_then(Value::as_str),
|
||||
Some("session-token-123")
|
||||
);
|
||||
assert_eq!(spec.provider_api_format, "windsurf:user_status");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_model_and_rate_limit_requests_use_connect_rpc_metadata() {
|
||||
let models = build_windsurf_pool_model_configs_request("key-ws", "api-key-123");
|
||||
let rate_limit = build_windsurf_pool_rate_limit_request("key-ws", "api-key-123");
|
||||
|
||||
assert_eq!(
|
||||
models.url,
|
||||
format!("https://server.codeium.com{WINDSURF_MODEL_CONFIGS_PATH}")
|
||||
);
|
||||
assert_eq!(
|
||||
rate_limit.url,
|
||||
format!("https://server.codeium.com{WINDSURF_RATE_LIMIT_PATH}")
|
||||
);
|
||||
for spec in [models, rate_limit] {
|
||||
assert_eq!(spec.method, "POST");
|
||||
assert_eq!(
|
||||
spec.headers
|
||||
.get("connect-protocol-version")
|
||||
.map(String::as_str),
|
||||
Some("1")
|
||||
);
|
||||
assert_eq!(
|
||||
spec.json_body
|
||||
.as_ref()
|
||||
.and_then(|body| body.pointer("/metadata/apiKey"))
|
||||
.and_then(Value::as_str),
|
||||
Some("api-key-123")
|
||||
);
|
||||
assert_eq!(spec.client_api_format, "openai:chat");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_rate_limit_metadata_keeps_member_schedulable() {
|
||||
let service = ProviderPoolService::with_builtin_adapters();
|
||||
let key = sample_key(Some(json!({
|
||||
"windsurf": {
|
||||
"updated_at": 1_700_000_000u64,
|
||||
"rate_limit": {
|
||||
"limited": true,
|
||||
"retry_after_ms": 60_000
|
||||
}
|
||||
}
|
||||
})));
|
||||
|
||||
let signals = service.member_signals("windsurf", &key, None);
|
||||
|
||||
assert!(!signals.quota_exhausted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_status_snapshot_ban_marks_member_exhausted() {
|
||||
let service = ProviderPoolService::with_builtin_adapters();
|
||||
let mut key = sample_key(Some(json!({
|
||||
"windsurf": {
|
||||
"updated_at": 1_700_000_000u64,
|
||||
"daily_remaining_percent": 100.0
|
||||
}
|
||||
})));
|
||||
key.status_snapshot = Some(json!({
|
||||
"quota": {
|
||||
"provider_type": "windsurf",
|
||||
"code": "banned",
|
||||
"exhausted": false,
|
||||
"windows": [{
|
||||
"code": "daily",
|
||||
"used_ratio": 0.0,
|
||||
"remaining_ratio": 1.0
|
||||
}]
|
||||
}
|
||||
}));
|
||||
|
||||
let signals = service.member_signals("windsurf", &key, None);
|
||||
|
||||
assert!(signals.quota_exhausted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_payload_derives_provider_support_from_capabilities() {
|
||||
let payload = build_admin_pool_scheduling_presets_payload();
|
||||
@@ -251,10 +369,13 @@ mod tests {
|
||||
.find(|item| item["name"] == "recent_refresh")
|
||||
.expect("recent_refresh should exist");
|
||||
|
||||
assert_eq!(free_first["providers"], json!(["codex", "grok", "kiro"]));
|
||||
assert_eq!(
|
||||
free_first["providers"],
|
||||
json!(["codex", "grok", "kiro", "windsurf"])
|
||||
);
|
||||
assert_eq!(
|
||||
recent_refresh["providers"],
|
||||
json!(["codex", "grok", "kiro"])
|
||||
json!(["codex", "grok", "kiro", "windsurf"])
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod default;
|
||||
pub mod grok;
|
||||
pub mod kiro;
|
||||
pub mod unsupported;
|
||||
pub mod windsurf;
|
||||
|
||||
pub use antigravity::AntigravityProviderPoolAdapter;
|
||||
pub use antigravity::{
|
||||
@@ -32,3 +33,11 @@ pub use unsupported::{
|
||||
UnsupportedQuotaProviderPoolAdapter, CLAUDE_CODE_PROVIDER_POOL_ADAPTER,
|
||||
GEMINI_CLI_PROVIDER_POOL_ADAPTER, VERTEX_AI_PROVIDER_POOL_ADAPTER,
|
||||
};
|
||||
pub use windsurf::{
|
||||
build_windsurf_pool_model_configs_request,
|
||||
build_windsurf_pool_model_configs_request_with_base_url, build_windsurf_pool_quota_request,
|
||||
build_windsurf_pool_quota_request_with_base_url, build_windsurf_pool_rate_limit_request,
|
||||
build_windsurf_pool_rate_limit_request_with_base_url, WindsurfProviderPoolAdapter,
|
||||
WINDSURF_DEFAULT_BASE_URL, WINDSURF_MODEL_CONFIGS_PATH, WINDSURF_RATE_LIMIT_PATH,
|
||||
WINDSURF_USER_STATUS_PATH,
|
||||
};
|
||||
|
||||
286
crates/aether-provider-pool/src/providers/windsurf.rs
Normal file
286
crates/aether-provider-pool/src/providers/windsurf.rs
Normal file
@@ -0,0 +1,286 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
|
||||
};
|
||||
use aether_pool_core::PoolSchedulingPreset;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::capability::ProviderPoolCapabilities;
|
||||
use crate::provider::{
|
||||
provider_pool_endpoint_format_matches, provider_pool_matching_endpoint, ProviderPoolAdapter,
|
||||
ProviderPoolMemberInput,
|
||||
};
|
||||
use crate::quota::{
|
||||
provider_pool_json_bool, provider_pool_json_f64, provider_pool_member_quota_snapshot,
|
||||
provider_pool_metadata_bucket, provider_pool_quota_snapshot_exhausted_decision,
|
||||
};
|
||||
use crate::quota_refresh::ProviderPoolQuotaRequestSpec;
|
||||
|
||||
pub const WINDSURF_DEFAULT_BASE_URL: &str = "https://server.codeium.com";
|
||||
pub const WINDSURF_USER_STATUS_PATH: &str =
|
||||
"/exa.seat_management_pb.SeatManagementService/GetUserStatus";
|
||||
pub const WINDSURF_MODEL_CONFIGS_PATH: &str =
|
||||
"/exa.api_server_pb.ApiServerService/GetCascadeModelConfigs";
|
||||
pub const WINDSURF_RATE_LIMIT_PATH: &str =
|
||||
"/exa.api_server_pb.ApiServerService/CheckUserMessageRateLimit";
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct WindsurfProviderPoolAdapter;
|
||||
|
||||
impl ProviderPoolAdapter for WindsurfProviderPoolAdapter {
|
||||
fn provider_type(&self) -> &'static str {
|
||||
"windsurf"
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> ProviderPoolCapabilities {
|
||||
ProviderPoolCapabilities {
|
||||
plan_tier: true,
|
||||
quota_reset: true,
|
||||
quota_refresh: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn default_scheduling_presets(&self) -> Vec<PoolSchedulingPreset> {
|
||||
vec![PoolSchedulingPreset {
|
||||
preset: "recent_refresh".to_string(),
|
||||
enabled: true,
|
||||
mode: None,
|
||||
}]
|
||||
}
|
||||
|
||||
fn quota_exhausted(&self, input: &ProviderPoolMemberInput<'_>) -> bool {
|
||||
if windsurf_quota_snapshot_hard_exhausted(input.key, input.provider_type) {
|
||||
return true;
|
||||
}
|
||||
if let Some(exhausted) =
|
||||
provider_pool_quota_snapshot_exhausted_decision(input.key, input.provider_type)
|
||||
{
|
||||
return exhausted;
|
||||
}
|
||||
provider_pool_metadata_bucket(input.key.upstream_metadata.as_ref(), input.provider_type)
|
||||
.is_some_and(windsurf_quota_exhausted_from_bucket)
|
||||
}
|
||||
|
||||
fn quota_refresh_endpoint(
|
||||
&self,
|
||||
endpoints: &[StoredProviderCatalogEndpoint],
|
||||
include_inactive: bool,
|
||||
) -> Option<StoredProviderCatalogEndpoint> {
|
||||
provider_pool_matching_endpoint(endpoints, include_inactive, |endpoint| {
|
||||
provider_pool_endpoint_format_matches(endpoint, "openai:chat")
|
||||
})
|
||||
}
|
||||
|
||||
fn quota_refresh_missing_endpoint_message(&self) -> String {
|
||||
"找不到有效的 openai:chat 端点".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn windsurf_quota_snapshot_hard_exhausted(
|
||||
key: &StoredProviderCatalogKey,
|
||||
provider_type: &str,
|
||||
) -> bool {
|
||||
provider_pool_member_quota_snapshot(key, provider_type)
|
||||
.and_then(|quota| quota.get("code"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.map(str::to_ascii_lowercase)
|
||||
.is_some_and(|code| matches!(code.as_str(), "banned" | "forbidden" | "quarantined"))
|
||||
}
|
||||
|
||||
pub fn build_windsurf_pool_quota_request(
|
||||
key_id: &str,
|
||||
api_key: &str,
|
||||
) -> ProviderPoolQuotaRequestSpec {
|
||||
build_windsurf_pool_quota_request_with_base_url(key_id, WINDSURF_DEFAULT_BASE_URL, api_key)
|
||||
}
|
||||
|
||||
pub fn build_windsurf_pool_quota_request_with_base_url(
|
||||
key_id: &str,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
) -> ProviderPoolQuotaRequestSpec {
|
||||
build_windsurf_connect_rpc_request(
|
||||
format!("windsurf-quota:{key_id}"),
|
||||
"windsurf:user_status",
|
||||
"windsurf-user-status",
|
||||
base_url,
|
||||
WINDSURF_USER_STATUS_PATH,
|
||||
api_key,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_windsurf_pool_model_configs_request(
|
||||
key_id: &str,
|
||||
api_key: &str,
|
||||
) -> ProviderPoolQuotaRequestSpec {
|
||||
build_windsurf_pool_model_configs_request_with_base_url(
|
||||
key_id,
|
||||
WINDSURF_DEFAULT_BASE_URL,
|
||||
api_key,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_windsurf_pool_model_configs_request_with_base_url(
|
||||
key_id: &str,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
) -> ProviderPoolQuotaRequestSpec {
|
||||
build_windsurf_connect_rpc_request(
|
||||
format!("windsurf-models:{key_id}"),
|
||||
"windsurf:model_configs",
|
||||
"windsurf-model-configs",
|
||||
base_url,
|
||||
WINDSURF_MODEL_CONFIGS_PATH,
|
||||
api_key,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_windsurf_pool_rate_limit_request(
|
||||
key_id: &str,
|
||||
api_key: &str,
|
||||
) -> ProviderPoolQuotaRequestSpec {
|
||||
build_windsurf_pool_rate_limit_request_with_base_url(key_id, WINDSURF_DEFAULT_BASE_URL, api_key)
|
||||
}
|
||||
|
||||
pub fn build_windsurf_pool_rate_limit_request_with_base_url(
|
||||
key_id: &str,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
) -> ProviderPoolQuotaRequestSpec {
|
||||
build_windsurf_connect_rpc_request(
|
||||
format!("windsurf-rate-limit:{key_id}"),
|
||||
"windsurf:rate_limit",
|
||||
"windsurf-rate-limit",
|
||||
base_url,
|
||||
WINDSURF_RATE_LIMIT_PATH,
|
||||
api_key,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_windsurf_connect_rpc_request(
|
||||
request_id: String,
|
||||
provider_api_format: &str,
|
||||
model_name: &str,
|
||||
base_url: &str,
|
||||
path: &str,
|
||||
api_key: &str,
|
||||
) -> ProviderPoolQuotaRequestSpec {
|
||||
let mut headers = BTreeMap::new();
|
||||
headers.insert("content-type".to_string(), "application/json".to_string());
|
||||
headers.insert("accept".to_string(), "application/json".to_string());
|
||||
headers.insert("connect-protocol-version".to_string(), "1".to_string());
|
||||
headers.insert("user-agent".to_string(), "windsurf/1.9600.41".to_string());
|
||||
|
||||
ProviderPoolQuotaRequestSpec {
|
||||
request_id,
|
||||
provider_name: "windsurf".to_string(),
|
||||
quota_kind: "windsurf".to_string(),
|
||||
method: "POST".to_string(),
|
||||
url: format!("{}{}", base_url.trim_end_matches('/'), path),
|
||||
headers,
|
||||
content_type: Some("application/json".to_string()),
|
||||
json_body: Some(json!({
|
||||
"metadata": windsurf_metadata(api_key),
|
||||
})),
|
||||
client_api_format: "openai:chat".to_string(),
|
||||
provider_api_format: provider_api_format.to_string(),
|
||||
model_name: Some(model_name.to_string()),
|
||||
accept_invalid_certs: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn windsurf_metadata(api_key: &str) -> Value {
|
||||
json!({
|
||||
"apiKey": api_key,
|
||||
"ideName": "windsurf",
|
||||
"ideVersion": "1.9600.41",
|
||||
"extensionName": "windsurf",
|
||||
"extensionVersion": "1.9600.41",
|
||||
"locale": "en",
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn windsurf_quota_exhausted_from_bucket(bucket: &Map<String, Value>) -> bool {
|
||||
if provider_pool_json_bool(bucket.get("banned"))
|
||||
.or_else(|| provider_pool_json_bool(bucket.get("quarantined")))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let daily_remaining = provider_pool_json_f64(bucket.get("daily_remaining_percent"));
|
||||
let weekly_remaining = provider_pool_json_f64(bucket.get("weekly_remaining_percent"));
|
||||
daily_remaining.is_some_and(|value| value <= 0.0)
|
||||
|| weekly_remaining.is_some_and(|value| value <= 0.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{windsurf_quota_exhausted_from_bucket, windsurf_quota_snapshot_hard_exhausted};
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
||||
use serde_json::json;
|
||||
|
||||
fn sample_key_with_quota(code: &str, exhausted: bool) -> StoredProviderCatalogKey {
|
||||
let mut key = StoredProviderCatalogKey::new(
|
||||
"key-windsurf".to_string(),
|
||||
"provider-windsurf".to_string(),
|
||||
"windsurf@example.com".to_string(),
|
||||
"oauth".to_string(),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("sample key should build");
|
||||
key.status_snapshot = Some(json!({
|
||||
"quota": {
|
||||
"provider_type": "windsurf",
|
||||
"code": code,
|
||||
"exhausted": exhausted,
|
||||
"windows": [{
|
||||
"code": "daily",
|
||||
"used_ratio": 0.0,
|
||||
"remaining_ratio": 1.0
|
||||
}]
|
||||
}
|
||||
}));
|
||||
key
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_rate_limit_bucket_does_not_mark_quota_exhausted() {
|
||||
let bucket = json!({
|
||||
"rate_limit": {
|
||||
"limited": true,
|
||||
"retry_after_ms": 60_000u64
|
||||
},
|
||||
"daily_remaining_percent": 50.0,
|
||||
"weekly_remaining_percent": 50.0
|
||||
});
|
||||
let bucket = bucket.as_object().expect("bucket should be object");
|
||||
|
||||
assert!(!windsurf_quota_exhausted_from_bucket(bucket));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_banned_and_quarantined_snapshot_codes_are_hard_exhausted() {
|
||||
for code in ["banned", "forbidden", "quarantined"] {
|
||||
let key = sample_key_with_quota(code, false);
|
||||
|
||||
assert!(
|
||||
windsurf_quota_snapshot_hard_exhausted(&key, "windsurf"),
|
||||
"{code} should be hard exhausted"
|
||||
);
|
||||
}
|
||||
|
||||
let cooldown_key = sample_key_with_quota("cooldown", false);
|
||||
assert!(!windsurf_quota_snapshot_hard_exhausted(
|
||||
&cooldown_key,
|
||||
"windsurf"
|
||||
));
|
||||
let rate_limited_key = sample_key_with_quota("rate_limited", false);
|
||||
assert!(!windsurf_quota_snapshot_hard_exhausted(
|
||||
&rate_limited_key,
|
||||
"windsurf"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,8 @@ use crate::provider::{ProviderPoolAdapter, ProviderPoolMemberInput};
|
||||
use crate::providers::{
|
||||
AntigravityProviderPoolAdapter, ChatGptWebProviderPoolAdapter, CodexProviderPoolAdapter,
|
||||
DefaultProviderPoolAdapter, GrokProviderPoolAdapter, KiroProviderPoolAdapter,
|
||||
CLAUDE_CODE_PROVIDER_POOL_ADAPTER, GEMINI_CLI_PROVIDER_POOL_ADAPTER,
|
||||
VERTEX_AI_PROVIDER_POOL_ADAPTER,
|
||||
WindsurfProviderPoolAdapter, CLAUDE_CODE_PROVIDER_POOL_ADAPTER,
|
||||
GEMINI_CLI_PROVIDER_POOL_ADAPTER, VERTEX_AI_PROVIDER_POOL_ADAPTER,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -54,6 +54,7 @@ impl ProviderPoolService {
|
||||
.with_adapter(Arc::new(GrokProviderPoolAdapter))
|
||||
.with_adapter(Arc::new(KiroProviderPoolAdapter))
|
||||
.with_adapter(Arc::new(ChatGptWebProviderPoolAdapter))
|
||||
.with_adapter(Arc::new(WindsurfProviderPoolAdapter))
|
||||
.with_adapter(Arc::new(VERTEX_AI_PROVIDER_POOL_ADAPTER))
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,11 @@ use crate::vertex::{
|
||||
local_vertex_gemini_transport_unsupported_reason_with_network,
|
||||
resolve_local_vertex_api_key_query_auth, VERTEX_API_KEY_QUERY_PARAM,
|
||||
};
|
||||
use crate::windsurf::{
|
||||
is_windsurf_provider_transport,
|
||||
local_windsurf_request_transport_unsupported_reason_with_network,
|
||||
resolve_windsurf_cascade_auth,
|
||||
};
|
||||
use crate::GatewayProviderTransportSnapshot;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -122,6 +127,11 @@ pub fn request_conversion_transport_unsupported_reason(
|
||||
if is_kiro_claude_messages_transport(transport, &transport.endpoint.api_format) {
|
||||
return local_kiro_request_transport_unsupported_reason_with_network(transport);
|
||||
}
|
||||
if is_windsurf_provider_transport(transport)
|
||||
&& normalize_api_format_alias(&transport.endpoint.api_format) == "openai:chat"
|
||||
{
|
||||
return local_windsurf_request_transport_unsupported_reason_with_network(transport);
|
||||
}
|
||||
|
||||
match normalize_api_format_alias(&transport.endpoint.api_format).as_str() {
|
||||
"openai:chat" => local_openai_chat_transport_unsupported_reason(transport),
|
||||
@@ -202,6 +212,9 @@ fn request_direct_auth_for_provider_format(
|
||||
provider_api_format: &str,
|
||||
) -> Option<(String, String)> {
|
||||
match normalize_api_format_alias(provider_api_format).as_str() {
|
||||
"openai:chat" if is_windsurf_provider_transport(transport) => {
|
||||
resolve_windsurf_cascade_auth(transport)
|
||||
}
|
||||
"openai:chat"
|
||||
| "openai:responses"
|
||||
| "openai:responses:compact"
|
||||
@@ -623,6 +636,35 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_openai_chat_anchor_supports_cross_format_conversion_via_cascade() {
|
||||
let mut transport = transport_snapshot("windsurf", "openai:chat", "oauth", true, None);
|
||||
transport.key.decrypted_api_key = "devin-session-token$abc".to_string();
|
||||
transport.key.decrypted_auth_config = Some(r#"{"provider_type":"windsurf"}"#.to_string());
|
||||
|
||||
assert!(request_pair_allowed_for_transport(
|
||||
&transport,
|
||||
"claude:messages",
|
||||
"openai:chat"
|
||||
));
|
||||
assert!(request_pair_allowed_for_transport(
|
||||
&transport,
|
||||
"openai:responses",
|
||||
"openai:chat"
|
||||
));
|
||||
assert!(request_conversion_transport_supported(
|
||||
&transport,
|
||||
RequestConversionKind::ToOpenAIChat
|
||||
));
|
||||
assert_eq!(
|
||||
request_conversion_direct_auth(&transport, RequestConversionKind::ToOpenAIChat),
|
||||
Some((
|
||||
"authorization".to_string(),
|
||||
"Bearer devin-session-token$abc".to_string()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_common_transport_policy_checks_active_state_format_and_allowed_models() {
|
||||
let mut transport = transport_snapshot("custom", "openai:chat", "bearer", true, None);
|
||||
|
||||
@@ -24,6 +24,7 @@ mod standard;
|
||||
pub mod url;
|
||||
pub mod vertex;
|
||||
mod video;
|
||||
pub mod windsurf;
|
||||
|
||||
pub use aether_oauth as oauth;
|
||||
pub use auth::{build_passthrough_headers, ensure_upstream_auth_header};
|
||||
@@ -131,3 +132,9 @@ pub use video::{
|
||||
resolve_video_create_auth, video_create_transport_unsupported_reason,
|
||||
ProviderVideoCreateFamily, ProviderVideoCreateHeadersInput, VideoTaskTransportSnapshotLookup,
|
||||
};
|
||||
pub use windsurf::{
|
||||
build_windsurf_cascade_headers, build_windsurf_cascade_request_body,
|
||||
build_windsurf_cascade_upstream_url, is_windsurf_provider_transport,
|
||||
local_windsurf_request_transport_unsupported_reason_with_network, GET_CHAT_MESSAGE_PATH,
|
||||
WINDSURF_ENVELOPE_NAME,
|
||||
};
|
||||
|
||||
@@ -253,6 +253,17 @@ const GROK_RUNTIME_POLICY: ProviderRuntimePolicy = ProviderRuntimePolicy {
|
||||
..STANDARD_RUNTIME_POLICY
|
||||
};
|
||||
|
||||
const WINDSURF_RUNTIME_POLICY: ProviderRuntimePolicy = ProviderRuntimePolicy {
|
||||
fixed_provider: true,
|
||||
api_format_inheritance: ProviderApiFormatInheritance::OAuthOrBearer,
|
||||
enable_format_conversion_by_default: true,
|
||||
oauth_is_bearer_like: true,
|
||||
supports_model_fetch: false,
|
||||
supports_local_openai_chat_transport: false,
|
||||
supports_local_same_format_transport: false,
|
||||
..STANDARD_RUNTIME_POLICY
|
||||
};
|
||||
|
||||
const CLAUDE_CODE_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTemplate {
|
||||
provider_type: "claude_code",
|
||||
version: 1,
|
||||
@@ -405,6 +416,19 @@ const GROK_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTemplat
|
||||
runtime_policy: GROK_RUNTIME_POLICY,
|
||||
};
|
||||
|
||||
const WINDSURF_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTemplate {
|
||||
provider_type: "windsurf",
|
||||
version: 1,
|
||||
base_url: "https://server.codeium.com",
|
||||
endpoints: &[FixedProviderEndpointTemplate {
|
||||
item_key: "openai:chat",
|
||||
api_format: "openai:chat",
|
||||
custom_path: None,
|
||||
config_defaults: EMPTY_ENDPOINT_CONFIG_DEFAULTS,
|
||||
}],
|
||||
runtime_policy: WINDSURF_RUNTIME_POLICY,
|
||||
};
|
||||
|
||||
pub fn provider_type_is_fixed(provider_type: &str) -> bool {
|
||||
provider_runtime_policy(provider_type).fixed_provider
|
||||
}
|
||||
@@ -455,6 +479,7 @@ pub fn fixed_provider_template(provider_type: &str) -> Option<&'static FixedProv
|
||||
"gemini_cli" => Some(&GEMINI_CLI_FIXED_PROVIDER_TEMPLATE),
|
||||
"vertex_ai" => Some(&VERTEX_AI_FIXED_PROVIDER_TEMPLATE),
|
||||
"antigravity" => Some(&ANTIGRAVITY_FIXED_PROVIDER_TEMPLATE),
|
||||
"windsurf" => Some(&WINDSURF_FIXED_PROVIDER_TEMPLATE),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -565,6 +590,17 @@ pub fn provider_type_admin_oauth_template(provider_type: &str) -> Option<Provide
|
||||
redirect_uri: "http://localhost:51121/oauth2callback",
|
||||
use_pkce: true,
|
||||
}),
|
||||
"windsurf" => Some(ProviderOAuthTemplate {
|
||||
provider_type: "windsurf",
|
||||
display_name: "Windsurf",
|
||||
authorize_url: "https://windsurf.com/windsurf/signin",
|
||||
token_url: "https://register.windsurf.com/exa.seat_management_pb.SeatManagementService/RegisterUser",
|
||||
client_id: "3GUryQ7ldAeKEuD2obYnppsnmj58eP5u",
|
||||
client_secret: "",
|
||||
scopes: &[],
|
||||
redirect_uri: "show-auth-token",
|
||||
use_pkce: false,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -575,16 +611,18 @@ pub const ADMIN_PROVIDER_OAUTH_TEMPLATE_TYPES: &[&str] = &[
|
||||
"chatgpt_web",
|
||||
"gemini_cli",
|
||||
"antigravity",
|
||||
"windsurf",
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
fixed_provider_endpoint_template_by_api_format, fixed_provider_key_inherits_api_formats,
|
||||
fixed_provider_template, provider_runtime_policy,
|
||||
fixed_provider_template, provider_runtime_policy, provider_type_admin_oauth_template,
|
||||
provider_type_allows_auth_channel_mismatch_by_default, provider_type_oauth_is_bearer_like,
|
||||
provider_type_supports_local_embedding_transport,
|
||||
provider_type_supports_local_same_format_transport, FixedProviderEndpointConfigValue,
|
||||
ADMIN_PROVIDER_OAUTH_TEMPLATE_TYPES,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -675,6 +713,47 @@ mod tests {
|
||||
assert!(!template.runtime_policy.supports_local_same_format_transport);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_fixed_provider_template_exposes_openai_chat() {
|
||||
let template = fixed_provider_template("windsurf").expect("windsurf template should exist");
|
||||
assert_eq!(template.provider_type, "windsurf");
|
||||
assert_eq!(template.base_url, "https://server.codeium.com");
|
||||
assert_eq!(template.version, 1);
|
||||
assert_eq!(
|
||||
template
|
||||
.endpoints
|
||||
.iter()
|
||||
.map(|item| item.api_format)
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["openai:chat"]
|
||||
);
|
||||
assert!(
|
||||
fixed_provider_endpoint_template_by_api_format("windsurf", "openai:chat").is_some()
|
||||
);
|
||||
|
||||
let policy = provider_runtime_policy("windsurf");
|
||||
assert!(policy.fixed_provider);
|
||||
assert!(policy.enable_format_conversion_by_default);
|
||||
assert!(policy.oauth_is_bearer_like);
|
||||
assert!(!policy.supports_model_fetch);
|
||||
assert!(!policy.supports_local_same_format_transport);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_admin_oauth_template_is_advertised() {
|
||||
let template =
|
||||
provider_type_admin_oauth_template("windsurf").expect("windsurf oauth template");
|
||||
|
||||
assert_eq!(template.provider_type, "windsurf");
|
||||
assert_eq!(template.display_name, "Windsurf");
|
||||
assert_eq!(
|
||||
template.authorize_url,
|
||||
"https://windsurf.com/windsurf/signin"
|
||||
);
|
||||
assert_eq!(template.redirect_uri, "show-auth-token");
|
||||
assert!(ADMIN_PROVIDER_OAUTH_TEMPLATE_TYPES.contains(&"windsurf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_provider_key_inheritance_keeps_oauth_and_kiro_configured_bearer_keys_open() {
|
||||
assert!(fixed_provider_key_inherits_api_formats(
|
||||
|
||||
@@ -22,7 +22,6 @@ use crate::vertex::{
|
||||
build_vertex_service_account_gemini_embedding_url, resolve_local_vertex_api_key_query_auth,
|
||||
resolve_local_vertex_service_account_auth_config,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TransportRequestUrlParams<'a> {
|
||||
pub provider_api_format: &'a str,
|
||||
|
||||
581
crates/aether-provider-transport/src/windsurf.rs
Normal file
581
crates/aether-provider-transport/src/windsurf.rs
Normal file
@@ -0,0 +1,581 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::rules::{
|
||||
apply_local_body_rules_with_request_headers, apply_local_header_rules_with_request_headers,
|
||||
body_rules_are_locally_supported, header_rules_are_locally_supported,
|
||||
};
|
||||
use crate::snapshot::GatewayProviderTransportSnapshot;
|
||||
use crate::url::build_passthrough_path_url;
|
||||
use crate::{
|
||||
resolve_transport_profile, should_skip_upstream_passthrough_header,
|
||||
supports_local_oauth_request_auth_resolution, transport_profile_is_configured,
|
||||
transport_proxy_is_locally_supported,
|
||||
};
|
||||
|
||||
pub mod cascade;
|
||||
pub mod models;
|
||||
pub mod proto;
|
||||
|
||||
pub const PROVIDER_TYPE: &str = "windsurf";
|
||||
pub const WINDSURF_ENVELOPE_NAME: &str = "windsurf:GetChatMessage";
|
||||
pub const GET_CHAT_MESSAGE_PATH: &str = "/exa.api_server_pb.ApiServerService/GetChatMessage";
|
||||
const DEFAULT_IDE_VERSION: &str = "1.9600.41";
|
||||
const PLACEHOLDER_API_KEY: &str = "__placeholder__";
|
||||
|
||||
pub fn is_windsurf_provider_transport(transport: &GatewayProviderTransportSnapshot) -> bool {
|
||||
transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case(PROVIDER_TYPE)
|
||||
}
|
||||
|
||||
pub fn local_windsurf_request_transport_unsupported_reason_with_network(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<&'static str> {
|
||||
if !transport.provider.is_active {
|
||||
return Some("provider_inactive");
|
||||
}
|
||||
if !transport.endpoint.is_active {
|
||||
return Some("endpoint_inactive");
|
||||
}
|
||||
if !transport.key.is_active {
|
||||
return Some("key_inactive");
|
||||
}
|
||||
if !is_windsurf_provider_transport(transport) {
|
||||
return Some("transport_provider_type_unsupported");
|
||||
}
|
||||
if !transport
|
||||
.endpoint
|
||||
.api_format
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("openai:chat")
|
||||
{
|
||||
return Some("transport_api_format_mismatch");
|
||||
}
|
||||
if !header_rules_are_locally_supported(transport.endpoint.header_rules.as_ref()) {
|
||||
return Some("transport_header_rules_unsupported");
|
||||
}
|
||||
if !body_rules_are_locally_supported(transport.endpoint.body_rules.as_ref()) {
|
||||
return Some("transport_body_rules_unsupported");
|
||||
}
|
||||
if transport.key.decrypted_auth_config.is_some()
|
||||
&& !supports_local_oauth_request_auth_resolution(transport)
|
||||
&& !supports_local_windsurf_request_auth_resolution(transport)
|
||||
{
|
||||
return Some("transport_oauth_resolution_unsupported");
|
||||
}
|
||||
if !transport_proxy_is_locally_supported(transport) {
|
||||
return Some("transport_proxy_unsupported");
|
||||
}
|
||||
if transport_profile_is_configured(transport) && resolve_transport_profile(transport).is_none()
|
||||
{
|
||||
return Some("transport_profile_unsupported");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn supports_local_windsurf_request_auth_resolution(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> bool {
|
||||
resolve_windsurf_cascade_auth(transport).is_some()
|
||||
}
|
||||
|
||||
pub fn resolve_windsurf_cascade_auth(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<(String, String)> {
|
||||
if !is_windsurf_provider_transport(transport) {
|
||||
return None;
|
||||
}
|
||||
let auth_type = transport.key.auth_type.trim().to_ascii_lowercase();
|
||||
if !matches!(auth_type.as_str(), "oauth" | "api_key" | "bearer") {
|
||||
return None;
|
||||
}
|
||||
let secret = transport.key.decrypted_api_key.trim();
|
||||
if secret.is_empty() || secret == PLACEHOLDER_API_KEY {
|
||||
return None;
|
||||
}
|
||||
Some(("authorization".to_string(), format!("Bearer {secret}")))
|
||||
}
|
||||
|
||||
pub fn build_windsurf_cascade_upstream_url(
|
||||
upstream_base_url: &str,
|
||||
query: Option<&str>,
|
||||
) -> Option<String> {
|
||||
build_passthrough_path_url(upstream_base_url, GET_CHAT_MESSAGE_PATH, query, &[])
|
||||
}
|
||||
|
||||
pub fn build_windsurf_cascade_request_body(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
auth_value: &str,
|
||||
body_rules: Option<&Value>,
|
||||
request_headers: Option<&http::HeaderMap>,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
let mapped_model = mapped_model.trim();
|
||||
if mapped_model.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let messages = body_json.get("messages")?.as_array()?.clone();
|
||||
if messages.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let conversation_id =
|
||||
extract_conversation_id(body_json).unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
let message_text =
|
||||
latest_message_snapshot_text(&messages).unwrap_or_else(|| "Continue.".to_string());
|
||||
let mut provider_request_body = json!({
|
||||
"metadata": windsurf_metadata_from_auth(auth_value),
|
||||
"model": mapped_model,
|
||||
"modelName": mapped_model,
|
||||
"stream": upstream_is_stream,
|
||||
"conversationId": conversation_id,
|
||||
"message": message_text,
|
||||
"messages": messages,
|
||||
});
|
||||
|
||||
if let Some(max_tokens) = body_json
|
||||
.get("max_tokens")
|
||||
.or_else(|| body_json.get("maxTokens"))
|
||||
{
|
||||
provider_request_body
|
||||
.as_object_mut()?
|
||||
.insert("maxTokens".to_string(), max_tokens.clone());
|
||||
}
|
||||
if let Some(temperature) = body_json.get("temperature") {
|
||||
provider_request_body
|
||||
.as_object_mut()?
|
||||
.insert("temperature".to_string(), temperature.clone());
|
||||
}
|
||||
if let Some(top_p) = body_json.get("top_p").or_else(|| body_json.get("topP")) {
|
||||
provider_request_body
|
||||
.as_object_mut()?
|
||||
.insert("topP".to_string(), top_p.clone());
|
||||
}
|
||||
for field in [
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"toolChoice",
|
||||
"parallel_tool_calls",
|
||||
"response_format",
|
||||
] {
|
||||
if let Some(value) = body_json.get(field) {
|
||||
provider_request_body
|
||||
.as_object_mut()?
|
||||
.insert(field.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if !apply_local_body_rules_with_request_headers(
|
||||
&mut provider_request_body,
|
||||
body_rules,
|
||||
Some(body_json),
|
||||
request_headers,
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
Some(provider_request_body)
|
||||
}
|
||||
|
||||
pub fn build_windsurf_cascade_headers(
|
||||
headers: &http::HeaderMap,
|
||||
provider_request_body: &Value,
|
||||
original_request_body: &Value,
|
||||
header_rules: Option<&Value>,
|
||||
auth_header: &str,
|
||||
auth_value: &str,
|
||||
_upstream_is_stream: bool,
|
||||
) -> Option<BTreeMap<String, String>> {
|
||||
let mut out = BTreeMap::new();
|
||||
for (name, value) in headers {
|
||||
let Ok(value) = value.to_str() else {
|
||||
continue;
|
||||
};
|
||||
let key = name.as_str().to_ascii_lowercase();
|
||||
if should_skip_upstream_passthrough_header(&key) {
|
||||
continue;
|
||||
}
|
||||
let value = value.trim();
|
||||
if !value.is_empty() {
|
||||
out.insert(key, value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let auth_header = auth_header.trim().to_ascii_lowercase();
|
||||
if !apply_local_header_rules_with_request_headers(
|
||||
&mut out,
|
||||
header_rules,
|
||||
&[
|
||||
auth_header.as_str(),
|
||||
"content-type",
|
||||
"connect-protocol-version",
|
||||
],
|
||||
provider_request_body,
|
||||
Some(original_request_body),
|
||||
Some(headers),
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
|
||||
out.insert(
|
||||
"content-type".to_string(),
|
||||
"application/connect+json".to_string(),
|
||||
);
|
||||
out.insert("connect-protocol-version".to_string(), "1".to_string());
|
||||
out.insert(
|
||||
"user-agent".to_string(),
|
||||
format!("windsurf/{DEFAULT_IDE_VERSION}"),
|
||||
);
|
||||
out.insert("accept".to_string(), "application/connect+json".to_string());
|
||||
if !auth_header.is_empty() {
|
||||
out.insert(auth_header, auth_value.trim().to_string());
|
||||
}
|
||||
out.remove("content-length");
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn windsurf_metadata_from_auth(auth_value: &str) -> Value {
|
||||
json!({
|
||||
"apiKey": auth_secret_from_header_value(auth_value),
|
||||
"ideName": "windsurf",
|
||||
"ideVersion": DEFAULT_IDE_VERSION,
|
||||
"extensionName": "windsurf",
|
||||
"extensionVersion": DEFAULT_IDE_VERSION,
|
||||
"locale": "en",
|
||||
})
|
||||
}
|
||||
|
||||
fn auth_secret_from_header_value(auth_value: &str) -> String {
|
||||
let value = auth_value.trim();
|
||||
value
|
||||
.strip_prefix("Bearer ")
|
||||
.or_else(|| value.strip_prefix("bearer "))
|
||||
.unwrap_or(value)
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn extract_conversation_id(body_json: &Value) -> Option<String> {
|
||||
let object = body_json.as_object()?;
|
||||
string_value(object.get("conversation_id"))
|
||||
.or_else(|| string_value(object.get("conversationId")))
|
||||
.or_else(|| string_value(object.get("session_id")))
|
||||
.or_else(|| string_value(object.get("sessionId")))
|
||||
.or_else(|| {
|
||||
object
|
||||
.get("metadata")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|metadata| {
|
||||
string_value(metadata.get("conversation_id"))
|
||||
.or_else(|| string_value(metadata.get("conversationId")))
|
||||
.or_else(|| string_value(metadata.get("session_id")))
|
||||
.or_else(|| string_value(metadata.get("sessionId")))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn string_value(value: Option<&Value>) -> Option<String> {
|
||||
value
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn latest_message_snapshot_text(messages: &[Value]) -> Option<String> {
|
||||
messages
|
||||
.iter()
|
||||
.rev()
|
||||
.filter_map(Value::as_object)
|
||||
.find_map(|message| {
|
||||
let role = message.get("role").and_then(Value::as_str)?;
|
||||
match role {
|
||||
"user" => openai_content_to_text(message.get("content"))
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
"tool" => {
|
||||
let content = openai_content_to_text(message.get("content"))
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let tool_call_id = message
|
||||
.get("tool_call_id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
Some(format!(
|
||||
"<tool_result tool_call_id=\"{}\">\n{content}\n</tool_result>",
|
||||
escape_xml_attr(tool_call_id)
|
||||
))
|
||||
}
|
||||
"assistant" => openai_content_to_text(message.get("content"))
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn openai_content_to_text(value: Option<&Value>) -> Option<String> {
|
||||
match value? {
|
||||
Value::String(text) => Some(text.clone()),
|
||||
Value::Array(items) => {
|
||||
let parts = items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
item.as_object()
|
||||
.and_then(|object| object.get("text"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
(!parts.is_empty()).then(|| parts.join("\n"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_xml_attr(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('"', """)
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use http::HeaderMap;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::snapshot::{
|
||||
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
|
||||
};
|
||||
|
||||
use super::{
|
||||
build_windsurf_cascade_headers, build_windsurf_cascade_request_body,
|
||||
build_windsurf_cascade_upstream_url,
|
||||
local_windsurf_request_transport_unsupported_reason_with_network,
|
||||
resolve_windsurf_cascade_auth, GET_CHAT_MESSAGE_PATH,
|
||||
};
|
||||
|
||||
fn sample_windsurf_transport(auth_type: &str) -> GatewayProviderTransportSnapshot {
|
||||
GatewayProviderTransportSnapshot {
|
||||
provider: GatewayProviderTransportProvider {
|
||||
id: "provider-windsurf".to_string(),
|
||||
name: "Windsurf".to_string(),
|
||||
provider_type: "windsurf".to_string(),
|
||||
website: None,
|
||||
is_active: true,
|
||||
keep_priority_on_conversion: false,
|
||||
enable_format_conversion: true,
|
||||
concurrent_limit: None,
|
||||
max_retries: None,
|
||||
proxy: None,
|
||||
request_timeout_secs: None,
|
||||
stream_first_byte_timeout_secs: None,
|
||||
config: None,
|
||||
},
|
||||
endpoint: GatewayProviderTransportEndpoint {
|
||||
id: "endpoint-windsurf-chat".to_string(),
|
||||
provider_id: "provider-windsurf".to_string(),
|
||||
api_format: "openai:chat".to_string(),
|
||||
api_family: Some("openai".to_string()),
|
||||
endpoint_kind: Some("chat".to_string()),
|
||||
is_active: true,
|
||||
base_url: "https://server.codeium.com".to_string(),
|
||||
header_rules: None,
|
||||
body_rules: None,
|
||||
max_retries: None,
|
||||
custom_path: None,
|
||||
config: None,
|
||||
format_acceptance_config: None,
|
||||
proxy: None,
|
||||
},
|
||||
key: GatewayProviderTransportKey {
|
||||
id: "key-windsurf".to_string(),
|
||||
provider_id: "provider-windsurf".to_string(),
|
||||
name: "windsurf@example.com".to_string(),
|
||||
auth_type: auth_type.to_string(),
|
||||
is_active: true,
|
||||
api_formats: None,
|
||||
auth_type_by_format: None,
|
||||
allow_auth_channel_mismatch_formats: None,
|
||||
allowed_models: None,
|
||||
capabilities: None,
|
||||
rate_multipliers: None,
|
||||
global_priority_by_format: None,
|
||||
expires_at_unix_secs: None,
|
||||
proxy: None,
|
||||
fingerprint: None,
|
||||
decrypted_api_key: "devin-session-token$abc".to_string(),
|
||||
decrypted_auth_config: Some(r#"{"provider_type":"windsurf"}"#.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_windsurf_cascade_url() {
|
||||
assert_eq!(
|
||||
build_windsurf_cascade_upstream_url("https://server.codeium.com", Some("debug=1"))
|
||||
.as_deref(),
|
||||
Some(
|
||||
"https://server.codeium.com/exa.api_server_pb.ApiServerService/GetChatMessage?debug=1"
|
||||
)
|
||||
);
|
||||
assert!(GET_CHAT_MESSAGE_PATH.ends_with("/GetChatMessage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_cascade_request_body_with_metadata_and_messages() {
|
||||
let body = build_windsurf_cascade_request_body(
|
||||
&json!({
|
||||
"model": "gpt-5",
|
||||
"conversation_id": "conv-1",
|
||||
"messages": [
|
||||
{"role": "system", "content": "brief"},
|
||||
{"role": "user", "content": [{"type": "text", "text": "hello"}]}
|
||||
],
|
||||
"max_tokens": 128
|
||||
}),
|
||||
"windsurf-model",
|
||||
"Bearer devin-session-token$abc",
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("body should build");
|
||||
|
||||
assert_eq!(body["metadata"]["apiKey"], json!("devin-session-token$abc"));
|
||||
assert_eq!(body["modelName"], json!("windsurf-model"));
|
||||
assert_eq!(body["stream"], json!(true));
|
||||
assert_eq!(body["conversationId"], json!("conv-1"));
|
||||
assert_eq!(body["message"], json!("hello"));
|
||||
assert_eq!(body["maxTokens"], json!(128));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_openai_tool_fields_for_native_windsurf_runtime() {
|
||||
let body = build_windsurf_cascade_request_body(
|
||||
&json!({
|
||||
"model": "gpt-5-5-low",
|
||||
"messages": [
|
||||
{"role": "user", "content": "read Cargo.toml"}
|
||||
],
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "Read",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"file_path": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
}],
|
||||
"tool_choice": "required",
|
||||
"parallel_tool_calls": false,
|
||||
"response_format": {"type": "json_object"}
|
||||
}),
|
||||
"gpt-5-5-low",
|
||||
"Bearer devin-session-token$abc",
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.expect("body should build");
|
||||
|
||||
assert_eq!(body["tools"][0]["function"]["name"], json!("Read"));
|
||||
assert_eq!(body["tool_choice"], json!("required"));
|
||||
assert_eq!(body["parallel_tool_calls"], json!(false));
|
||||
assert_eq!(body["response_format"]["type"], json!("json_object"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_cascade_request_body_message_snapshot_from_latest_tool_result() {
|
||||
let body = build_windsurf_cascade_request_body(
|
||||
&json!({
|
||||
"model": "gpt-5-5-low",
|
||||
"messages": [
|
||||
{"role": "user", "content": "read Cargo.toml"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "Read", "arguments": "{\"file_path\":\"Cargo.toml\"}"}
|
||||
}]
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "workspace Cargo.toml content"}
|
||||
]
|
||||
}),
|
||||
"gpt-5-5-low",
|
||||
"Bearer devin-session-token$abc",
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.expect("body should build");
|
||||
|
||||
assert!(body["message"]
|
||||
.as_str()
|
||||
.expect("message should be a string")
|
||||
.contains(r#"<tool_result tool_call_id="call_1">"#));
|
||||
assert!(body["message"]
|
||||
.as_str()
|
||||
.expect("message should be a string")
|
||||
.contains("workspace Cargo.toml content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_cascade_headers_with_connect_protocol_and_auth() {
|
||||
let headers = build_windsurf_cascade_headers(
|
||||
&HeaderMap::new(),
|
||||
&json!({"metadata": {"apiKey": "secret"}}),
|
||||
&json!({"messages": []}),
|
||||
None,
|
||||
"authorization",
|
||||
"Bearer secret",
|
||||
false,
|
||||
)
|
||||
.expect("headers should build");
|
||||
|
||||
assert_eq!(
|
||||
headers.get("connect-protocol-version").map(String::as_str),
|
||||
Some("1")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("authorization").map(String::as_str),
|
||||
Some("Bearer secret")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("content-type").map(String::as_str),
|
||||
Some("application/connect+json")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("accept").map(String::as_str),
|
||||
Some("application/connect+json")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_windsurf_transport_resolves_direct_bearer_auth() {
|
||||
let transport = sample_windsurf_transport("oauth");
|
||||
|
||||
assert_eq!(
|
||||
local_windsurf_request_transport_unsupported_reason_with_network(&transport),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_windsurf_cascade_auth(&transport),
|
||||
Some((
|
||||
"authorization".to_string(),
|
||||
"Bearer devin-session-token$abc".to_string()
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
1475
crates/aether-provider-transport/src/windsurf/cascade.rs
Normal file
1475
crates/aether-provider-transport/src/windsurf/cascade.rs
Normal file
File diff suppressed because it is too large
Load Diff
384
crates/aether-provider-transport/src/windsurf/models.rs
Normal file
384
crates/aether-provider-transport/src/windsurf/models.rs
Normal file
@@ -0,0 +1,384 @@
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct WindsurfModel {
|
||||
pub canonical_name: &'static str,
|
||||
pub enum_value: u32,
|
||||
pub model_uid: Option<&'static str>,
|
||||
pub credit_multiplier: f32,
|
||||
pub provider: &'static str,
|
||||
pub deprecated: bool,
|
||||
}
|
||||
|
||||
#[rustfmt::skip]
|
||||
const MODELS: &[WindsurfModel] = &[
|
||||
WindsurfModel { canonical_name: "claude-3.5-sonnet", enum_value: 166, model_uid: None, credit_multiplier: 2.0, provider: "anthropic", deprecated: true },
|
||||
WindsurfModel { canonical_name: "claude-3.7-sonnet", enum_value: 226, model_uid: None, credit_multiplier: 2.0, provider: "anthropic", deprecated: true },
|
||||
WindsurfModel { canonical_name: "claude-3.7-sonnet-thinking", enum_value: 227, model_uid: None, credit_multiplier: 3.0, provider: "anthropic", deprecated: true },
|
||||
WindsurfModel { canonical_name: "claude-4-sonnet", enum_value: 281, model_uid: Some("MODEL_CLAUDE_4_SONNET"), credit_multiplier: 2.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-4-sonnet-thinking", enum_value: 282, model_uid: Some("MODEL_CLAUDE_4_SONNET_THINKING"), credit_multiplier: 3.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-4-opus", enum_value: 290, model_uid: Some("MODEL_CLAUDE_4_OPUS"), credit_multiplier: 4.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-4-opus-thinking", enum_value: 291, model_uid: Some("MODEL_CLAUDE_4_OPUS_THINKING"), credit_multiplier: 5.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-4.1-opus", enum_value: 328, model_uid: Some("MODEL_CLAUDE_4_1_OPUS"), credit_multiplier: 4.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-4.1-opus-thinking", enum_value: 329, model_uid: Some("MODEL_CLAUDE_4_1_OPUS_THINKING"), credit_multiplier: 5.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-4.5-haiku", enum_value: 0, model_uid: Some("MODEL_PRIVATE_11"), credit_multiplier: 1.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-4.5-sonnet", enum_value: 353, model_uid: Some("MODEL_PRIVATE_2"), credit_multiplier: 2.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-4.5-sonnet-thinking", enum_value: 354, model_uid: Some("MODEL_PRIVATE_3"), credit_multiplier: 3.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-4.5-opus", enum_value: 391, model_uid: Some("MODEL_CLAUDE_4_5_OPUS"), credit_multiplier: 4.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-4.5-opus-thinking", enum_value: 392, model_uid: Some("MODEL_CLAUDE_4_5_OPUS_THINKING"), credit_multiplier: 5.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-sonnet-4.6", enum_value: 0, model_uid: Some("claude-sonnet-4-6"), credit_multiplier: 4.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-sonnet-4.6-thinking", enum_value: 0, model_uid: Some("claude-sonnet-4-6-thinking"), credit_multiplier: 6.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-sonnet-4.6-1m", enum_value: 0, model_uid: Some("claude-sonnet-4-6-1m"), credit_multiplier: 12.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-sonnet-4.6-thinking-1m", enum_value: 0, model_uid: Some("claude-sonnet-4-6-thinking-1m"), credit_multiplier: 16.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-opus-4.6", enum_value: 0, model_uid: Some("claude-opus-4-6"), credit_multiplier: 6.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-opus-4.6-thinking", enum_value: 0, model_uid: Some("claude-opus-4-6-thinking"), credit_multiplier: 8.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-opus-4-7-medium", enum_value: 0, model_uid: Some("claude-opus-4-7-medium"), credit_multiplier: 8.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-opus-4-7-low", enum_value: 0, model_uid: Some("claude-opus-4-7-low"), credit_multiplier: 6.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-opus-4-7-high", enum_value: 0, model_uid: Some("claude-opus-4-7-high"), credit_multiplier: 10.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-opus-4-7-xhigh", enum_value: 0, model_uid: Some("claude-opus-4-7-xhigh"), credit_multiplier: 12.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-opus-4-7-medium-thinking", enum_value: 0, model_uid: Some("claude-opus-4-7-medium-thinking"), credit_multiplier: 10.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-opus-4-7-high-thinking", enum_value: 0, model_uid: Some("claude-opus-4-7-high-thinking"), credit_multiplier: 12.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-opus-4-7-xhigh-thinking", enum_value: 0, model_uid: Some("claude-opus-4-7-xhigh-thinking"), credit_multiplier: 16.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-opus-4-7-max", enum_value: 0, model_uid: Some("claude-opus-4-7-max"), credit_multiplier: 16.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-4o", enum_value: 109, model_uid: Some("MODEL_CHAT_GPT_4O_2024_08_06"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-4o-mini", enum_value: 113, model_uid: None, credit_multiplier: 0.5, provider: "openai", deprecated: true },
|
||||
WindsurfModel { canonical_name: "gpt-4.1", enum_value: 259, model_uid: Some("MODEL_CHAT_GPT_4_1_2025_04_14"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-4.1-mini", enum_value: 260, model_uid: None, credit_multiplier: 0.5, provider: "openai", deprecated: true },
|
||||
WindsurfModel { canonical_name: "gpt-4.1-nano", enum_value: 261, model_uid: None, credit_multiplier: 0.25, provider: "openai", deprecated: true },
|
||||
WindsurfModel { canonical_name: "gpt-5", enum_value: 340, model_uid: Some("MODEL_PRIVATE_6"), credit_multiplier: 0.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5-medium", enum_value: 0, model_uid: Some("MODEL_PRIVATE_7"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5-high", enum_value: 0, model_uid: Some("MODEL_PRIVATE_8"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5-mini", enum_value: 337, model_uid: None, credit_multiplier: 0.25, provider: "openai", deprecated: true },
|
||||
WindsurfModel { canonical_name: "gpt-5-codex", enum_value: 346, model_uid: Some("MODEL_CHAT_GPT_5_CODEX"), credit_multiplier: 0.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1", enum_value: 0, model_uid: Some("MODEL_PRIVATE_12"), credit_multiplier: 0.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-low", enum_value: 0, model_uid: Some("MODEL_PRIVATE_13"), credit_multiplier: 0.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-medium", enum_value: 0, model_uid: Some("MODEL_PRIVATE_14"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-high", enum_value: 0, model_uid: Some("MODEL_PRIVATE_15"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-fast", enum_value: 0, model_uid: Some("MODEL_PRIVATE_20"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-low-fast", enum_value: 0, model_uid: Some("MODEL_PRIVATE_21"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-medium-fast", enum_value: 0, model_uid: Some("MODEL_PRIVATE_22"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-high-fast", enum_value: 0, model_uid: Some("MODEL_PRIVATE_23"), credit_multiplier: 4.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-codex-low", enum_value: 0, model_uid: Some("MODEL_GPT_5_1_CODEX_LOW"), credit_multiplier: 0.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-codex-medium", enum_value: 0, model_uid: Some("MODEL_PRIVATE_9"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-codex-mini-low", enum_value: 0, model_uid: Some("MODEL_GPT_5_1_CODEX_MINI_LOW"), credit_multiplier: 0.25, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-codex-mini", enum_value: 0, model_uid: Some("MODEL_PRIVATE_19"), credit_multiplier: 0.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-codex-max-low", enum_value: 0, model_uid: Some("MODEL_GPT_5_1_CODEX_MAX_LOW"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-codex-max-medium", enum_value: 0, model_uid: Some("MODEL_GPT_5_1_CODEX_MAX_MEDIUM"), credit_multiplier: 1.25, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-codex-max-high", enum_value: 0, model_uid: Some("MODEL_GPT_5_1_CODEX_MAX_HIGH"), credit_multiplier: 1.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2", enum_value: 401, model_uid: Some("MODEL_GPT_5_2_MEDIUM"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-none", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_NONE"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-low", enum_value: 400, model_uid: Some("MODEL_GPT_5_2_LOW"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-high", enum_value: 402, model_uid: Some("MODEL_GPT_5_2_HIGH"), credit_multiplier: 3.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-xhigh", enum_value: 403, model_uid: Some("MODEL_GPT_5_2_XHIGH"), credit_multiplier: 8.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-none-fast", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_NONE_PRIORITY"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-low-fast", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_LOW_PRIORITY"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-medium-fast", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_MEDIUM_PRIORITY"), credit_multiplier: 4.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-high-fast", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_HIGH_PRIORITY"), credit_multiplier: 6.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-xhigh-fast", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_XHIGH_PRIORITY"), credit_multiplier: 16.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-codex-low", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_CODEX_LOW"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-codex-medium", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_CODEX_MEDIUM"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-codex-high", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_CODEX_HIGH"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-codex-xhigh", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_CODEX_XHIGH"), credit_multiplier: 3.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-codex-low-fast", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_CODEX_LOW_PRIORITY"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-codex-medium-fast", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_CODEX_MEDIUM_PRIORITY"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-codex-high-fast", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_CODEX_HIGH_PRIORITY"), credit_multiplier: 4.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-codex-xhigh-fast", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_CODEX_XHIGH_PRIORITY"), credit_multiplier: 6.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.3-codex", enum_value: 0, model_uid: Some("gpt-5-3-codex-medium"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.4-none", enum_value: 0, model_uid: Some("gpt-5-4-none"), credit_multiplier: 0.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.4-low", enum_value: 0, model_uid: Some("gpt-5-4-low"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.4-medium", enum_value: 0, model_uid: Some("gpt-5-4-medium"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.4-high", enum_value: 0, model_uid: Some("gpt-5-4-high"), credit_multiplier: 4.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.4-xhigh", enum_value: 0, model_uid: Some("gpt-5-4-xhigh"), credit_multiplier: 8.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.4-mini-low", enum_value: 0, model_uid: Some("gpt-5-4-mini-low"), credit_multiplier: 1.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.4-mini-medium", enum_value: 0, model_uid: Some("gpt-5-4-mini-medium"), credit_multiplier: 1.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.4-mini-high", enum_value: 0, model_uid: Some("gpt-5-4-mini-high"), credit_multiplier: 4.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.4-mini-xhigh", enum_value: 0, model_uid: Some("gpt-5-4-mini-xhigh"), credit_multiplier: 12.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.5", enum_value: 0, model_uid: Some("gpt-5-5-medium"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.5-none", enum_value: 0, model_uid: Some("gpt-5-5-none"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.5-low", enum_value: 0, model_uid: Some("gpt-5-5-low"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.5-medium", enum_value: 0, model_uid: Some("gpt-5-5-medium"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.5-high", enum_value: 0, model_uid: Some("gpt-5-5-high"), credit_multiplier: 4.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.5-xhigh", enum_value: 0, model_uid: Some("gpt-5-5-xhigh"), credit_multiplier: 8.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.5-none-fast", enum_value: 0, model_uid: Some("gpt-5-5-none-priority"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.5-low-fast", enum_value: 0, model_uid: Some("gpt-5-5-low-priority"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.5-medium-fast", enum_value: 0, model_uid: Some("gpt-5-5-medium-priority"), credit_multiplier: 4.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.5-high-fast", enum_value: 0, model_uid: Some("gpt-5-5-high-priority"), credit_multiplier: 8.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.5-xhigh-fast", enum_value: 0, model_uid: Some("gpt-5-5-xhigh-priority"), credit_multiplier: 16.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.3-codex-low", enum_value: 0, model_uid: Some("gpt-5-3-codex-low"), credit_multiplier: 0.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.3-codex-high", enum_value: 0, model_uid: Some("gpt-5-3-codex-high"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.3-codex-xhigh", enum_value: 0, model_uid: Some("gpt-5-3-codex-xhigh"), credit_multiplier: 4.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.3-codex-low-fast", enum_value: 0, model_uid: Some("gpt-5-3-codex-low-priority"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.3-codex-medium-fast", enum_value: 0, model_uid: Some("gpt-5-3-codex-medium-priority"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.3-codex-high-fast", enum_value: 0, model_uid: Some("gpt-5-3-codex-high-priority"), credit_multiplier: 4.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.3-codex-xhigh-fast", enum_value: 0, model_uid: Some("gpt-5-3-codex-xhigh-priority"), credit_multiplier: 6.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-oss-120b", enum_value: 0, model_uid: Some("MODEL_GPT_OSS_120B"), credit_multiplier: 0.25, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "o3-mini", enum_value: 207, model_uid: None, credit_multiplier: 0.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "o3", enum_value: 218, model_uid: Some("MODEL_CHAT_O3"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "o3-high", enum_value: 0, model_uid: Some("MODEL_CHAT_O3_HIGH"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "o3-pro", enum_value: 294, model_uid: None, credit_multiplier: 4.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "o4-mini", enum_value: 264, model_uid: None, credit_multiplier: 0.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gemini-2.5-pro", enum_value: 246, model_uid: Some("MODEL_GOOGLE_GEMINI_2_5_PRO"), credit_multiplier: 1.0, provider: "google", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gemini-2.5-flash", enum_value: 312, model_uid: Some("MODEL_GOOGLE_GEMINI_2_5_FLASH"), credit_multiplier: 0.5, provider: "google", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gemini-3.0-pro", enum_value: 412, model_uid: Some("MODEL_GOOGLE_GEMINI_3_0_PRO_LOW"), credit_multiplier: 1.0, provider: "google", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gemini-3.0-flash-minimal", enum_value: 0, model_uid: Some("MODEL_GOOGLE_GEMINI_3_0_FLASH_MINIMAL"), credit_multiplier: 0.75, provider: "google", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gemini-3.0-flash-low", enum_value: 0, model_uid: Some("MODEL_GOOGLE_GEMINI_3_0_FLASH_LOW"), credit_multiplier: 1.0, provider: "google", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gemini-3.0-flash", enum_value: 415, model_uid: Some("MODEL_GOOGLE_GEMINI_3_0_FLASH_MEDIUM"), credit_multiplier: 1.0, provider: "google", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gemini-3.0-flash-high", enum_value: 0, model_uid: Some("MODEL_GOOGLE_GEMINI_3_0_FLASH_HIGH"), credit_multiplier: 1.75, provider: "google", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gemini-3.1-pro-low", enum_value: 0, model_uid: Some("gemini-3-1-pro-low"), credit_multiplier: 1.0, provider: "google", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gemini-3.1-pro-high", enum_value: 0, model_uid: Some("gemini-3-1-pro-high"), credit_multiplier: 2.0, provider: "google", deprecated: false },
|
||||
WindsurfModel { canonical_name: "deepseek-v3", enum_value: 205, model_uid: None, credit_multiplier: 0.5, provider: "deepseek", deprecated: true },
|
||||
WindsurfModel { canonical_name: "deepseek-v3-2", enum_value: 409, model_uid: None, credit_multiplier: 0.5, provider: "deepseek", deprecated: true },
|
||||
WindsurfModel { canonical_name: "deepseek-r1", enum_value: 206, model_uid: None, credit_multiplier: 1.0, provider: "deepseek", deprecated: true },
|
||||
WindsurfModel { canonical_name: "grok-3", enum_value: 217, model_uid: Some("MODEL_XAI_GROK_3"), credit_multiplier: 1.0, provider: "xai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "grok-3-mini", enum_value: 234, model_uid: None, credit_multiplier: 0.5, provider: "xai", deprecated: true },
|
||||
WindsurfModel { canonical_name: "grok-3-mini-thinking", enum_value: 0, model_uid: Some("MODEL_XAI_GROK_3_MINI_REASONING"), credit_multiplier: 0.125, provider: "xai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "grok-code-fast-1", enum_value: 0, model_uid: Some("MODEL_PRIVATE_4"), credit_multiplier: 0.5, provider: "xai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "qwen-3", enum_value: 324, model_uid: None, credit_multiplier: 0.5, provider: "alibaba", deprecated: true },
|
||||
WindsurfModel { canonical_name: "kimi-k2", enum_value: 323, model_uid: Some("MODEL_KIMI_K2"), credit_multiplier: 0.5, provider: "moonshot", deprecated: false },
|
||||
WindsurfModel { canonical_name: "kimi-k2-thinking", enum_value: 394, model_uid: Some("MODEL_KIMI_K2_THINKING"), credit_multiplier: 1.0, provider: "moonshot", deprecated: false },
|
||||
WindsurfModel { canonical_name: "kimi-k2.5", enum_value: 0, model_uid: Some("kimi-k2-5"), credit_multiplier: 1.0, provider: "moonshot", deprecated: false },
|
||||
WindsurfModel { canonical_name: "kimi-k2-6", enum_value: 0, model_uid: Some("kimi-k2-6"), credit_multiplier: 1.0, provider: "moonshot", deprecated: false },
|
||||
WindsurfModel { canonical_name: "glm-4.7", enum_value: 417, model_uid: Some("MODEL_GLM_4_7"), credit_multiplier: 0.25, provider: "zhipu", deprecated: false },
|
||||
WindsurfModel { canonical_name: "glm-4.7-fast", enum_value: 418, model_uid: Some("MODEL_GLM_4_7_FAST"), credit_multiplier: 0.5, provider: "zhipu", deprecated: false },
|
||||
WindsurfModel { canonical_name: "glm-5", enum_value: 0, model_uid: Some("glm-5"), credit_multiplier: 1.5, provider: "zhipu", deprecated: false },
|
||||
WindsurfModel { canonical_name: "glm-5.1", enum_value: 0, model_uid: Some("glm-5-1"), credit_multiplier: 1.5, provider: "zhipu", deprecated: false },
|
||||
WindsurfModel { canonical_name: "minimax-m2.5", enum_value: 419, model_uid: Some("MODEL_MINIMAX_M2_1"), credit_multiplier: 1.0, provider: "minimax", deprecated: false },
|
||||
WindsurfModel { canonical_name: "swe-1.5", enum_value: 377, model_uid: Some("MODEL_SWE_1_5_SLOW"), credit_multiplier: 0.5, provider: "windsurf", deprecated: false },
|
||||
WindsurfModel { canonical_name: "swe-1.5-fast", enum_value: 359, model_uid: Some("MODEL_SWE_1_5"), credit_multiplier: 0.5, provider: "windsurf", deprecated: false },
|
||||
WindsurfModel { canonical_name: "swe-1.5-thinking", enum_value: 369, model_uid: Some("MODEL_SWE_1_5_THINKING"), credit_multiplier: 0.75, provider: "windsurf", deprecated: false },
|
||||
WindsurfModel { canonical_name: "swe-1.6", enum_value: 420, model_uid: Some("MODEL_SWE_1_6"), credit_multiplier: 0.5, provider: "windsurf", deprecated: false },
|
||||
WindsurfModel { canonical_name: "swe-1.6-fast", enum_value: 421, model_uid: Some("MODEL_SWE_1_6_FAST"), credit_multiplier: 0.5, provider: "windsurf", deprecated: false },
|
||||
WindsurfModel { canonical_name: "adaptive", enum_value: 0, model_uid: Some("adaptive"), credit_multiplier: 1.0, provider: "windsurf", deprecated: true },
|
||||
WindsurfModel { canonical_name: "arena-fast", enum_value: 0, model_uid: Some("arena-fast"), credit_multiplier: 0.5, provider: "windsurf", deprecated: true },
|
||||
WindsurfModel { canonical_name: "arena-smart", enum_value: 0, model_uid: Some("arena-smart"), credit_multiplier: 1.0, provider: "windsurf", deprecated: true },
|
||||
];
|
||||
|
||||
#[rustfmt::skip]
|
||||
const ALIASES: &[(&str, &str)] = &[
|
||||
("claude-3-5-haiku-20241022", "claude-4.5-haiku"),
|
||||
("claude-3-5-haiku-latest", "claude-4.5-haiku"),
|
||||
("claude-3-5-sonnet-20240620", "claude-3.5-sonnet"),
|
||||
("claude-3-5-sonnet-20241022", "claude-3.5-sonnet"),
|
||||
("claude-3-5-sonnet-latest", "claude-3.5-sonnet"),
|
||||
("claude-3-7-sonnet-20250219", "claude-3.7-sonnet"),
|
||||
("claude-3-7-sonnet-latest", "claude-3.7-sonnet"),
|
||||
("claude-4.6", "claude-sonnet-4.6"),
|
||||
("claude-4.6-1m", "claude-sonnet-4.6-1m"),
|
||||
("claude-4.6-thinking", "claude-sonnet-4.6-thinking"),
|
||||
("claude-4.6-thinking-1m", "claude-sonnet-4.6-thinking-1m"),
|
||||
("claude-haiku-3-5", "claude-4.5-haiku"),
|
||||
("claude-haiku-3-5-latest", "claude-4.5-haiku"),
|
||||
("claude-haiku-4-5", "claude-4.5-haiku"),
|
||||
("claude-haiku-4-5-20251001", "claude-4.5-haiku"),
|
||||
("claude-haiku-4-5-latest", "claude-4.5-haiku"),
|
||||
("claude-haiku-4.5", "claude-4.5-haiku"),
|
||||
("claude-haiku-4.5-latest", "claude-4.5-haiku"),
|
||||
("claude-opus-4-0", "claude-4-opus"),
|
||||
("claude-opus-4-1", "claude-4.1-opus"),
|
||||
("claude-opus-4-1-20250805", "claude-4.1-opus"),
|
||||
("claude-opus-4-20250514", "claude-4-opus"),
|
||||
("claude-opus-4-5", "claude-4.5-opus"),
|
||||
("claude-opus-4-5-20251101", "claude-4.5-opus"),
|
||||
("claude-opus-4-5-latest", "claude-4.5-opus"),
|
||||
("claude-opus-4-6", "claude-opus-4.6"),
|
||||
("claude-opus-4-6-thinking", "claude-opus-4.6-thinking"),
|
||||
("claude-opus-4-7", "claude-opus-4-7-medium"),
|
||||
("claude-opus-4-7-latest", "claude-opus-4-7-medium"),
|
||||
("claude-opus-4-7-thinking", "claude-opus-4-7-medium-thinking"),
|
||||
("claude-opus-4.5", "claude-4.5-opus"),
|
||||
("claude-opus-4.5-thinking", "claude-4.5-opus-thinking"),
|
||||
("claude-opus-4.7", "claude-opus-4-7-medium"),
|
||||
("claude-opus-4.7-high", "claude-opus-4-7-high"),
|
||||
("claude-opus-4.7-high-thinking", "claude-opus-4-7-high-thinking"),
|
||||
("claude-opus-4.7-low", "claude-opus-4-7-low"),
|
||||
("claude-opus-4.7-max", "claude-opus-4-7-max"),
|
||||
("claude-opus-4.7-medium", "claude-opus-4-7-medium"),
|
||||
("claude-opus-4.7-medium-thinking", "claude-opus-4-7-medium-thinking"),
|
||||
("claude-opus-4.7-thinking", "claude-opus-4-7-medium-thinking"),
|
||||
("claude-opus-4.7-xhigh", "claude-opus-4-7-xhigh"),
|
||||
("claude-opus-4.7-xhigh-thinking", "claude-opus-4-7-xhigh-thinking"),
|
||||
("claude-sonnet-4-0", "claude-4-sonnet"),
|
||||
("claude-sonnet-4-20250514", "claude-4-sonnet"),
|
||||
("claude-sonnet-4-5", "claude-4.5-sonnet"),
|
||||
("claude-sonnet-4-5-20250929", "claude-4.5-sonnet"),
|
||||
("claude-sonnet-4-5-latest", "claude-4.5-sonnet"),
|
||||
("claude-sonnet-4-6", "claude-sonnet-4.6"),
|
||||
("claude-sonnet-4-6-1m", "claude-sonnet-4.6-1m"),
|
||||
("claude-sonnet-4-6-thinking", "claude-sonnet-4.6-thinking"),
|
||||
("claude-sonnet-4-6-thinking-1m", "claude-sonnet-4.6-thinking-1m"),
|
||||
("claude-sonnet-4.5", "claude-4.5-sonnet"),
|
||||
("claude-sonnet-4.5-thinking", "claude-4.5-sonnet-thinking"),
|
||||
("gpt-4.1-2025-04-14", "gpt-4.1"),
|
||||
("gpt-4.1-mini-2025-04-14", "gpt-4.1-mini"),
|
||||
("gpt-4.1-nano-2025-04-14", "gpt-4.1-nano"),
|
||||
("gpt-4o-2024-05-13", "gpt-4o"),
|
||||
("gpt-4o-2024-08-06", "gpt-4o"),
|
||||
("gpt-4o-2024-11-20", "gpt-4o"),
|
||||
("gpt-4o-mini-2024-07-18", "gpt-4o-mini"),
|
||||
("gpt-5-2-codex-medium", "gpt-5.2-codex-medium"),
|
||||
("gpt-5-2-medium", "gpt-5.2"),
|
||||
("gpt-5-2025-08-07", "gpt-5"),
|
||||
("gpt-5-3-codex-high", "gpt-5.3-codex-high"),
|
||||
("gpt-5-3-codex-high-priority", "gpt-5.3-codex-high-fast"),
|
||||
("gpt-5-3-codex-low", "gpt-5.3-codex-low"),
|
||||
("gpt-5-3-codex-low-priority", "gpt-5.3-codex-low-fast"),
|
||||
("gpt-5-3-codex-medium", "gpt-5.3-codex"),
|
||||
("gpt-5-3-codex-medium-priority", "gpt-5.3-codex-medium-fast"),
|
||||
("gpt-5-3-codex-xhigh", "gpt-5.3-codex-xhigh"),
|
||||
("gpt-5-3-codex-xhigh-priority", "gpt-5.3-codex-xhigh-fast"),
|
||||
("gpt-5-4-high", "gpt-5.4-high"),
|
||||
("gpt-5-4-low", "gpt-5.4-low"),
|
||||
("gpt-5-4-medium", "gpt-5.4-medium"),
|
||||
("gpt-5-4-mini-high", "gpt-5.4-mini-high"),
|
||||
("gpt-5-4-mini-low", "gpt-5.4-mini-low"),
|
||||
("gpt-5-4-mini-medium", "gpt-5.4-mini-medium"),
|
||||
("gpt-5-4-mini-xhigh", "gpt-5.4-mini-xhigh"),
|
||||
("gpt-5-4-none", "gpt-5.4-none"),
|
||||
("gpt-5-4-xhigh", "gpt-5.4-xhigh"),
|
||||
("gpt-5-5", "gpt-5.5-medium"),
|
||||
("gpt-5-5-high", "gpt-5.5-high"),
|
||||
("gpt-5-5-high-priority", "gpt-5.5-high-fast"),
|
||||
("gpt-5-5-low", "gpt-5.5-low"),
|
||||
("gpt-5-5-low-priority", "gpt-5.5-low-fast"),
|
||||
("gpt-5-5-medium", "gpt-5.5-medium"),
|
||||
("gpt-5-5-medium-priority", "gpt-5.5-medium-fast"),
|
||||
("gpt-5-5-none", "gpt-5.5-none"),
|
||||
("gpt-5-5-none-priority", "gpt-5.5-none-fast"),
|
||||
("gpt-5-5-xhigh", "gpt-5.5-xhigh"),
|
||||
("gpt-5-5-xhigh-priority", "gpt-5.5-xhigh-fast"),
|
||||
("gpt-5-pro-2025-10-06", "gpt-5-high"),
|
||||
("gpt-5.2-codex", "gpt-5.2-codex-medium"),
|
||||
("gpt-5.2-medium", "gpt-5.2"),
|
||||
("gpt-5.3-codex-medium", "gpt-5.3-codex"),
|
||||
("gpt-5.4", "gpt-5.4-medium"),
|
||||
("gpt-5.5", "gpt-5.5-medium"),
|
||||
("haiku-4.5", "claude-4.5-haiku"),
|
||||
("kimi-k2-5", "kimi-k2.5"),
|
||||
("minimax-m2-5", "minimax-m2.5"),
|
||||
("model_claude_4_5_sonnet", "claude-4.5-sonnet"),
|
||||
("model_claude_4_5_sonnet_thinking", "claude-4.5-sonnet-thinking"),
|
||||
("o4.7", "claude-opus-4-7-medium"),
|
||||
("opus-4", "claude-4-opus"),
|
||||
("opus-4-7", "claude-opus-4-7-medium"),
|
||||
("opus-4.1", "claude-4.1-opus"),
|
||||
("opus-4.6", "claude-opus-4.6"),
|
||||
("opus-4.6-thinking", "claude-opus-4.6-thinking"),
|
||||
("opus-4.7", "claude-opus-4-7-medium"),
|
||||
("opus-4.7-thinking", "claude-opus-4-7-medium-thinking"),
|
||||
("sonnet-3.5", "claude-3.5-sonnet"),
|
||||
("sonnet-3.7", "claude-3.7-sonnet"),
|
||||
("sonnet-4", "claude-4-sonnet"),
|
||||
("sonnet-4.5", "claude-4.5-sonnet"),
|
||||
("sonnet-4.5-thinking", "claude-4.5-sonnet-thinking"),
|
||||
("sonnet-4.6", "claude-sonnet-4.6"),
|
||||
("sonnet-4.6-1m", "claude-sonnet-4.6-1m"),
|
||||
("sonnet-4.6-thinking", "claude-sonnet-4.6-thinking"),
|
||||
("swe-1-6", "swe-1.6"),
|
||||
("swe-1-6-fast", "swe-1.6-fast"),
|
||||
("ws-haiku", "claude-4.5-haiku"),
|
||||
("ws-opus", "claude-opus-4.6"),
|
||||
("ws-opus-thinking", "claude-opus-4.6-thinking"),
|
||||
("ws-sonnet", "claude-sonnet-4.6"),
|
||||
("ws-sonnet-thinking", "claude-sonnet-4.6-thinking"),
|
||||
];
|
||||
|
||||
pub fn windsurf_models() -> &'static [WindsurfModel] {
|
||||
MODELS
|
||||
}
|
||||
|
||||
pub fn resolve_windsurf_model(name: &str) -> Option<WindsurfModel> {
|
||||
let normalized = name.trim().to_ascii_lowercase();
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let canonical = ALIASES
|
||||
.iter()
|
||||
.find_map(|(alias, canonical)| (*alias == normalized).then_some(*canonical))
|
||||
.unwrap_or(normalized.as_str());
|
||||
MODELS
|
||||
.iter()
|
||||
.find(|model| model_matches(model, canonical))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn model_matches(model: &WindsurfModel, value: &str) -> bool {
|
||||
model.canonical_name.eq_ignore_ascii_case(value)
|
||||
|| model
|
||||
.model_uid
|
||||
.is_some_and(|uid| uid.eq_ignore_ascii_case(value))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolves_gpt55_cloud_alias_to_windsurf_model_uid() {
|
||||
let model = resolve_windsurf_model("gpt-5-5-low").expect("model should resolve");
|
||||
|
||||
assert_eq!(model.canonical_name, "gpt-5.5-low");
|
||||
assert_eq!(model.model_uid, Some("gpt-5-5-low"));
|
||||
assert_eq!(model.enum_value, 0);
|
||||
assert_eq!(model.credit_multiplier, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_claude_opus_47_bare_alias_to_medium() {
|
||||
let model = resolve_windsurf_model("claude-opus-4.7").expect("model should resolve");
|
||||
|
||||
assert_eq!(model.canonical_name, "claude-opus-4-7-medium");
|
||||
assert_eq!(model.model_uid, Some("claude-opus-4-7-medium"));
|
||||
assert_eq!(model.enum_value, 0);
|
||||
assert_eq!(model.credit_multiplier, 8.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_priority_alias_to_fast_variant() {
|
||||
let model = resolve_windsurf_model("gpt-5-5-low-priority").expect("model should resolve");
|
||||
|
||||
assert_eq!(model.canonical_name, "gpt-5.5-low-fast");
|
||||
assert_eq!(model.model_uid, Some("gpt-5-5-low-priority"));
|
||||
assert_eq!(model.credit_multiplier, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_full_gpt55_effort_ladder_and_priority_aliases() {
|
||||
let none = resolve_windsurf_model("gpt-5-5-none").expect("none should resolve");
|
||||
assert_eq!(none.canonical_name, "gpt-5.5-none");
|
||||
assert_eq!(none.model_uid, Some("gpt-5-5-none"));
|
||||
assert_eq!(none.credit_multiplier, 1.0);
|
||||
|
||||
let high = resolve_windsurf_model("gpt-5.5-high").expect("high should resolve");
|
||||
assert_eq!(high.canonical_name, "gpt-5.5-high");
|
||||
assert_eq!(high.model_uid, Some("gpt-5-5-high"));
|
||||
assert_eq!(high.credit_multiplier, 4.0);
|
||||
|
||||
let xhigh_fast = resolve_windsurf_model("gpt-5-5-xhigh-priority")
|
||||
.expect("xhigh priority should resolve");
|
||||
assert_eq!(xhigh_fast.canonical_name, "gpt-5.5-xhigh-fast");
|
||||
assert_eq!(xhigh_fast.model_uid, Some("gpt-5-5-xhigh-priority"));
|
||||
assert_eq!(xhigh_fast.credit_multiplier, 16.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_windsurfapi_catalog_aliases_beyond_gpt55() {
|
||||
let gpt52_medium = resolve_windsurf_model("gpt-5.2-medium").expect("gpt-5.2 medium alias");
|
||||
assert_eq!(gpt52_medium.canonical_name, "gpt-5.2");
|
||||
assert_eq!(gpt52_medium.model_uid, Some("MODEL_GPT_5_2_MEDIUM"));
|
||||
|
||||
let haiku = resolve_windsurf_model("claude-haiku-4-5-20251001").expect("dated haiku alias");
|
||||
assert_eq!(haiku.canonical_name, "claude-4.5-haiku");
|
||||
assert_eq!(haiku.model_uid, Some("MODEL_PRIVATE_11"));
|
||||
|
||||
let uid = resolve_windsurf_model("MODEL_GPT_5_2_LOW").expect("model uid alias");
|
||||
assert_eq!(uid.canonical_name, "gpt-5.2-low");
|
||||
assert_eq!(uid.enum_value, 400);
|
||||
|
||||
let cursor = resolve_windsurf_model("ws-opus").expect("cursor alias");
|
||||
assert_eq!(cursor.canonical_name, "claude-opus-4.6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_catalog_covers_current_windsurfapi_model_set() {
|
||||
assert_eq!(MODELS.len(), 139);
|
||||
assert!(ALIASES.len() >= 100);
|
||||
}
|
||||
}
|
||||
248
crates/aether-provider-transport/src/windsurf/proto.rs
Normal file
248
crates/aether-provider-transport/src/windsurf/proto.rs
Normal file
@@ -0,0 +1,248 @@
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WireType {
|
||||
Varint = 0,
|
||||
Fixed64 = 1,
|
||||
Len = 2,
|
||||
Fixed32 = 5,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FieldValue {
|
||||
Varint(u64),
|
||||
Bytes(Vec<u8>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Field {
|
||||
pub number: u32,
|
||||
pub wire_type: WireType,
|
||||
pub value: FieldValue,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
pub fn bytes(&self) -> &[u8] {
|
||||
match &self.value {
|
||||
FieldValue::Bytes(bytes) => bytes.as_slice(),
|
||||
FieldValue::Varint(_) => &[],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProtoError {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl ProtoError {
|
||||
fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ProtoError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ProtoError {}
|
||||
|
||||
pub fn encode_varint(value: u64) -> Vec<u8> {
|
||||
let mut value = value;
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
let mut byte = (value & 0x7f) as u8;
|
||||
value >>= 7;
|
||||
if value != 0 {
|
||||
byte |= 0x80;
|
||||
}
|
||||
out.push(byte);
|
||||
if value == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn decode_varint(buf: &[u8], offset: usize) -> Result<(u64, usize), ProtoError> {
|
||||
let mut value = 0u64;
|
||||
let mut shift = 0u32;
|
||||
let mut pos = offset;
|
||||
while pos < buf.len() {
|
||||
let byte = buf[pos];
|
||||
pos += 1;
|
||||
value |= u64::from(byte & 0x7f) << shift;
|
||||
if byte & 0x80 == 0 {
|
||||
return Ok((value, pos - offset));
|
||||
}
|
||||
shift += 7;
|
||||
if shift >= 64 {
|
||||
return Err(ProtoError::new("varint overflow"));
|
||||
}
|
||||
}
|
||||
Err(ProtoError::new("truncated varint"))
|
||||
}
|
||||
|
||||
fn tag(field: u32, wire_type: WireType) -> Vec<u8> {
|
||||
encode_varint((u64::from(field) << 3) | wire_type as u64)
|
||||
}
|
||||
|
||||
pub fn write_varint_field(field: u32, value: u64) -> Vec<u8> {
|
||||
let mut out = tag(field, WireType::Varint);
|
||||
out.extend(encode_varint(value));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn write_string_field(field: u32, value: &str) -> Vec<u8> {
|
||||
let bytes = value.as_bytes();
|
||||
let mut out = tag(field, WireType::Len);
|
||||
out.extend(encode_varint(bytes.len() as u64));
|
||||
out.extend(bytes);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn write_message_field(field: u32, value: &[u8]) -> Vec<u8> {
|
||||
if value.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = tag(field, WireType::Len);
|
||||
out.extend(encode_varint(value.len() as u64));
|
||||
out.extend(value);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn write_bool_field(field: u32, value: bool) -> Vec<u8> {
|
||||
if value {
|
||||
write_varint_field(field, 1)
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_fields(buf: &[u8]) -> Result<Vec<Field>, ProtoError> {
|
||||
let mut fields = Vec::new();
|
||||
let mut pos = 0usize;
|
||||
while pos < buf.len() {
|
||||
let (tag, tag_len) = decode_varint(buf, pos)?;
|
||||
pos += tag_len;
|
||||
let number = (tag >> 3) as u32;
|
||||
let wire_type = match tag & 0x07 {
|
||||
0 => WireType::Varint,
|
||||
1 => WireType::Fixed64,
|
||||
2 => WireType::Len,
|
||||
5 => WireType::Fixed32,
|
||||
other => {
|
||||
return Err(ProtoError::new(format!(
|
||||
"unknown wire type {other} at offset {pos}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
let value = match wire_type {
|
||||
WireType::Varint => {
|
||||
let (value, value_len) = decode_varint(buf, pos)?;
|
||||
pos += value_len;
|
||||
FieldValue::Varint(value)
|
||||
}
|
||||
WireType::Len => {
|
||||
let (len, len_len) = decode_varint(buf, pos)?;
|
||||
pos += len_len;
|
||||
let len = usize::try_from(len)
|
||||
.map_err(|_| ProtoError::new("length-delimited field too large"))?;
|
||||
if pos + len > buf.len() {
|
||||
return Err(ProtoError::new(format!(
|
||||
"truncated len-delimited field {number} at offset {pos}"
|
||||
)));
|
||||
}
|
||||
let bytes = buf[pos..pos + len].to_vec();
|
||||
pos += len;
|
||||
FieldValue::Bytes(bytes)
|
||||
}
|
||||
WireType::Fixed64 => {
|
||||
if pos + 8 > buf.len() {
|
||||
return Err(ProtoError::new(format!("truncated fixed64 field {number}")));
|
||||
}
|
||||
let bytes = buf[pos..pos + 8].to_vec();
|
||||
pos += 8;
|
||||
FieldValue::Bytes(bytes)
|
||||
}
|
||||
WireType::Fixed32 => {
|
||||
if pos + 4 > buf.len() {
|
||||
return Err(ProtoError::new(format!("truncated fixed32 field {number}")));
|
||||
}
|
||||
let bytes = buf[pos..pos + 4].to_vec();
|
||||
pos += 4;
|
||||
FieldValue::Bytes(bytes)
|
||||
}
|
||||
};
|
||||
fields.push(Field {
|
||||
number,
|
||||
wire_type,
|
||||
value,
|
||||
});
|
||||
}
|
||||
Ok(fields)
|
||||
}
|
||||
|
||||
pub fn get_field(fields: &[Field], number: u32, wire_type: Option<WireType>) -> Option<&Field> {
|
||||
fields
|
||||
.iter()
|
||||
.find(|field| field.number == number && wire_type.is_none_or(|ty| field.wire_type == ty))
|
||||
}
|
||||
|
||||
pub fn get_all_fields(fields: &[Field], number: u32) -> Vec<&Field> {
|
||||
fields
|
||||
.iter()
|
||||
.filter(|field| field.number == number)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn get_varint(fields: &[Field], number: u32) -> Option<u64> {
|
||||
match get_field(fields, number, Some(WireType::Varint))?.value {
|
||||
FieldValue::Varint(value) => Some(value),
|
||||
FieldValue::Bytes(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_string(fields: &[Field], number: u32) -> Option<String> {
|
||||
let field = get_field(fields, number, Some(WireType::Len))?;
|
||||
String::from_utf8(field.bytes().to_vec()).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encodes_varint_and_string_fields_like_windsurfapi() {
|
||||
assert_eq!(encode_varint(300), vec![0xac, 0x02]);
|
||||
assert_eq!(
|
||||
write_string_field(3, "abc"),
|
||||
vec![0x1a, 0x03, b'a', b'b', b'c']
|
||||
);
|
||||
assert_eq!(write_bool_field(2, false), Vec::<u8>::new());
|
||||
assert_eq!(write_bool_field(2, true), vec![0x10, 0x01]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_repeated_len_delimited_fields() {
|
||||
let mut bytes = Vec::new();
|
||||
bytes.extend(write_string_field(1, "alpha"));
|
||||
bytes.extend(write_string_field(1, "beta"));
|
||||
bytes.extend(write_varint_field(2, 42));
|
||||
|
||||
let fields = parse_fields(&bytes).expect("fields should parse");
|
||||
assert_eq!(get_all_fields(&fields, 1).len(), 2);
|
||||
assert_eq!(get_string(&fields, 1).as_deref(), Some("alpha"));
|
||||
assert_eq!(get_varint(&fields, 2), Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncated_len_delimited_field() {
|
||||
let err = parse_fields(&[0x0a, 0x05, b'a']).expect_err("must reject truncated field");
|
||||
assert!(err.to_string().contains("truncated"));
|
||||
}
|
||||
}
|
||||
@@ -153,20 +153,16 @@ pub fn resolve_provider_model_name_with_model_directives(
|
||||
return None;
|
||||
}
|
||||
|
||||
if key_allowed_models
|
||||
.iter()
|
||||
.any(|value| value == requested_model_name)
|
||||
for candidate_name in
|
||||
requested_model_name_candidates(requested_model_name, enable_model_directives)
|
||||
{
|
||||
return Some((selected_provider_model_name, None));
|
||||
}
|
||||
|
||||
if enable_model_directives {
|
||||
if let Some(base_model) =
|
||||
aether_ai_formats::model_directive_base_model(requested_model_name)
|
||||
if key_allowed_models
|
||||
.iter()
|
||||
.any(|value| value == candidate_name.as_ref())
|
||||
{
|
||||
if key_allowed_models.iter().any(|value| value == &base_model) {
|
||||
return Some((selected_provider_model_name, Some(base_model)));
|
||||
}
|
||||
let matched = (candidate_name.as_ref() != requested_model_name)
|
||||
.then(|| candidate_name.into_owned());
|
||||
return Some((selected_provider_model_name, matched));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,10 +424,55 @@ fn requested_model_name_candidates(
|
||||
enable_model_directives: bool,
|
||||
) -> impl Iterator<Item = Cow<'_, str>> {
|
||||
let requested_model_name = requested_model_name.trim();
|
||||
let base_model = enable_model_directives
|
||||
.then(|| aether_ai_formats::model_directive_base_model(requested_model_name))
|
||||
.flatten();
|
||||
std::iter::once(Cow::Borrowed(requested_model_name)).chain(base_model.map(Cow::Owned))
|
||||
let mut candidates = Vec::new();
|
||||
push_model_name_candidate(&mut candidates, Cow::Borrowed(requested_model_name));
|
||||
for alias in requested_model_name_aliases(requested_model_name) {
|
||||
push_model_name_candidate(&mut candidates, Cow::Owned(alias));
|
||||
}
|
||||
if enable_model_directives {
|
||||
if let Some(base_model) =
|
||||
aether_ai_formats::model_directive_base_model(requested_model_name)
|
||||
{
|
||||
for alias in requested_model_name_aliases(&base_model) {
|
||||
push_model_name_candidate(&mut candidates, Cow::Owned(alias));
|
||||
}
|
||||
push_model_name_candidate(&mut candidates, Cow::Owned(base_model));
|
||||
}
|
||||
}
|
||||
candidates.into_iter()
|
||||
}
|
||||
|
||||
fn push_model_name_candidate<'a>(candidates: &mut Vec<Cow<'a, str>>, candidate: Cow<'a, str>) {
|
||||
if candidate.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
if candidates
|
||||
.iter()
|
||||
.any(|existing| existing.as_ref() == candidate.as_ref())
|
||||
{
|
||||
return;
|
||||
}
|
||||
candidates.push(candidate);
|
||||
}
|
||||
|
||||
fn requested_model_name_aliases(requested_model_name: &str) -> Vec<String> {
|
||||
let requested_model_name = requested_model_name.trim();
|
||||
let Some(alias) = windsurf_gpt55_model_alias(requested_model_name) else {
|
||||
return Vec::new();
|
||||
};
|
||||
vec![alias]
|
||||
}
|
||||
|
||||
fn windsurf_gpt55_model_alias(model_name: &str) -> Option<String> {
|
||||
let suffix = model_name
|
||||
.strip_prefix("gpt-5-5")
|
||||
.map(|suffix| format!("gpt-5.5{suffix}"))
|
||||
.or_else(|| {
|
||||
model_name
|
||||
.strip_prefix("gpt-5.5")
|
||||
.map(|suffix| format!("gpt-5-5{suffix}"))
|
||||
})?;
|
||||
(suffix != model_name).then_some(suffix)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -544,6 +585,39 @@ mod tests {
|
||||
assert_eq!(resolved.1.as_deref(), Some("gpt-5.4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_dashed_gpt55_alias_matches_dotted_model_name() {
|
||||
let row = sample_row("gpt-5.5-low", "gpt-5.5-low");
|
||||
|
||||
assert!(row_supports_requested_model(
|
||||
&row,
|
||||
"gpt-5-5-low",
|
||||
"openai:chat"
|
||||
));
|
||||
assert_eq!(
|
||||
resolve_requested_global_model_name_with_model_directives(
|
||||
&[row],
|
||||
"gpt-5-5-low",
|
||||
"openai:chat",
|
||||
false,
|
||||
)
|
||||
.as_deref(),
|
||||
Some("gpt-5.5-low")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_dashed_gpt55_alias_satisfies_key_allowed_models() {
|
||||
let mut row = sample_row("gpt-5.5-low", "windsurf-upstream-uid");
|
||||
row.key_allowed_models = Some(vec!["gpt-5.5-low".to_string()]);
|
||||
|
||||
let resolved = resolve_provider_model_name(&row, "gpt-5-5-low", "openai:chat")
|
||||
.expect("dashed alias should satisfy dotted allowed model");
|
||||
|
||||
assert_eq!(resolved.0, "windsurf-upstream-uid");
|
||||
assert_eq!(resolved.1.as_deref(), Some("gpt-5.5-low"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_scoped_default_mapping_limits_exact_global_model_match() {
|
||||
let mut row = sample_row("deepseek-v4-pro", "deepseek-v4-pro");
|
||||
|
||||
@@ -1739,7 +1739,7 @@ fn clone_usage_capture_value(value: Option<&Value>) -> Option<Value> {
|
||||
}
|
||||
|
||||
fn clone_usage_body_value(value: Option<&Value>) -> Option<Value> {
|
||||
value.cloned()
|
||||
value.cloned().map(mask_sensitive_body_fields)
|
||||
}
|
||||
|
||||
fn sanitize_usage_event_capture_fields(mut data: UsageEventData) -> UsageEventData {
|
||||
@@ -2057,6 +2057,52 @@ fn mask_sensitive_headers_in_json_value(value: Option<Value>) -> Option<Value> {
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn mask_sensitive_body_fields(mut value: Value) -> Value {
|
||||
mask_sensitive_body_fields_in_place(&mut value);
|
||||
value
|
||||
}
|
||||
|
||||
fn mask_sensitive_body_fields_in_place(value: &mut Value) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
for (key, value) in object.iter_mut() {
|
||||
if is_sensitive_body_key(key) {
|
||||
let replacement = if value.is_null() {
|
||||
Value::Null
|
||||
} else if let Some(text) = value.as_str() {
|
||||
Value::String(mask_sensitive_header_value(text))
|
||||
} else {
|
||||
Value::String(mask_sensitive_header_value(&value.to_string()))
|
||||
};
|
||||
*value = replacement;
|
||||
} else {
|
||||
mask_sensitive_body_fields_in_place(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
mask_sensitive_body_fields_in_place(item);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_sensitive_body_key(key: &str) -> bool {
|
||||
let normalized = key
|
||||
.chars()
|
||||
.filter(|ch| ch.is_ascii_alphanumeric())
|
||||
.collect::<String>()
|
||||
.to_ascii_lowercase();
|
||||
normalized.contains("token")
|
||||
|| normalized.contains("apikey")
|
||||
|| normalized.contains("password")
|
||||
|| normalized.contains("authorization")
|
||||
|| normalized.contains("secret")
|
||||
|| normalized == "cookie"
|
||||
}
|
||||
|
||||
fn resolve_error_category(status_code: u16, event_type: UsageEventType) -> Option<String> {
|
||||
match event_type {
|
||||
UsageEventType::Cancelled => Some("cancelled".to_string()),
|
||||
@@ -2111,6 +2157,11 @@ fn decode_body_for_storage(body_base64: Option<&str>) -> Option<Value> {
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(body_base64)
|
||||
.ok()?;
|
||||
if let Some(error_body) =
|
||||
aether_ai_formats::api::extract_provider_private_stream_error_body(None, &bytes)
|
||||
{
|
||||
return Some(error_body);
|
||||
}
|
||||
if let Ok(json_body) = serde_json::from_slice::<Value>(&bytes) {
|
||||
return Some(json_body);
|
||||
}
|
||||
@@ -2968,12 +3019,12 @@ mod tests {
|
||||
build_stream_terminal_usage_event, build_streaming_usage_record,
|
||||
build_sync_terminal_usage_event, build_sync_terminal_usage_payload_seed,
|
||||
build_sync_terminal_usage_seed, build_terminal_usage_context_seed,
|
||||
build_terminal_usage_event_from_seed, build_usage_event_data_seed,
|
||||
build_terminal_usage_event_from_seed, build_usage_event_data_seed, decode_body_for_storage,
|
||||
extract_token_counts_from_json, extract_token_counts_from_value, headers_to_json,
|
||||
mask_header_value, mask_sensitive_headers_in_json_value, parse_sse_body_for_storage,
|
||||
resolve_error_message, trim_owned_non_empty_string, LifecycleUsageSeed, TerminalUsageSeed,
|
||||
UsageBodyRefsSeed, UsageBodyStatesSeed, UsageRoutingSeed, UsageTerminalState,
|
||||
MAX_USAGE_CAPTURE_BYTES, MAX_USAGE_CAPTURE_DEPTH,
|
||||
mask_header_value, mask_sensitive_body_fields, mask_sensitive_headers_in_json_value,
|
||||
parse_sse_body_for_storage, resolve_error_message, trim_owned_non_empty_string,
|
||||
LifecycleUsageSeed, TerminalUsageSeed, UsageBodyRefsSeed, UsageBodyStatesSeed,
|
||||
UsageRoutingSeed, UsageTerminalState, MAX_USAGE_CAPTURE_BYTES, MAX_USAGE_CAPTURE_DEPTH,
|
||||
};
|
||||
use crate::{
|
||||
build_upsert_usage_record_from_event, GatewayStreamReportRequest, GatewaySyncReportRequest,
|
||||
@@ -3378,6 +3429,114 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_body_capture_redacts_nested_provider_secrets() {
|
||||
let masked = mask_sensitive_body_fields(json!({
|
||||
"metadata": {
|
||||
"apiKey": "devin-session-token$secret-value",
|
||||
"nested": {
|
||||
"sessionToken": "session-token-secret"
|
||||
}
|
||||
},
|
||||
"password": "plain-password",
|
||||
"messages": [{"content": "safe text"}]
|
||||
}));
|
||||
|
||||
assert_ne!(
|
||||
masked.pointer("/metadata/apiKey").and_then(Value::as_str),
|
||||
Some("devin-session-token$secret-value")
|
||||
);
|
||||
assert_ne!(
|
||||
masked
|
||||
.pointer("/metadata/nested/sessionToken")
|
||||
.and_then(Value::as_str),
|
||||
Some("session-token-secret")
|
||||
);
|
||||
assert_ne!(
|
||||
masked.get("password").and_then(Value::as_str),
|
||||
Some("plain-password")
|
||||
);
|
||||
assert_eq!(
|
||||
masked
|
||||
.pointer("/messages/0/content")
|
||||
.and_then(Value::as_str),
|
||||
Some("safe text")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_terminal_usage_redacts_provider_request_body_secrets_from_context() {
|
||||
let plan = ExecutionPlan {
|
||||
request_id: "req-sync-redact-provider-request-1".to_string(),
|
||||
candidate_id: Some("cand-sync-redact-provider-request-1".to_string()),
|
||||
provider_name: Some("Windsurf".to_string()),
|
||||
provider_id: "provider-windsurf".to_string(),
|
||||
endpoint_id: "endpoint-windsurf".to_string(),
|
||||
key_id: "key-windsurf".to_string(),
|
||||
method: "POST".to_string(),
|
||||
url: "https://server.codeium.com/exa.api_server_pb.ApiServerService/GetChatMessage"
|
||||
.to_string(),
|
||||
headers: BTreeMap::new(),
|
||||
content_type: Some("application/json".to_string()),
|
||||
content_encoding: None,
|
||||
body: RequestBody::from_json(json!({"model": "windsurf-model"})),
|
||||
stream: false,
|
||||
client_api_format: "openai:chat".to_string(),
|
||||
provider_api_format: "openai:chat".to_string(),
|
||||
model_name: Some("windsurf-model".to_string()),
|
||||
proxy: None,
|
||||
transport_profile: None,
|
||||
timeouts: None,
|
||||
};
|
||||
let payload = GatewaySyncReportRequest {
|
||||
trace_id: "trace-sync-redact-provider-request-1".to_string(),
|
||||
report_kind: "openai_chat_sync_success".to_string(),
|
||||
report_context: Some(json!({
|
||||
"client_api_format": "openai:chat",
|
||||
"provider_api_format": "openai:chat",
|
||||
"provider_request_body": {
|
||||
"metadata": {
|
||||
"apiKey": "devin-session-token$abc",
|
||||
"sessionToken": "session-token-secret"
|
||||
},
|
||||
"message": "safe prompt"
|
||||
}
|
||||
})),
|
||||
status_code: 200,
|
||||
headers: BTreeMap::new(),
|
||||
body_json: Some(json!({"id": "resp_1", "choices": []})),
|
||||
client_body_json: None,
|
||||
body_base64: None,
|
||||
telemetry: None,
|
||||
};
|
||||
|
||||
let event =
|
||||
build_sync_terminal_usage_event(&plan, payload.report_context.as_ref(), &payload)
|
||||
.expect("terminal usage should build");
|
||||
let provider_request = event
|
||||
.data
|
||||
.provider_request_body
|
||||
.as_ref()
|
||||
.expect("provider request body should be captured");
|
||||
|
||||
assert_ne!(
|
||||
provider_request
|
||||
.pointer("/metadata/apiKey")
|
||||
.and_then(Value::as_str),
|
||||
Some("devin-session-token$abc")
|
||||
);
|
||||
assert_ne!(
|
||||
provider_request
|
||||
.pointer("/metadata/sessionToken")
|
||||
.and_then(Value::as_str),
|
||||
Some("session-token-secret")
|
||||
);
|
||||
assert_eq!(
|
||||
provider_request.pointer("/message").and_then(Value::as_str),
|
||||
Some("safe prompt")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_stream_terminal_usage_from_provider_body_and_preserves_client_body() {
|
||||
let plan = ExecutionPlan {
|
||||
@@ -5249,6 +5408,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_body_for_storage_extracts_connect_json_error_frames() {
|
||||
let payload = br#"{"error":{"code":"resource_exhausted","message":"quota exhausted"}}"#;
|
||||
let mut framed = Vec::new();
|
||||
framed.push(2);
|
||||
framed.extend_from_slice(&(payload.len() as u32).to_be_bytes());
|
||||
framed.extend_from_slice(payload);
|
||||
let body_base64 = base64::engine::general_purpose::STANDARD.encode(framed);
|
||||
|
||||
assert_eq!(
|
||||
decode_body_for_storage(Some(body_base64.as_str())),
|
||||
Some(json!({
|
||||
"error": {
|
||||
"code": "resource_exhausted",
|
||||
"message": "quota exhausted",
|
||||
"type": "resource_exhausted"
|
||||
}
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sse_body_for_storage_handles_crlf_and_cr_line_endings() {
|
||||
let sse_body = concat!(
|
||||
|
||||
Reference in New Issue
Block a user