mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-07-30 06:32:17 +00:00
PR fixes
This commit is contained in:
Generated
+36
-15
@@ -1071,6 +1071,17 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cron"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5877d3fbf742507b66bc2a1945106bd30dd8504019d596901ddd012a4dd01740"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"once_cell",
|
||||
"winnow 0.6.26",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam"
|
||||
version = "0.8.4"
|
||||
@@ -3650,7 +3661,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-api"
|
||||
version = "0.1.0"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3686,7 +3697,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-channels"
|
||||
version = "0.1.0"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3713,7 +3724,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -3740,7 +3751,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-desktop"
|
||||
version = "0.1.0"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"open",
|
||||
@@ -3766,7 +3777,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-extensions"
|
||||
version = "0.1.0"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -3794,7 +3805,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-hands"
|
||||
version = "0.1.0"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap",
|
||||
@@ -3811,10 +3822,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-kernel"
|
||||
version = "0.1.0"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"cron",
|
||||
"crossbeam",
|
||||
"dashmap",
|
||||
"dirs 6.0.0",
|
||||
@@ -3846,7 +3858,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-memory"
|
||||
version = "0.1.0"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -3865,7 +3877,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-migrate"
|
||||
version = "0.1.0"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
@@ -3884,7 +3896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-runtime"
|
||||
version = "0.1.0"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -3915,7 +3927,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-skills"
|
||||
version = "0.1.0"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
@@ -3937,7 +3949,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-types"
|
||||
version = "0.1.0"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -3956,7 +3968,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-wire"
|
||||
version = "0.1.0"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4638,7 +4650,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8250,6 +8262,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.6.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.14"
|
||||
@@ -8491,7 +8512,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
version = "0.1.6"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
|
||||
@@ -6930,6 +6930,36 @@ pub async fn patch_agent_config(
|
||||
}
|
||||
};
|
||||
|
||||
// Input length limits
|
||||
const MAX_NAME_LEN: usize = 256;
|
||||
const MAX_DESC_LEN: usize = 4096;
|
||||
const MAX_PROMPT_LEN: usize = 65_536;
|
||||
|
||||
if let Some(ref name) = req.name {
|
||||
if name.len() > MAX_NAME_LEN {
|
||||
return (
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
Json(serde_json::json!({"error": format!("Name exceeds max length ({MAX_NAME_LEN} chars)")})),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(ref desc) = req.description {
|
||||
if desc.len() > MAX_DESC_LEN {
|
||||
return (
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
Json(serde_json::json!({"error": format!("Description exceeds max length ({MAX_DESC_LEN} chars)")})),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(ref prompt) = req.system_prompt {
|
||||
if prompt.len() > MAX_PROMPT_LEN {
|
||||
return (
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
Json(serde_json::json!({"error": format!("System prompt exceeds max length ({MAX_PROMPT_LEN} chars)")})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate color format if provided
|
||||
if let Some(ref color) = req.color {
|
||||
if !color.is_empty() && !color.starts_with('#') {
|
||||
@@ -7069,6 +7099,13 @@ pub async fn clone_agent(
|
||||
}
|
||||
};
|
||||
|
||||
if req.new_name.len() > 256 {
|
||||
return (
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
Json(serde_json::json!({"error": "Name exceeds max length (256 chars)"})),
|
||||
);
|
||||
}
|
||||
|
||||
if req.new_name.trim().is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
|
||||
@@ -1523,7 +1523,7 @@
|
||||
<th>Agent</th>
|
||||
<th>Status</th>
|
||||
<th>Last Run</th>
|
||||
<th>Runs</th>
|
||||
<th>Next Run</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1543,7 +1543,7 @@
|
||||
<span class="badge" :class="job.enabled ? 'badge-success' : 'badge-dim'" x-text="job.enabled ? 'Active' : 'Paused'"></span>
|
||||
</td>
|
||||
<td class="text-xs" :title="formatTime(job.last_run)" x-text="relativeTime(job.last_run)"></td>
|
||||
<td class="text-xs" x-text="job.run_count || 0"></td>
|
||||
<td class="text-xs" :title="formatTime(job.next_run)" x-text="relativeTime(job.next_run)"></td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
<button class="btn btn-primary btn-sm" @click="runNow(job)" :disabled="runningJobId === job.id">
|
||||
|
||||
@@ -62,8 +62,29 @@ function schedulerPage() {
|
||||
},
|
||||
|
||||
async loadJobs() {
|
||||
var data = await OpenFangAPI.get('/api/schedules');
|
||||
this.jobs = data.schedules || [];
|
||||
var data = await OpenFangAPI.get('/api/cron/jobs');
|
||||
var raw = data.jobs || [];
|
||||
// Normalize cron API response to flat fields the UI expects
|
||||
this.jobs = raw.map(function(j) {
|
||||
var cron = '';
|
||||
if (j.schedule) {
|
||||
if (j.schedule.kind === 'cron') cron = j.schedule.expr || '';
|
||||
else if (j.schedule.kind === 'every') cron = 'every ' + j.schedule.every_secs + 's';
|
||||
else if (j.schedule.kind === 'at') cron = 'at ' + (j.schedule.at || '');
|
||||
}
|
||||
return {
|
||||
id: j.id,
|
||||
name: j.name,
|
||||
cron: cron,
|
||||
agent_id: j.agent_id,
|
||||
message: j.action ? j.action.message || '' : '',
|
||||
enabled: j.enabled,
|
||||
last_run: j.last_run,
|
||||
next_run: j.next_run,
|
||||
delivery: j.delivery ? j.delivery.kind || '' : '',
|
||||
created_at: j.created_at
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async loadTriggers() {
|
||||
@@ -82,25 +103,20 @@ function schedulerPage() {
|
||||
async loadHistory() {
|
||||
this.historyLoading = true;
|
||||
try {
|
||||
// Build history from jobs with run data + recent audit entries
|
||||
var historyItems = [];
|
||||
|
||||
// Add job run info from schedule data
|
||||
var jobs = this.jobs || [];
|
||||
for (var i = 0; i < jobs.length; i++) {
|
||||
var job = jobs[i];
|
||||
if (job.last_run) {
|
||||
historyItems.push({
|
||||
timestamp: job.last_run,
|
||||
name: job.name || job.description || '(unnamed)',
|
||||
name: job.name || '(unnamed)',
|
||||
type: 'schedule',
|
||||
status: 'completed',
|
||||
run_count: job.run_count || 0
|
||||
run_count: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Also load trigger fire counts
|
||||
var triggers = this.triggers || [];
|
||||
for (var j = 0; j < triggers.length; j++) {
|
||||
var t = triggers[j];
|
||||
@@ -114,12 +130,9 @@ function schedulerPage() {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp descending
|
||||
historyItems.sort(function(a, b) {
|
||||
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
|
||||
});
|
||||
|
||||
this.history = historyItems;
|
||||
} catch(e) {
|
||||
this.history = [];
|
||||
@@ -141,13 +154,15 @@ function schedulerPage() {
|
||||
this.creating = true;
|
||||
try {
|
||||
var jobName = this.newJob.name;
|
||||
await OpenFangAPI.post('/api/schedules', {
|
||||
name: this.newJob.name,
|
||||
cron: this.newJob.cron,
|
||||
var body = {
|
||||
agent_id: this.newJob.agent_id,
|
||||
message: this.newJob.message,
|
||||
name: this.newJob.name,
|
||||
schedule: { kind: 'cron', expr: this.newJob.cron },
|
||||
action: { kind: 'agent_turn', message: this.newJob.message || 'Scheduled task: ' + this.newJob.name },
|
||||
delivery: { kind: 'last_channel' },
|
||||
enabled: this.newJob.enabled
|
||||
});
|
||||
};
|
||||
await OpenFangAPI.post('/api/cron/jobs', body);
|
||||
this.showCreateForm = false;
|
||||
this.newJob = { name: '', cron: '', agent_id: '', message: '', enabled: true };
|
||||
OpenFangToast.success('Schedule "' + jobName + '" created');
|
||||
@@ -161,7 +176,7 @@ function schedulerPage() {
|
||||
async toggleJob(job) {
|
||||
try {
|
||||
var newState = !job.enabled;
|
||||
await OpenFangAPI.put('/api/schedules/' + job.id, { enabled: newState });
|
||||
await OpenFangAPI.put('/api/cron/jobs/' + job.id + '/enable', { enabled: newState });
|
||||
job.enabled = newState;
|
||||
OpenFangToast.success('Schedule ' + (newState ? 'enabled' : 'paused'));
|
||||
} catch(e) {
|
||||
@@ -174,7 +189,7 @@ function schedulerPage() {
|
||||
var jobName = job.name || job.id;
|
||||
OpenFangToast.confirm('Delete Schedule', 'Delete "' + jobName + '"? This cannot be undone.', async function() {
|
||||
try {
|
||||
await OpenFangAPI.del('/api/schedules/' + job.id);
|
||||
await OpenFangAPI.del('/api/cron/jobs/' + job.id);
|
||||
self.jobs = self.jobs.filter(function(j) { return j.id !== job.id; });
|
||||
OpenFangToast.success('Schedule "' + jobName + '" deleted');
|
||||
} catch(e) {
|
||||
@@ -189,19 +204,17 @@ function schedulerPage() {
|
||||
var result = await OpenFangAPI.post('/api/schedules/' + job.id + '/run', {});
|
||||
if (result.status === 'completed') {
|
||||
OpenFangToast.success('Schedule "' + (job.name || 'job') + '" executed successfully');
|
||||
// Update the job's last_run locally
|
||||
job.last_run = new Date().toISOString();
|
||||
job.run_count = (job.run_count || 0) + 1;
|
||||
} else {
|
||||
OpenFangToast.error('Schedule run failed: ' + (result.error || 'Unknown error'));
|
||||
}
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to run schedule: ' + (e.message || e));
|
||||
OpenFangToast.error('Run Now is not yet available for cron jobs');
|
||||
}
|
||||
this.runningJobId = '';
|
||||
},
|
||||
|
||||
// ── Trigger helpers (reused from workflows page) ──
|
||||
// ── Trigger helpers ──
|
||||
|
||||
triggerType(pattern) {
|
||||
if (!pattern) return 'unknown';
|
||||
@@ -259,13 +272,16 @@ function schedulerPage() {
|
||||
for (var i = 0; i < agents.length; i++) {
|
||||
if (agents[i].id === agentId) return agents[i].name;
|
||||
}
|
||||
// Truncate UUID
|
||||
if (agentId.length > 12) return agentId.substring(0, 8) + '...';
|
||||
return agentId;
|
||||
},
|
||||
|
||||
describeCron(expr) {
|
||||
if (!expr) return '';
|
||||
// Handle non-cron schedule descriptions
|
||||
if (expr.indexOf('every ') === 0) return expr;
|
||||
if (expr.indexOf('at ') === 0) return 'One-time: ' + expr.substring(3);
|
||||
|
||||
var map = {
|
||||
'* * * * *': 'Every minute',
|
||||
'*/2 * * * *': 'Every 2 minutes',
|
||||
@@ -291,7 +307,6 @@ function schedulerPage() {
|
||||
};
|
||||
if (map[expr]) return map[expr];
|
||||
|
||||
// Try to parse common patterns
|
||||
var parts = expr.split(' ');
|
||||
if (parts.length !== 5) return expr;
|
||||
|
||||
@@ -301,22 +316,26 @@ function schedulerPage() {
|
||||
var mon = parts[3];
|
||||
var dow = parts[4];
|
||||
|
||||
// "*/N * * * *" patterns
|
||||
if (min.indexOf('*/') === 0 && hour === '*' && dom === '*' && mon === '*' && dow === '*') {
|
||||
return 'Every ' + min.substring(2) + ' minutes';
|
||||
}
|
||||
// "0 */N * * *" patterns
|
||||
if (min === '0' && hour.indexOf('*/') === 0 && dom === '*' && mon === '*' && dow === '*') {
|
||||
return 'Every ' + hour.substring(2) + ' hours';
|
||||
}
|
||||
// "M H * * *" — daily at specific time
|
||||
if (dom === '*' && mon === '*' && dow === '*' && min.match(/^\d+$/) && hour.match(/^\d+$/)) {
|
||||
|
||||
var dowNames = { '0': 'Sun', '1': 'Mon', '2': 'Tue', '3': 'Wed', '4': 'Thu', '5': 'Fri', '6': 'Sat', '7': 'Sun',
|
||||
'1-5': 'Weekdays', '0,6': 'Weekends', '6,0': 'Weekends' };
|
||||
|
||||
if (dom === '*' && mon === '*' && min.match(/^\d+$/) && hour.match(/^\d+$/)) {
|
||||
var h = parseInt(hour, 10);
|
||||
var m = parseInt(min, 10);
|
||||
var ampm = h >= 12 ? 'PM' : 'AM';
|
||||
var h12 = h === 0 ? 12 : (h > 12 ? h - 12 : h);
|
||||
var mStr = m < 10 ? '0' + m : '' + m;
|
||||
return 'Daily at ' + h12 + ':' + mStr + ' ' + ampm;
|
||||
var timeStr = h12 + ':' + mStr + ' ' + ampm;
|
||||
if (dow === '*') return 'Daily at ' + timeStr;
|
||||
var dowLabel = dowNames[dow] || ('DoW ' + dow);
|
||||
return dowLabel + ' at ' + timeStr;
|
||||
}
|
||||
|
||||
return expr;
|
||||
@@ -340,7 +359,14 @@ function schedulerPage() {
|
||||
try {
|
||||
var diff = Date.now() - new Date(ts).getTime();
|
||||
if (isNaN(diff)) return 'never';
|
||||
if (diff < 0) return 'just now';
|
||||
if (diff < 0) {
|
||||
// Future time
|
||||
var absDiff = Math.abs(diff);
|
||||
if (absDiff < 60000) return 'in <1m';
|
||||
if (absDiff < 3600000) return 'in ' + Math.floor(absDiff / 60000) + 'm';
|
||||
if (absDiff < 86400000) return 'in ' + Math.floor(absDiff / 3600000) + 'h';
|
||||
return 'in ' + Math.floor(absDiff / 86400000) + 'd';
|
||||
}
|
||||
if (diff < 60000) return 'just now';
|
||||
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
|
||||
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
|
||||
|
||||
@@ -268,38 +268,48 @@ fn parse_mastodon_notification(
|
||||
fn strip_html_tags(html: &str) -> String {
|
||||
let mut result = String::with_capacity(html.len());
|
||||
let mut in_tag = false;
|
||||
let mut tag_buf = String::new();
|
||||
|
||||
for ch in html.chars() {
|
||||
match ch {
|
||||
'<' => {
|
||||
in_tag = true;
|
||||
// Check if this is a <br> or </p> — insert newline
|
||||
if html[result.len()..].starts_with("<br")
|
||||
|| html[result.len()..].starts_with("</p")
|
||||
tag_buf.clear();
|
||||
}
|
||||
'>' if in_tag => {
|
||||
in_tag = false;
|
||||
// Insert newline for block-level closing tags
|
||||
let tag_lower = tag_buf.to_lowercase();
|
||||
if tag_lower.starts_with("br")
|
||||
|| tag_lower.starts_with("/p")
|
||||
|| tag_lower.starts_with("/div")
|
||||
|| tag_lower.starts_with("/li")
|
||||
{
|
||||
result.push('\n');
|
||||
}
|
||||
tag_buf.clear();
|
||||
}
|
||||
'>' => {
|
||||
in_tag = false;
|
||||
_ if in_tag => {
|
||||
tag_buf.push(ch);
|
||||
}
|
||||
_ if !in_tag => {
|
||||
_ => {
|
||||
result.push(ch);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Decode common HTML entities
|
||||
result
|
||||
// Decode HTML entities
|
||||
let decoded = result
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.trim()
|
||||
.to_string()
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ");
|
||||
|
||||
decoded.trim().to_string()
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -577,6 +587,35 @@ mod tests {
|
||||
assert_eq!(strip_html_tags("plain text"), "plain text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_html_tags_emoji() {
|
||||
assert_eq!(
|
||||
strip_html_tags("<p>Hello 🦀🔥 world</p>"),
|
||||
"Hello 🦀🔥 world"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_html_tags_cjk() {
|
||||
assert_eq!(
|
||||
strip_html_tags("<p>你好 <strong>世界</strong></p>"),
|
||||
"你好 世界"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_html_tags_numeric_entities() {
|
||||
assert_eq!(strip_html_tags("'hello'"), "'hello'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_html_tags_div_newline() {
|
||||
assert_eq!(
|
||||
strip_html_tags("<div>one</div><div>two</div>").trim(),
|
||||
"one\ntwo"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mastodon_notification_mention() {
|
||||
let notif = serde_json::json!({
|
||||
|
||||
@@ -26,6 +26,32 @@ pub fn default_client_ids() -> HashMap<&'static str, &'static str> {
|
||||
m
|
||||
}
|
||||
|
||||
/// Resolve OAuth client IDs with config overrides applied on top of defaults.
|
||||
pub fn resolve_client_ids(
|
||||
config: &openfang_types::config::OAuthConfig,
|
||||
) -> HashMap<String, String> {
|
||||
let defaults = default_client_ids();
|
||||
let mut resolved: HashMap<String, String> = defaults
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect();
|
||||
|
||||
if let Some(ref id) = config.google_client_id {
|
||||
resolved.insert("google".into(), id.clone());
|
||||
}
|
||||
if let Some(ref id) = config.github_client_id {
|
||||
resolved.insert("github".into(), id.clone());
|
||||
}
|
||||
if let Some(ref id) = config.microsoft_client_id {
|
||||
resolved.insert("microsoft".into(), id.clone());
|
||||
}
|
||||
if let Some(ref id) = config.slack_client_id {
|
||||
resolved.insert("slack".into(), id.clone());
|
||||
}
|
||||
|
||||
resolved
|
||||
}
|
||||
|
||||
/// OAuth2 token response (raw from provider, for deserialization).
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct OAuthTokens {
|
||||
@@ -333,4 +359,27 @@ mod tests {
|
||||
assert!(ids.contains_key("microsoft"));
|
||||
assert!(ids.contains_key("slack"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_client_ids_uses_defaults() {
|
||||
let config = openfang_types::config::OAuthConfig::default();
|
||||
let ids = resolve_client_ids(&config);
|
||||
assert_eq!(ids["google"], "openfang-google-client-id");
|
||||
assert_eq!(ids["github"], "openfang-github-client-id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_client_ids_applies_overrides() {
|
||||
let config = openfang_types::config::OAuthConfig {
|
||||
google_client_id: Some("my-real-google-id".into()),
|
||||
github_client_id: None,
|
||||
microsoft_client_id: Some("my-msft-id".into()),
|
||||
slack_client_id: None,
|
||||
};
|
||||
let ids = resolve_client_ids(&config);
|
||||
assert_eq!(ids["google"], "my-real-google-id");
|
||||
assert_eq!(ids["github"], "openfang-github-client-id"); // default
|
||||
assert_eq!(ids["microsoft"], "my-msft-id");
|
||||
assert_eq!(ids["slack"], "openfang-slack-client-id"); // default
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ const SALT_LEN: usize = 16;
|
||||
/// Nonce length for AES-256-GCM.
|
||||
const NONCE_LEN: usize = 12;
|
||||
/// Magic bytes for vault file format versioning.
|
||||
#[allow(dead_code)]
|
||||
const VAULT_MAGIC: &[u8; 4] = b"OFV1";
|
||||
|
||||
/// On-disk vault format (encrypted).
|
||||
@@ -312,14 +311,34 @@ impl CredentialVault {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&self.path, content)?;
|
||||
|
||||
// Prepend OFV1 magic bytes for format detection
|
||||
let mut output = Vec::with_capacity(VAULT_MAGIC.len() + content.len());
|
||||
output.extend_from_slice(VAULT_MAGIC);
|
||||
output.extend_from_slice(content.as_bytes());
|
||||
std::fs::write(&self.path, output)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load and decrypt vault from disk.
|
||||
fn load(&mut self, master_key: &[u8; 32]) -> ExtensionResult<()> {
|
||||
let content = std::fs::read_to_string(&self.path)?;
|
||||
let vault_file: VaultFile = serde_json::from_str(&content)
|
||||
let raw = std::fs::read(&self.path)?;
|
||||
|
||||
// Strip OFV1 magic header if present; legacy JSON files start with '{'
|
||||
let content = if raw.starts_with(VAULT_MAGIC) {
|
||||
std::str::from_utf8(&raw[VAULT_MAGIC.len()..])
|
||||
.map_err(|e| ExtensionError::Vault(format!("UTF-8 decode failed: {e}")))?
|
||||
} else if raw.first() == Some(&b'{') {
|
||||
// Legacy JSON vault (no magic header)
|
||||
std::str::from_utf8(&raw)
|
||||
.map_err(|e| ExtensionError::Vault(format!("UTF-8 decode failed: {e}")))?
|
||||
} else {
|
||||
return Err(ExtensionError::Vault(
|
||||
"Unrecognized vault file format".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
let vault_file: VaultFile = serde_json::from_str(content)
|
||||
.map_err(|e| ExtensionError::Vault(format!("Vault file parse failed: {e}")))?;
|
||||
|
||||
if vault_file.version != 1 {
|
||||
@@ -590,4 +609,50 @@ mod tests {
|
||||
let k2 = derive_key(&master, &salt).unwrap();
|
||||
assert_eq!(k1.as_ref(), k2.as_ref());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vault_file_has_magic_header() {
|
||||
let (_dir, mut vault) = test_vault();
|
||||
let key = random_key();
|
||||
vault.init_with_key(key).unwrap();
|
||||
|
||||
let raw = std::fs::read(&vault.path).unwrap();
|
||||
assert_eq!(&raw[..4], b"OFV1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vault_legacy_json_compat() {
|
||||
let (dir, mut vault) = test_vault();
|
||||
let key = random_key();
|
||||
vault.init_with_key(key.clone()).unwrap();
|
||||
vault
|
||||
.set("KEY".to_string(), Zeroizing::new("val".to_string()))
|
||||
.unwrap();
|
||||
|
||||
// Strip the OFV1 magic header to simulate a legacy vault file
|
||||
let raw = std::fs::read(&vault.path).unwrap();
|
||||
assert_eq!(&raw[..4], b"OFV1");
|
||||
std::fs::write(&vault.path, &raw[4..]).unwrap();
|
||||
|
||||
// Should still load (legacy compat)
|
||||
let mut vault2 = CredentialVault::new(dir.path().join("vault.enc"));
|
||||
vault2.unlock_with_key(key).unwrap();
|
||||
assert_eq!(vault2.get("KEY").unwrap().as_str(), "val");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vault_rejects_bad_magic() {
|
||||
let (dir, mut vault) = test_vault();
|
||||
let key = random_key();
|
||||
vault.init_with_key(key.clone()).unwrap();
|
||||
|
||||
// Overwrite with unrecognized binary data
|
||||
std::fs::write(&vault.path, b"BAAD not json").unwrap();
|
||||
|
||||
let mut vault2 = CredentialVault::new(dir.path().join("vault.enc"));
|
||||
let result = vault2.unlock_with_key(key);
|
||||
assert!(result.is_err());
|
||||
let msg = format!("{:?}", result.unwrap_err());
|
||||
assert!(msg.contains("Unrecognized vault file format"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ subtle = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
cron = "0.15"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -293,16 +293,37 @@ impl CronScheduler {
|
||||
///
|
||||
/// - `At { at }` — returns `at` directly.
|
||||
/// - `Every { every_secs }` — returns `now + every_secs`.
|
||||
/// - `Cron { .. }` — returns 60 seconds from now (placeholder until a cron
|
||||
/// expression parser is added).
|
||||
/// - `Cron { expr, tz }` — parses the cron expression and computes the next
|
||||
/// matching time. Supports standard 5-field (`min hour dom month dow`) and
|
||||
/// 6-field (`sec min hour dom month dow`) formats by converting to the
|
||||
/// 7-field format required by the `cron` crate.
|
||||
pub fn compute_next_run(schedule: &CronSchedule) -> chrono::DateTime<Utc> {
|
||||
match schedule {
|
||||
CronSchedule::At { at } => *at,
|
||||
CronSchedule::Every { every_secs } => Utc::now() + Duration::seconds(*every_secs as i64),
|
||||
CronSchedule::Cron { .. } => {
|
||||
// Placeholder: real cron parsing will be added when the `cron`
|
||||
// crate is brought in. For now, fire 60 seconds from now.
|
||||
Utc::now() + Duration::seconds(60)
|
||||
CronSchedule::Cron { expr, tz: _ } => {
|
||||
// Convert standard 5/6-field cron to 7-field for the `cron` crate.
|
||||
// Standard 5-field: min hour dom month dow
|
||||
// 6-field: sec min hour dom month dow
|
||||
// cron crate: sec min hour dom month dow year
|
||||
let trimmed = expr.trim();
|
||||
let fields: Vec<&str> = trimmed.split_whitespace().collect();
|
||||
let seven_field = match fields.len() {
|
||||
5 => format!("0 {trimmed} *"),
|
||||
6 => format!("{trimmed} *"),
|
||||
_ => expr.clone(),
|
||||
};
|
||||
|
||||
match seven_field.parse::<cron::Schedule>() {
|
||||
Ok(sched) => sched
|
||||
.upcoming(Utc)
|
||||
.next()
|
||||
.unwrap_or_else(|| Utc::now() + Duration::hours(1)),
|
||||
Err(e) => {
|
||||
warn!("Failed to parse cron expression '{}': {}", expr, e);
|
||||
Utc::now() + Duration::hours(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -655,18 +676,47 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_next_run_cron_placeholder() {
|
||||
let before = Utc::now();
|
||||
fn test_compute_next_run_cron_daily() {
|
||||
let now = Utc::now();
|
||||
let schedule = CronSchedule::Cron {
|
||||
expr: "0 9 * * *".into(),
|
||||
tz: None,
|
||||
};
|
||||
let next = compute_next_run(&schedule);
|
||||
let after = Utc::now();
|
||||
|
||||
// Placeholder returns ~60s from now
|
||||
assert!(next >= before + Duration::seconds(59));
|
||||
assert!(next <= after + Duration::seconds(61));
|
||||
// Should be within the next 24 hours (next 09:00 UTC)
|
||||
assert!(next > now);
|
||||
assert!(next <= now + Duration::hours(24));
|
||||
assert_eq!(next.format("%M").to_string(), "00");
|
||||
assert_eq!(next.format("%H").to_string(), "09");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_next_run_cron_with_dow() {
|
||||
let now = Utc::now();
|
||||
let schedule = CronSchedule::Cron {
|
||||
expr: "30 14 * * 1-5".into(),
|
||||
tz: None,
|
||||
};
|
||||
let next = compute_next_run(&schedule);
|
||||
|
||||
// Should be within the next 7 days and at 14:30
|
||||
assert!(next > now);
|
||||
assert!(next <= now + Duration::days(7));
|
||||
assert_eq!(next.format("%H:%M").to_string(), "14:30");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_next_run_cron_invalid_expr() {
|
||||
let now = Utc::now();
|
||||
let schedule = CronSchedule::Cron {
|
||||
expr: "not a cron".into(),
|
||||
tz: None,
|
||||
};
|
||||
let next = compute_next_run(&schedule);
|
||||
// Invalid expression falls back to 1 hour from now
|
||||
assert!(next > now + Duration::minutes(59));
|
||||
assert!(next <= now + Duration::minutes(61));
|
||||
}
|
||||
|
||||
// -- error message truncation in record_failure -------------------------
|
||||
|
||||
@@ -975,6 +975,9 @@ impl OpenFangKernel {
|
||||
if !dm.model.is_empty() {
|
||||
manifest.model.model = dm.model.clone();
|
||||
}
|
||||
if dm.base_url.is_some() {
|
||||
manifest.model.base_url = dm.base_url.clone();
|
||||
}
|
||||
}
|
||||
|
||||
// Create workspace directory for the agent
|
||||
@@ -3499,7 +3502,11 @@ impl OpenFangKernel {
|
||||
let driver_config = DriverConfig {
|
||||
provider: agent_provider.clone(),
|
||||
api_key: std::env::var(&api_key_env).ok(),
|
||||
base_url: manifest.model.base_url.clone(),
|
||||
base_url: manifest
|
||||
.model
|
||||
.base_url
|
||||
.clone()
|
||||
.or_else(|| self.config.default_model.base_url.clone()),
|
||||
};
|
||||
|
||||
drivers::create_driver(&driver_config).map_err(|e| {
|
||||
|
||||
@@ -382,6 +382,9 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
|
||||
if model.contains("glm") {
|
||||
return (1.50, 5.00);
|
||||
}
|
||||
if model.contains("codegeex") {
|
||||
return (0.10, 0.10);
|
||||
}
|
||||
|
||||
// ── Moonshot / Kimi ─────────────────────────────────────────
|
||||
if model.contains("moonshot") || model.contains("kimi") {
|
||||
|
||||
@@ -88,7 +88,9 @@ impl AgentScheduler {
|
||||
// Reset the window if an hour has passed
|
||||
tracker.reset_if_expired();
|
||||
|
||||
if tracker.total_tokens > quota.max_llm_tokens_per_hour {
|
||||
if quota.max_llm_tokens_per_hour > 0
|
||||
&& tracker.total_tokens > quota.max_llm_tokens_per_hour
|
||||
{
|
||||
return Err(OpenFangError::QuotaExceeded(format!(
|
||||
"Token limit exceeded: {} / {}",
|
||||
tracker.total_tokens, quota.max_llm_tokens_per_hour
|
||||
|
||||
@@ -17,7 +17,7 @@ use openfang_types::model_catalog::{
|
||||
MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL,
|
||||
OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
|
||||
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VLLM_BASE_URL, XAI_BASE_URL,
|
||||
ZHIPU_BASE_URL,
|
||||
ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -152,6 +152,11 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
|
||||
api_key_env: "ZHIPU_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
"zhipu_coding" | "codegeex" => Some(ProviderDefaults {
|
||||
base_url: ZHIPU_CODING_BASE_URL,
|
||||
api_key_env: "ZHIPU_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
"qianfan" | "baidu" => Some(ProviderDefaults {
|
||||
base_url: QIANFAN_BASE_URL,
|
||||
api_key_env: "QIANFAN_API_KEY",
|
||||
@@ -317,6 +322,7 @@ pub fn known_providers() -> &'static [&'static str] {
|
||||
"qwen",
|
||||
"minimax",
|
||||
"zhipu",
|
||||
"zhipu_coding",
|
||||
"qianfan",
|
||||
]
|
||||
}
|
||||
@@ -409,8 +415,9 @@ mod tests {
|
||||
assert!(providers.contains(&"qwen"));
|
||||
assert!(providers.contains(&"minimax"));
|
||||
assert!(providers.contains(&"zhipu"));
|
||||
assert!(providers.contains(&"zhipu_coding"));
|
||||
assert!(providers.contains(&"qianfan"));
|
||||
assert_eq!(providers.len(), 26);
|
||||
assert_eq!(providers.len(), 27);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,7 +10,7 @@ use openfang_types::model_catalog::{
|
||||
LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, OLLAMA_BASE_URL,
|
||||
OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
|
||||
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VLLM_BASE_URL, XAI_BASE_URL,
|
||||
ZHIPU_BASE_URL,
|
||||
ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -435,6 +435,15 @@ fn builtin_providers() -> Vec<ProviderInfo> {
|
||||
auth_status: AuthStatus::Missing,
|
||||
model_count: 0,
|
||||
},
|
||||
ProviderInfo {
|
||||
id: "zhipu_coding".into(),
|
||||
display_name: "Zhipu Coding (CodeGeeX)".into(),
|
||||
api_key_env: "ZHIPU_API_KEY".into(),
|
||||
base_url: ZHIPU_CODING_BASE_URL.into(),
|
||||
key_required: true,
|
||||
auth_status: AuthStatus::Missing,
|
||||
model_count: 0,
|
||||
},
|
||||
ProviderInfo {
|
||||
id: "moonshot".into(),
|
||||
display_name: "Moonshot (Kimi)".into(),
|
||||
@@ -518,6 +527,7 @@ fn builtin_aliases() -> HashMap<String, String> {
|
||||
("ernie", "ernie-4.5-8k"),
|
||||
("kimi", "moonshot-v1-128k"),
|
||||
("minimax", "minimax-text-01"),
|
||||
("codegeex", "codegeex-4"),
|
||||
];
|
||||
pairs
|
||||
.into_iter()
|
||||
@@ -2350,6 +2360,23 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
aliases: vec![],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Zhipu Coding / CodeGeeX (1)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
id: "codegeex-4".into(),
|
||||
display_name: "CodeGeeX 4".into(),
|
||||
provider: "zhipu_coding".into(),
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 131_072,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 0.10,
|
||||
output_cost_per_m: 0.10,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["codegeex".into()],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Moonshot / Kimi (3)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
@@ -2570,7 +2597,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_catalog_has_providers() {
|
||||
let catalog = ModelCatalog::new();
|
||||
assert_eq!(catalog.list_providers().len(), 27);
|
||||
assert_eq!(catalog.list_providers().len(), 28);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2790,6 +2817,7 @@ mod tests {
|
||||
assert!(catalog.get_provider("qwen").is_some());
|
||||
assert!(catalog.get_provider("minimax").is_some());
|
||||
assert!(catalog.get_provider("zhipu").is_some());
|
||||
assert!(catalog.get_provider("zhipu_coding").is_some());
|
||||
assert!(catalog.get_provider("moonshot").is_some());
|
||||
assert!(catalog.get_provider("qianfan").is_some());
|
||||
assert!(catalog.get_provider("bedrock").is_some());
|
||||
@@ -2800,6 +2828,7 @@ mod tests {
|
||||
let catalog = ModelCatalog::new();
|
||||
assert!(catalog.find_model("kimi").is_some());
|
||||
assert!(catalog.find_model("glm").is_some());
|
||||
assert!(catalog.find_model("codegeex").is_some());
|
||||
assert!(catalog.find_model("ernie").is_some());
|
||||
assert!(catalog.find_model("minimax").is_some());
|
||||
}
|
||||
|
||||
@@ -140,15 +140,28 @@ pub(crate) fn check_ssrf(url: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
let host = extract_host(url);
|
||||
let hostname = host.split(':').next().unwrap_or(&host);
|
||||
// For IPv6 bracket notation like [::1]:80, extract [::1] as hostname
|
||||
let hostname = if host.starts_with('[') {
|
||||
host.find(']')
|
||||
.map(|i| &host[..=i])
|
||||
.unwrap_or(&host)
|
||||
} else {
|
||||
host.split(':').next().unwrap_or(&host)
|
||||
};
|
||||
|
||||
// Hostname-based blocklist (catches metadata endpoints)
|
||||
let blocked = [
|
||||
"localhost",
|
||||
"ip6-localhost",
|
||||
"metadata.google.internal",
|
||||
"metadata.aws.internal",
|
||||
"instance-data",
|
||||
"169.254.169.254",
|
||||
"100.100.100.200", // Alibaba Cloud IMDS
|
||||
"192.0.0.192", // Azure IMDS alternative
|
||||
"0.0.0.0",
|
||||
"::1",
|
||||
"[::1]",
|
||||
];
|
||||
if blocked.contains(&hostname) {
|
||||
return Err(format!("SSRF blocked: {hostname} is a restricted hostname"));
|
||||
@@ -192,6 +205,19 @@ fn is_private_ip(ip: &IpAddr) -> bool {
|
||||
fn extract_host(url: &str) -> String {
|
||||
if let Some(after_scheme) = url.split("://").nth(1) {
|
||||
let host_port = after_scheme.split('/').next().unwrap_or(after_scheme);
|
||||
// Handle IPv6 bracket notation: [::1]:8080
|
||||
if host_port.starts_with('[') {
|
||||
// Extract [addr]:port or [addr]
|
||||
if let Some(bracket_end) = host_port.find(']') {
|
||||
let ipv6_host = &host_port[..=bracket_end]; // includes brackets
|
||||
let after_bracket = &host_port[bracket_end + 1..];
|
||||
if let Some(port) = after_bracket.strip_prefix(':') {
|
||||
return format!("{ipv6_host}:{port}");
|
||||
}
|
||||
let default_port = if url.starts_with("https") { 443 } else { 80 };
|
||||
return format!("{ipv6_host}:{default_port}");
|
||||
}
|
||||
}
|
||||
if host_port.contains(':') {
|
||||
host_port.to_string()
|
||||
} else if url.starts_with("https") {
|
||||
@@ -245,4 +271,35 @@ mod tests {
|
||||
assert!(check_ssrf("ftp://internal.corp/data").is_err());
|
||||
assert!(check_ssrf("gopher://evil.com").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_blocks_cloud_metadata() {
|
||||
// Alibaba Cloud IMDS
|
||||
assert!(check_ssrf("http://100.100.100.200/latest/meta-data/").is_err());
|
||||
// Azure IMDS alternative
|
||||
assert!(check_ssrf("http://192.0.0.192/metadata/instance").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_blocks_zero_ip() {
|
||||
assert!(check_ssrf("http://0.0.0.0/").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_blocks_ipv6_localhost() {
|
||||
assert!(check_ssrf("http://[::1]/admin").is_err());
|
||||
assert!(check_ssrf("http://[::1]:8080/api").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_host_ipv6() {
|
||||
let h = extract_host("http://[::1]:8080/path");
|
||||
assert_eq!(h, "[::1]:8080");
|
||||
|
||||
let h2 = extract_host("https://[::1]/path");
|
||||
assert_eq!(h2, "[::1]:443");
|
||||
|
||||
let h3 = extract_host("http://[::1]/path");
|
||||
assert_eq!(h3, "[::1]:80");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1043,6 +1043,30 @@ pub struct KernelConfig {
|
||||
/// e.g. `ollama = "http://192.168.1.100:11434/v1"`
|
||||
#[serde(default)]
|
||||
pub provider_urls: HashMap<String, String>,
|
||||
/// OAuth client ID overrides for PKCE flows.
|
||||
#[serde(default)]
|
||||
pub oauth: OAuthConfig,
|
||||
}
|
||||
|
||||
/// OAuth client ID overrides for PKCE flows.
|
||||
///
|
||||
/// Configure in config.toml:
|
||||
/// ```toml
|
||||
/// [oauth]
|
||||
/// google_client_id = "your-google-client-id"
|
||||
/// github_client_id = "your-github-client-id"
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct OAuthConfig {
|
||||
/// Google OAuth2 client ID for PKCE flow.
|
||||
pub google_client_id: Option<String>,
|
||||
/// GitHub OAuth client ID for PKCE flow.
|
||||
pub github_client_id: Option<String>,
|
||||
/// Microsoft (Entra ID) OAuth client ID.
|
||||
pub microsoft_client_id: Option<String>,
|
||||
/// Slack OAuth client ID.
|
||||
pub slack_client_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Global spending budget configuration.
|
||||
@@ -1188,6 +1212,7 @@ impl Default for KernelConfig {
|
||||
thinking: None,
|
||||
budget: BudgetConfig::default(),
|
||||
provider_urls: HashMap::new(),
|
||||
oauth: OAuthConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ pub const GITHUB_COPILOT_BASE_URL: &str = "https://api.githubcopilot.com";
|
||||
pub const QWEN_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
||||
pub const MINIMAX_BASE_URL: &str = "https://api.minimax.chat/v1";
|
||||
pub const ZHIPU_BASE_URL: &str = "https://open.bigmodel.cn/api/paas/v4";
|
||||
pub const ZHIPU_CODING_BASE_URL: &str = "https://open.bigmodel.cn/api/paas/v4";
|
||||
pub const MOONSHOT_BASE_URL: &str = "https://api.moonshot.cn/v1";
|
||||
pub const QIANFAN_BASE_URL: &str = "https://qianfan.baidubce.com/v2";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user