mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-04-25 17:25:11 +02:00
- #962: WebSocket auth now URL-decodes token before comparison. API keys with +/=/ characters (base64-derived) now work correctly for WS streaming. - #939: Clippy bool_comparison lint fixed in web_fetch.rs test. - #983: Dockerfile adds perl and make for openssl-sys compilation on slim-bookworm. - #987: Nextcloud chat poll endpoint corrected from api/v4/room/{token}/chat to api/v1/chat/{token}/ matching the send endpoint. - #970: Moonshot Kimi K2/K2.5 models now redirect to api.moonshot.cn/v1 instead of api.moonshot.ai/v1. The .ai domain only serves legacy moonshot-v1-* models. - #882: Closed as resolved by v0.5.7 custom hand persistence fix (#984). - #926: Verified already fixed (rmcp builder API from previous session). All tests passing. 8 files changed, 75 insertions.
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
FROM rust:1-slim-bookworm AS builder
|
FROM rust:1-slim-bookworm AS builder
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y pkg-config libssl-dev perl make && rm -rf /var/lib/apt/lists/*
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
COPY crates ./crates
|
COPY crates ./crates
|
||||||
COPY xtask ./xtask
|
COPY xtask ./xtask
|
||||||
|
|||||||
@@ -3,6 +3,38 @@
|
|||||||
//! Exposes agent management, status, and chat via JSON REST endpoints.
|
//! Exposes agent management, status, and chat via JSON REST endpoints.
|
||||||
//! The kernel runs in-process; the CLI connects over HTTP.
|
//! The kernel runs in-process; the CLI connects over HTTP.
|
||||||
|
|
||||||
|
/// Decode percent-encoded strings (e.g. `%2B` → `+`).
|
||||||
|
/// Used to normalise `?token=` values that browsers encode with `encodeURIComponent`.
|
||||||
|
pub(crate) fn percent_decode(input: &str) -> String {
|
||||||
|
let bytes = input.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(bytes.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||||
|
if let (Some(hi), Some(lo)) = (
|
||||||
|
hex_val(bytes[i + 1]),
|
||||||
|
hex_val(bytes[i + 2]),
|
||||||
|
) {
|
||||||
|
out.push(hi << 4 | lo);
|
||||||
|
i += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(bytes[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
String::from_utf8(out).unwrap_or_else(|_| input.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_val(b: u8) -> Option<u8> {
|
||||||
|
match b {
|
||||||
|
b'0'..=b'9' => Some(b - b'0'),
|
||||||
|
b'a'..=b'f' => Some(b - b'a' + 10),
|
||||||
|
b'A'..=b'F' => Some(b - b'A' + 10),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub mod channel_bridge;
|
pub mod channel_bridge;
|
||||||
pub mod middleware;
|
pub mod middleware;
|
||||||
pub mod openai_compat;
|
pub mod openai_compat;
|
||||||
|
|||||||
@@ -167,13 +167,14 @@ pub async fn auth(
|
|||||||
|
|
||||||
// Also check ?token= query parameter (for EventSource/SSE clients that
|
// Also check ?token= query parameter (for EventSource/SSE clients that
|
||||||
// cannot set custom headers, same approach as WebSocket auth).
|
// cannot set custom headers, same approach as WebSocket auth).
|
||||||
let query_token = request
|
let query_token_decoded = request
|
||||||
.uri()
|
.uri()
|
||||||
.query()
|
.query()
|
||||||
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")));
|
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
|
||||||
|
.map(crate::percent_decode);
|
||||||
|
|
||||||
// SECURITY: Use constant-time comparison to prevent timing attacks.
|
// SECURITY: Use constant-time comparison to prevent timing attacks.
|
||||||
let query_auth = query_token.map(|token| {
|
let query_auth = query_token_decoded.as_deref().map(|token| {
|
||||||
use subtle::ConstantTimeEq;
|
use subtle::ConstantTimeEq;
|
||||||
if token.len() != api_key.len() {
|
if token.len() != api_key.len() {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -169,7 +169,8 @@ pub async fn agent_ws(
|
|||||||
let query_auth = uri
|
let query_auth = uri
|
||||||
.query()
|
.query()
|
||||||
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
|
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
|
||||||
.map(|token| ct_eq(token, api_key))
|
.map(|raw| crate::percent_decode(raw))
|
||||||
|
.map(|token| ct_eq(&token, api_key))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
if !header_auth && !query_auth {
|
if !header_auth && !query_auth {
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ impl ChannelAdapter for NextcloudAdapter {
|
|||||||
|
|
||||||
// Use lookIntoFuture=1 and lastKnownMessageId for incremental polling
|
// Use lookIntoFuture=1 and lastKnownMessageId for incremental polling
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"{}/ocs/v2.php/apps/spreed/api/v4/room/{}/chat?format=json&lookIntoFuture=1&limit=100&lastKnownMessageId={}",
|
"{}/ocs/v2.php/apps/spreed/api/v1/chat/{}?format=json&lookIntoFuture=1&limit=100&lastKnownMessageId={}",
|
||||||
server_url, room_token, last_id
|
server_url, room_token, last_id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use crate::think_filter::{FilterAction, StreamingThinkFilter};
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use openfang_types::message::{ContentBlock, MessageContent, Role, StopReason, TokenUsage};
|
use openfang_types::message::{ContentBlock, MessageContent, Role, StopReason, TokenUsage};
|
||||||
|
use openfang_types::model_catalog::MOONSHOT_KIMI_BASE_URL;
|
||||||
use openfang_types::tool::ToolCall;
|
use openfang_types::tool::ToolCall;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
@@ -84,7 +85,17 @@ impl OpenAIDriver {
|
|||||||
AZURE_API_VERSION,
|
AZURE_API_VERSION,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
format!("{}/chat/completions", self.base_url)
|
// Kimi K2/K2.5 models live on api.moonshot.cn, not api.moonshot.ai.
|
||||||
|
// When the moonshot provider is configured with the default .ai URL
|
||||||
|
// but the model is a kimi-k2* model, redirect to the .cn endpoint.
|
||||||
|
let effective_url = if self.base_url.contains("api.moonshot.ai")
|
||||||
|
&& model.to_lowercase().starts_with("kimi-k2")
|
||||||
|
{
|
||||||
|
MOONSHOT_KIMI_BASE_URL
|
||||||
|
} else {
|
||||||
|
&self.base_url
|
||||||
|
};
|
||||||
|
format!("{}/chat/completions", effective_url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1836,4 +1847,24 @@ mod tests {
|
|||||||
let url = driver.chat_url("gpt-4o");
|
let url = driver.chat_url("gpt-4o");
|
||||||
assert_eq!(url, "https://api.openai.com/v1/chat/completions");
|
assert_eq!(url, "https://api.openai.com/v1/chat/completions");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression test for #970: kimi-k2.5 on moonshot.ai should redirect to moonshot.cn
|
||||||
|
#[test]
|
||||||
|
fn test_kimi_k2_redirects_to_moonshot_cn() {
|
||||||
|
let driver = OpenAIDriver::new(
|
||||||
|
"test-key".to_string(),
|
||||||
|
"https://api.moonshot.ai/v1".to_string(),
|
||||||
|
);
|
||||||
|
// kimi-k2.5 must go to the .cn endpoint
|
||||||
|
let url = driver.chat_url("kimi-k2.5");
|
||||||
|
assert_eq!(url, "https://api.moonshot.cn/v1/chat/completions");
|
||||||
|
|
||||||
|
// kimi-k2 must also redirect
|
||||||
|
let url = driver.chat_url("kimi-k2");
|
||||||
|
assert_eq!(url, "https://api.moonshot.cn/v1/chat/completions");
|
||||||
|
|
||||||
|
// moonshot-v1-128k should NOT redirect (stays on .ai)
|
||||||
|
let url = driver.chat_url("moonshot-v1-128k");
|
||||||
|
assert_eq!(url, "https://api.moonshot.ai/v1/chat/completions");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -514,7 +514,7 @@ mod tests {
|
|||||||
let allow = vec!["*.example.com".to_string()];
|
let allow = vec!["*.example.com".to_string()];
|
||||||
assert!(check_ssrf("http://api.example.com", &allow).is_ok());
|
assert!(check_ssrf("http://api.example.com", &allow).is_ok());
|
||||||
// Non-matching domain should still go through normal checks
|
// Non-matching domain should still go through normal checks
|
||||||
assert!(is_host_allowed("other.net", &allow) == false);
|
assert!(!is_host_allowed("other.net", &allow));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ pub const ZHIPU_CODING_BASE_URL: &str = "https://open.bigmodel.cn/api/coding/paa
|
|||||||
pub const ZAI_BASE_URL: &str = "https://api.z.ai/api/paas/v4";
|
pub const ZAI_BASE_URL: &str = "https://api.z.ai/api/paas/v4";
|
||||||
pub const ZAI_CODING_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
|
pub const ZAI_CODING_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
|
||||||
pub const MOONSHOT_BASE_URL: &str = "https://api.moonshot.ai/v1";
|
pub const MOONSHOT_BASE_URL: &str = "https://api.moonshot.ai/v1";
|
||||||
|
/// Kimi K2/K2.5 models live on the `.cn` platform, not `.ai`.
|
||||||
|
pub const MOONSHOT_KIMI_BASE_URL: &str = "https://api.moonshot.cn/v1";
|
||||||
pub const KIMI_CODING_BASE_URL: &str = "https://api.kimi.com/coding";
|
pub const KIMI_CODING_BASE_URL: &str = "https://api.kimi.com/coding";
|
||||||
pub const QIANFAN_BASE_URL: &str = "https://qianfan.baidubce.com/v2";
|
pub const QIANFAN_BASE_URL: &str = "https://qianfan.baidubce.com/v2";
|
||||||
pub const VOLCENGINE_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/v3";
|
pub const VOLCENGINE_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/v3";
|
||||||
|
|||||||
Reference in New Issue
Block a user