mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 16:19:21 +00:00
fix: persist OpenAI-compatible provider and respect embeddings disable (#177)
* fix: persist OpenAI-compatible provider and respect embeddings disable (#129) Three interrelated bugs caused the agent to ignore user choices made during onboarding when using an OpenAI-compatible LLM provider: 1. Session auth ran before DB config reload, so Config::from_env() defaulted to NearAi and attempted Clerk auth before the real backend was known. Moved session auth to after final config resolution. 2. EmbeddingsConfig::resolve() force-enabled embeddings whenever OPENAI_API_KEY was present, ignoring the user's explicit disable. Changed to respect the stored setting as source of truth. 3. LLM_BACKEND was not saved to the bootstrap .env file, so Config::from_env() always defaulted to NearAi before the DB was connected. Now saves LLM_BACKEND, LLM_BASE_URL, and OLLAMA_BASE_URL alongside the database bootstrap vars. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: add SAFETY comments and sanitize .env value escaping Address PR review feedback: - Add SAFETY comments to all unsafe env var manipulation in config tests (gemini-code-assist). - Escape backslashes and double quotes in save_bootstrap_env() to prevent env var injection via malicious URLs (gemini-code-assist). - Add test verifying injection attempt is neutralized. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: incorporate PR #138 changes (chat completions, model sorting, tool schemas) Includes all changes from bigguybobby's PR #138: - Use Chat Completions API for OpenAI-compatible providers (avoids Responses API assumptions like required tool call IDs) - Fall back to settings.selected_model when LLM_MODEL env var is unset - Update OpenAI model list (add gpt-5 family) with priority-based sorting - Add is_openai_chat_model() filter with broader exclusion patterns - Fix http tool: headers schema → array of {name,value}, body → string type, parse_headers_param() accepts both legacy object and array formats - Fix json tool: data schema → string type, parse_json_input() normalizer, validate uses strict string-only check - Add mutex-serialized config tests for env var manipulation - Update NEAR AI config comment for accuracy Co-Authored-By: Bobby (bigguybobby) <[email protected]> Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Illia Polosukhin <[email protected]> Co-authored-by: Claude Opus 4.6 <[email protected]> Co-authored-by: Bobby (bigguybobby) <[email protected]>
This commit is contained in:
co-authored by
Illia Polosukhin
Claude Opus 4.6
Bobby
parent
c3340c60ef
commit
750a94030b
+115
-18
@@ -106,6 +106,46 @@ fn is_disallowed_ip(ip: &IpAddr) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_headers_param(
|
||||
headers: Option<&serde_json::Value>,
|
||||
) -> Result<Vec<(String, String)>, ToolError> {
|
||||
match headers {
|
||||
None => Ok(Vec::new()),
|
||||
Some(serde_json::Value::Object(map)) => {
|
||||
let mut out = Vec::with_capacity(map.len());
|
||||
for (k, v) in map {
|
||||
let value = v.as_str().ok_or_else(|| {
|
||||
ToolError::InvalidParameters(format!("header '{}' must have a string value", k))
|
||||
})?;
|
||||
out.push((k.clone(), value.to_string()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
Some(serde_json::Value::Array(items)) => {
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
for (idx, item) in items.iter().enumerate() {
|
||||
let obj = item.as_object().ok_or_else(|| {
|
||||
ToolError::InvalidParameters(format!(
|
||||
"headers[{}] must be an object with 'name' and 'value'",
|
||||
idx
|
||||
))
|
||||
})?;
|
||||
let name = obj.get("name").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
ToolError::InvalidParameters(format!("headers[{}].name must be a string", idx))
|
||||
})?;
|
||||
let value = obj.get("value").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
ToolError::InvalidParameters(format!("headers[{}].value must be a string", idx))
|
||||
})?;
|
||||
out.push((name.to_string(), value.to_string()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
Some(_) => Err(ToolError::InvalidParameters(
|
||||
"'headers' must be an object or an array of {name, value}".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HttpTool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
@@ -136,12 +176,21 @@ impl Tool for HttpTool {
|
||||
"description": "The URL to request"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" },
|
||||
"description": "HTTP headers to include"
|
||||
"type": "array",
|
||||
"description": "Optional headers as a list of {name, value} objects",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"value": { "type": "string" }
|
||||
},
|
||||
"required": ["name", "value"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"description": "Request body (for POST/PUT/PATCH)"
|
||||
"type": "string",
|
||||
"description": "Request body. Use plain text or serialized JSON."
|
||||
},
|
||||
"timeout_secs": {
|
||||
"type": "integer",
|
||||
@@ -165,14 +214,7 @@ impl Tool for HttpTool {
|
||||
let parsed_url = validate_url(url)?;
|
||||
|
||||
// Parse headers
|
||||
let headers: HashMap<String, String> = params
|
||||
.get("headers")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
let headers_vec: Vec<(String, String)> = headers
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
let headers_vec = parse_headers_param(params.get("headers"))?;
|
||||
|
||||
// Build request
|
||||
let mut request = match method.to_uppercase().as_str() {
|
||||
@@ -190,16 +232,31 @@ impl Tool for HttpTool {
|
||||
};
|
||||
|
||||
// Add headers
|
||||
for (key, value) in headers {
|
||||
request = request.header(&key, &value);
|
||||
for (key, value) in &headers_vec {
|
||||
request = request.header(key.as_str(), value.as_str());
|
||||
}
|
||||
|
||||
// Add body if present
|
||||
let body_bytes = if let Some(body) = params.get("body") {
|
||||
let bytes = serde_json::to_vec(body)
|
||||
.map_err(|e| ToolError::InvalidParameters(format!("invalid body JSON: {}", e)))?;
|
||||
request = request.json(body);
|
||||
Some(bytes)
|
||||
if let Some(body_str) = body.as_str() {
|
||||
if let Ok(json_body) = serde_json::from_str::<serde_json::Value>(body_str) {
|
||||
let bytes = serde_json::to_vec(&json_body).map_err(|e| {
|
||||
ToolError::InvalidParameters(format!("invalid body JSON: {}", e))
|
||||
})?;
|
||||
request = request.json(&json_body);
|
||||
Some(bytes)
|
||||
} else {
|
||||
let bytes = body_str.as_bytes().to_vec();
|
||||
request = request.body(body_str.to_string());
|
||||
Some(bytes)
|
||||
}
|
||||
} else {
|
||||
let bytes = serde_json::to_vec(body).map_err(|e| {
|
||||
ToolError::InvalidParameters(format!("invalid body JSON: {}", e))
|
||||
})?;
|
||||
request = request.json(body);
|
||||
Some(bytes)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -304,6 +361,20 @@ impl Tool for HttpTool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_http_tool_schema_body_has_type() {
|
||||
let tool = HttpTool::new();
|
||||
let schema = tool.parameters_schema();
|
||||
assert_eq!(schema["properties"]["body"]["type"], "string");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_http_tool_schema_headers_is_array() {
|
||||
let tool = HttpTool::new();
|
||||
let schema = tool.parameters_schema();
|
||||
assert_eq!(schema["properties"]["headers"]["type"], "array");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_url_rejects_http() {
|
||||
let err = validate_url("http://example.com").unwrap_err();
|
||||
@@ -363,4 +434,30 @@ mod tests {
|
||||
// MAX_RESPONSE_SIZE should be 5 MB to prevent OOM while allowing typical API responses.
|
||||
assert_eq!(MAX_RESPONSE_SIZE, 5 * 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_headers_param_accepts_object_legacy_shape() {
|
||||
let headers = serde_json::json!({"Authorization": "Bearer token"});
|
||||
let parsed = parse_headers_param(Some(&headers)).unwrap();
|
||||
assert_eq!(
|
||||
parsed,
|
||||
vec![("Authorization".to_string(), "Bearer token".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_headers_param_accepts_array_shape() {
|
||||
let headers = serde_json::json!([
|
||||
{"name": "Authorization", "value": "Bearer token"},
|
||||
{"name": "X-Test", "value": "1"}
|
||||
]);
|
||||
let parsed = parse_headers_param(Some(&headers)).unwrap();
|
||||
assert_eq!(
|
||||
parsed,
|
||||
vec![
|
||||
("Authorization".to_string(), "Bearer token".to_string()),
|
||||
("X-Test".to_string(), "1".to_string())
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ impl Tool for JsonTool {
|
||||
"description": "The JSON operation to perform"
|
||||
},
|
||||
"data": {
|
||||
"description": "The JSON data to operate on (string for parse, object otherwise)"
|
||||
"type": "string",
|
||||
"description": "JSON input string. For query/stringify/validate, pass serialized JSON."
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
@@ -64,7 +65,8 @@ impl Tool for JsonTool {
|
||||
parsed
|
||||
}
|
||||
"stringify" => {
|
||||
let json_str = serde_json::to_string_pretty(data).map_err(|e| {
|
||||
let value = parse_json_input(data)?;
|
||||
let json_str = serde_json::to_string_pretty(&value).map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!("failed to stringify: {}", e))
|
||||
})?;
|
||||
|
||||
@@ -75,14 +77,14 @@ impl Tool for JsonTool {
|
||||
ToolError::InvalidParameters("missing 'path' parameter for query".to_string())
|
||||
})?;
|
||||
|
||||
query_json(data, path)?
|
||||
let value = parse_json_input(data)?;
|
||||
query_json(&value, path)?
|
||||
}
|
||||
"validate" => {
|
||||
let is_valid = if let Some(s) = data.as_str() {
|
||||
serde_json::from_str::<serde_json::Value>(s).is_ok()
|
||||
} else {
|
||||
true // Already a valid JSON value
|
||||
};
|
||||
let is_valid = data
|
||||
.as_str()
|
||||
.map(|s| serde_json::from_str::<serde_json::Value>(s).is_ok())
|
||||
.unwrap_or(false);
|
||||
|
||||
serde_json::json!({ "valid": is_valid })
|
||||
}
|
||||
@@ -102,6 +104,14 @@ impl Tool for JsonTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_json_input(data: &serde_json::Value) -> Result<serde_json::Value, ToolError> {
|
||||
let json_str = data
|
||||
.as_str()
|
||||
.ok_or_else(|| ToolError::InvalidParameters("'data' must be a JSON string".to_string()))?;
|
||||
serde_json::from_str(json_str)
|
||||
.map_err(|e| ToolError::InvalidParameters(format!("invalid JSON input: {}", e)))
|
||||
}
|
||||
|
||||
/// Simple JSONPath-like query implementation.
|
||||
fn query_json(data: &serde_json::Value, path: &str) -> Result<serde_json::Value, ToolError> {
|
||||
let mut current = data;
|
||||
@@ -144,6 +154,13 @@ fn query_json(data: &serde_json::Value, path: &str) -> Result<serde_json::Value,
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_json_tool_schema_data_has_type() {
|
||||
let tool = JsonTool;
|
||||
let schema = tool.parameters_schema();
|
||||
assert_eq!(schema["properties"]["data"]["type"], "string");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_json() {
|
||||
let data = serde_json::json!({
|
||||
@@ -166,4 +183,18 @@ mod tests {
|
||||
serde_json::json!(3)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_json_input_accepts_valid_json_string() {
|
||||
let input = serde_json::json!("{\"ok\":true}");
|
||||
let parsed = parse_json_input(&input).unwrap();
|
||||
assert_eq!(parsed, serde_json::json!({"ok": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_json_input_rejects_invalid_json_string() {
|
||||
let input = serde_json::json!("{not valid json}");
|
||||
let err = parse_json_input(&input).unwrap_err();
|
||||
assert!(err.to_string().contains("invalid JSON input"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user