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
|
||||
FROM rust:1-slim-bookworm AS builder
|
||||
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 crates ./crates
|
||||
COPY xtask ./xtask
|
||||
|
||||
@@ -3,6 +3,38 @@
|
||||
//! Exposes agent management, status, and chat via JSON REST endpoints.
|
||||
//! 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 middleware;
|
||||
pub mod openai_compat;
|
||||
|
||||
@@ -167,13 +167,14 @@ pub async fn auth(
|
||||
|
||||
// Also check ?token= query parameter (for EventSource/SSE clients that
|
||||
// cannot set custom headers, same approach as WebSocket auth).
|
||||
let query_token = request
|
||||
let query_token_decoded = request
|
||||
.uri()
|
||||
.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.
|
||||
let query_auth = query_token.map(|token| {
|
||||
let query_auth = query_token_decoded.as_deref().map(|token| {
|
||||
use subtle::ConstantTimeEq;
|
||||
if token.len() != api_key.len() {
|
||||
return false;
|
||||
|
||||
@@ -169,7 +169,8 @@ pub async fn agent_ws(
|
||||
let query_auth = uri
|
||||
.query()
|
||||
.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);
|
||||
|
||||
if !header_auth && !query_auth {
|
||||
|
||||
@@ -260,7 +260,7 @@ impl ChannelAdapter for NextcloudAdapter {
|
||||
|
||||
// Use lookIntoFuture=1 and lastKnownMessageId for incremental polling
|
||||
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
|
||||
);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::think_filter::{FilterAction, StreamingThinkFilter};
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt;
|
||||
use openfang_types::message::{ContentBlock, MessageContent, Role, StopReason, TokenUsage};
|
||||
use openfang_types::model_catalog::MOONSHOT_KIMI_BASE_URL;
|
||||
use openfang_types::tool::ToolCall;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, warn};
|
||||
@@ -84,7 +85,17 @@ impl OpenAIDriver {
|
||||
AZURE_API_VERSION,
|
||||
)
|
||||
} 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");
|
||||
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()];
|
||||
assert!(check_ssrf("http://api.example.com", &allow).is_ok());
|
||||
// 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]
|
||||
|
||||
@@ -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_CODING_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
|
||||
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 QIANFAN_BASE_URL: &str = "https://qianfan.baidubce.com/v2";
|
||||
pub const VOLCENGINE_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/v3";
|
||||
|
||||
Reference in New Issue
Block a user