chore: handle empty strings in AI resource fields via serde deserializer (#7723)
* fix: handle empty strings in AI resource fields via serde deserializer Add `empty_string_as_none` deserializer that converts empty strings to None during deserialization. Applied to base_url, api_key, region, and AWS credential fields in AIStandardResource and ProviderResource. This fixes the "relative URL without a base" error when creating Anthropic resources with empty base_url fields. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * nit * nit * nit * cleaning * cleaning * cleaning * cleaning * fix: apply empty_string_as_none deserializer to api_key field Consistent with other fields in ProviderResource, empty strings are now deserialized as None for the api_key field. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, value::RawValue};
|
||||
use std::collections::HashMap;
|
||||
use windmill_audit::{audit_oss::audit_log, ActionKind};
|
||||
use windmill_common::ai_providers::{AIProvider, ProviderConfig, ProviderModel};
|
||||
use windmill_common::ai_providers::{empty_string_as_none, AIProvider, ProviderConfig, ProviderModel};
|
||||
use windmill_common::error::{to_anyhow, Error, Result};
|
||||
use windmill_common::utils::configure_client;
|
||||
use windmill_common::variables::get_variable_or_self;
|
||||
@@ -143,15 +143,17 @@ enum AnthropicPlatform {
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct AIStandardResource {
|
||||
#[serde(alias = "baseUrl")]
|
||||
#[serde(alias = "baseUrl", default, deserialize_with = "empty_string_as_none")]
|
||||
base_url: Option<String>,
|
||||
#[serde(alias = "apiKey")]
|
||||
#[serde(alias = "apiKey", default, deserialize_with = "empty_string_as_none")]
|
||||
api_key: Option<String>,
|
||||
#[serde(default, deserialize_with = "empty_string_as_none")]
|
||||
organization_id: Option<String>,
|
||||
#[serde(default, deserialize_with = "empty_string_as_none")]
|
||||
region: Option<String>,
|
||||
#[serde(alias = "awsAccessKeyId")]
|
||||
#[serde(alias = "awsAccessKeyId", default, deserialize_with = "empty_string_as_none")]
|
||||
aws_access_key_id: Option<String>,
|
||||
#[serde(alias = "awsSecretAccessKey")]
|
||||
#[serde(alias = "awsSecretAccessKey", default, deserialize_with = "empty_string_as_none")]
|
||||
aws_secret_access_key: Option<String>,
|
||||
/// Platform for Anthropic API (standard or google_vertex_ai)
|
||||
#[serde(default)]
|
||||
@@ -207,9 +209,14 @@ impl AIRequestConfig {
|
||||
AIResource::Standard(resource) => {
|
||||
let region = resource.region.clone();
|
||||
let platform = resource.platform.clone();
|
||||
let base_url = provider
|
||||
.get_base_url(resource.base_url, resource.region, db)
|
||||
.await?;
|
||||
// Skip get_base_url for Bedrock - it uses SDK directly, not HTTP
|
||||
let base_url = if matches!(provider, AIProvider::AWSBedrock) {
|
||||
String::new()
|
||||
} else {
|
||||
provider
|
||||
.get_base_url(resource.base_url, db)
|
||||
.await?
|
||||
};
|
||||
let api_key = if let Some(api_key) = resource.api_key {
|
||||
Some(get_variable_or_self(api_key, db, w_id).await?)
|
||||
} else {
|
||||
@@ -251,7 +258,7 @@ impl AIRequestConfig {
|
||||
None
|
||||
};
|
||||
let token = Self::get_token_using_oauth(resource, db, w_id).await?;
|
||||
let base_url = provider.get_base_url(None, None, db).await?;
|
||||
let base_url = provider.get_base_url(None, db).await?;
|
||||
|
||||
(
|
||||
None,
|
||||
@@ -578,7 +585,7 @@ async fn global_proxy(
|
||||
return Err(Error::BadRequest("API key is required".to_string()));
|
||||
};
|
||||
|
||||
let base_url = provider.get_base_url(None, None, &db).await?;
|
||||
let base_url = provider.get_base_url(None, &db).await?;
|
||||
|
||||
let url = format!("{}/{}", base_url, ai_path);
|
||||
|
||||
@@ -745,7 +752,7 @@ async fn proxy(
|
||||
let region = request_config
|
||||
.region
|
||||
.as_deref()
|
||||
.ok_or_else(|| Error::internal_err("AWS region must be set for Bedrock"))?;
|
||||
.unwrap_or(windmill_common::ai_providers::USE_ENV_REGION);
|
||||
|
||||
// Audit log before making the SDK request
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
@@ -4,7 +4,17 @@
|
||||
|
||||
use crate::db::DB;
|
||||
use crate::error::{Error, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
/// Deserializes an Option<String> where empty strings become None.
|
||||
/// Use with `#[serde(default, deserialize_with = "empty_string_as_none")]`
|
||||
pub fn empty_string_as_none<'de, D>(deserializer: D) -> std::result::Result<Option<String>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let opt = Option::<String>::deserialize(deserializer)?;
|
||||
Ok(opt.filter(|s| !s.is_empty()))
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref OPENAI_AZURE_BASE_PATH: Option<String> = std::env::var("OPENAI_AZURE_BASE_PATH").ok();
|
||||
@@ -13,6 +23,10 @@ lazy_static::lazy_static! {
|
||||
pub const OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
|
||||
pub const GOOGLE_AI_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta";
|
||||
|
||||
/// Empty string signals BedrockClient::from_env() to use the region from AWS environment/config
|
||||
/// (e.g., AWS_REGION or AWS_DEFAULT_REGION env vars, or ~/.aws/config)
|
||||
pub const USE_ENV_REGION: &str = "";
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Hash, Clone)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AIProvider {
|
||||
@@ -36,11 +50,9 @@ impl AIProvider {
|
||||
pub async fn get_base_url(
|
||||
&self,
|
||||
resource_base_url: Option<String>,
|
||||
region: Option<String>,
|
||||
db: &DB,
|
||||
) -> Result<String> {
|
||||
// If a base URL is provided in the resource, use it (ignore empty strings)
|
||||
if let Some(base_url) = resource_base_url.filter(|s| !s.is_empty()) {
|
||||
if let Some(base_url) = resource_base_url {
|
||||
return Ok(base_url);
|
||||
}
|
||||
|
||||
@@ -78,23 +90,10 @@ impl AIProvider {
|
||||
format!("{:?} provider requires a base URL in the resource", p),
|
||||
)),
|
||||
AIProvider::AWSBedrock => {
|
||||
#[cfg(feature = "bedrock")]
|
||||
{
|
||||
Ok(format!(
|
||||
"https://bedrock-runtime.{}.amazonaws.com",
|
||||
region
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "us-east-1".to_string())
|
||||
))
|
||||
}
|
||||
#[cfg(not(feature = "bedrock"))]
|
||||
{
|
||||
let _ = region;
|
||||
Err(Error::BadRequest(
|
||||
"AWS Bedrock support is not enabled. Build with 'bedrock' feature."
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
// AWS Bedrock uses the SDK directly, not HTTP base URL
|
||||
Err(Error::internal_err(
|
||||
"AWS Bedrock uses SDK directly, not HTTP base URL".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,10 +51,9 @@ impl BedrockQueryBuilder {
|
||||
) -> Result<ParsedResponse, Error> {
|
||||
let bedrock_client = if !api_key.is_empty() {
|
||||
BedrockClient::from_bearer_token(api_key.to_string(), region).await?
|
||||
} else if let (Some(access_key_id), Some(secret_access_key)) = (
|
||||
aws_access_key_id.filter(|s| !s.is_empty()),
|
||||
aws_secret_access_key.filter(|s| !s.is_empty()),
|
||||
) {
|
||||
} else if let (Some(access_key_id), Some(secret_access_key)) =
|
||||
(aws_access_key_id, aws_secret_access_key)
|
||||
{
|
||||
BedrockClient::from_credentials(
|
||||
access_key_id.to_string(),
|
||||
secret_access_key.to_string(),
|
||||
|
||||
@@ -14,7 +14,11 @@ pub struct McpToolSource {
|
||||
pub resource_path: String,
|
||||
}
|
||||
use windmill_common::{
|
||||
ai_providers::AIProvider, db::DB, error::Error, flow_status::AgentAction, flows::FlowModule,
|
||||
ai_providers::{empty_string_as_none, AIProvider},
|
||||
db::DB,
|
||||
error::Error,
|
||||
flow_status::AgentAction,
|
||||
flows::FlowModule,
|
||||
s3_helpers::S3Object,
|
||||
};
|
||||
use windmill_parser::Typ;
|
||||
@@ -162,17 +166,18 @@ pub enum AnthropicPlatform {
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ProviderResource {
|
||||
#[serde(alias = "apiKey")]
|
||||
#[serde(alias = "apiKey", default, deserialize_with = "empty_string_as_none")]
|
||||
pub api_key: Option<String>,
|
||||
#[serde(alias = "baseUrl")]
|
||||
#[serde(alias = "baseUrl", default, deserialize_with = "empty_string_as_none")]
|
||||
pub base_url: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
#[serde(default, deserialize_with = "empty_string_as_none")]
|
||||
pub region: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
#[serde(alias = "awsAccessKeyId")]
|
||||
#[serde(alias = "awsAccessKeyId", default, deserialize_with = "empty_string_as_none")]
|
||||
pub aws_access_key_id: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
#[serde(alias = "awsSecretAccessKey")]
|
||||
#[serde(alias = "awsSecretAccessKey", default, deserialize_with = "empty_string_as_none")]
|
||||
pub aws_secret_access_key: Option<String>,
|
||||
/// Platform for Anthropic API (standard or google_vertex_ai)
|
||||
#[serde(default)]
|
||||
@@ -199,7 +204,6 @@ impl ProviderWithResource {
|
||||
self.kind
|
||||
.get_base_url(
|
||||
self.resource.base_url.clone(),
|
||||
self.resource.region.clone(),
|
||||
db,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -21,7 +21,7 @@ use windmill_mcp::McpClient;
|
||||
#[cfg(not(feature = "mcp"))]
|
||||
use crate::ai::tools::McpClientStub as McpClient;
|
||||
use windmill_common::{
|
||||
ai_providers::AIProvider,
|
||||
ai_providers::{AIProvider},
|
||||
cache,
|
||||
client::AuthedClient,
|
||||
db::DB,
|
||||
@@ -410,9 +410,14 @@ pub async fn run_agent(
|
||||
has_websearch: bool,
|
||||
) -> error::Result<Box<RawValue>> {
|
||||
let output_type = args.output_type.as_ref().unwrap_or(&OutputType::Text);
|
||||
let base_url = args.provider.get_base_url(db).await?;
|
||||
// Skip get_base_url for Bedrock - it uses SDK directly, not HTTP
|
||||
let base_url = if args.provider.kind == AIProvider::AWSBedrock {
|
||||
String::new()
|
||||
} else {
|
||||
args.provider.get_base_url(db).await?
|
||||
};
|
||||
let api_key = args.provider.get_api_key().unwrap_or("");
|
||||
|
||||
|
||||
// Create the query builder for the provider
|
||||
let query_builder = create_query_builder(&args.provider);
|
||||
|
||||
@@ -660,12 +665,7 @@ pub async fn run_agent(
|
||||
let parsed = if args.provider.kind == AIProvider::AWSBedrock {
|
||||
#[cfg(feature = "bedrock")]
|
||||
{
|
||||
let region = args.provider.get_region();
|
||||
let Some(region) = region else {
|
||||
return Err(Error::internal_err(
|
||||
"AWS Bedrock region is required".to_string(),
|
||||
));
|
||||
};
|
||||
let region = args.provider.get_region().unwrap_or(windmill_common::ai_providers::USE_ENV_REGION);
|
||||
// Use Bedrock SDK via dedicated query builder
|
||||
crate::ai::providers::bedrock::BedrockQueryBuilder::default()
|
||||
.execute_request(
|
||||
|
||||
Reference in New Issue
Block a user