Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/volcengine-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@
- Agent Plan:`https://ark.cn-beijing.volces.com/api/plan/v3`;Coding Plan:`https://ark.cn-beijing.volces.com/api/coding/v3`。使用各自套餐的专属 API Key,按所选请求格式适配协议路径。
- 选择套餐后,通过“查看支持的模型”打开对应的 [Agent Plan 控制台](https://console.volcengine.com/ark/subscription/agent-plan)或 [Coding Plan 控制台](https://console.volcengine.com/ark/subscription/coding-plan),复制支持的文本模型 ID,手动填写后执行“验证”。该按钮不拉取在线模型列表,也不验证密钥。
- 预设地址只读,已有自定义地址保持可编辑;新建自定义接口使用自定义供应商入口。普通火山方舟及其他供应商保留原有模型列表获取行为。
- 方舟通道的协议设置提供“低延迟推理(fast)”开关;启用后请求会携带 `service_tier: "fast"`。该参数只对支持快速服务档位的模型有效,不支持的模型可能返回 400。
55 changes: 50 additions & 5 deletions openless-all/app/crates/openless-core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4390,13 +4390,34 @@ impl OpenLessBackend {
Some(id.clone()),
crate::llm_protocol::REQUEST_FORMAT_ACCOUNT,
)?;
let service_tier_key = CredentialKey::new(
crate::CredentialNamespace::Llm,
Some(id.clone()),
crate::llm_protocol::SERVICE_TIER_ACCOUNT,
)?;
let reset = kind == ChannelKind::Llm && previous.provider_type != provider_type;
let old_format = if reset {
let value = self.deps.credential_store.read(key.clone()).await?;
let (old_format, old_service_tier) = if reset {
let old_format = self.deps.credential_store.read(key.clone()).await?;
let old_service_tier = self
.deps
.credential_store
.read(service_tier_key.clone())
.await?;
self.deps.credential_store.remove(key.clone()).await?;
value
if let Err(error) = self
.deps
.credential_store
.remove(service_tier_key.clone())
.await
{
if let Some(value) = old_format.clone() {
self.deps.credential_store.write(key.clone(), value).await?;
}
return Err(error);
}
(old_format, old_service_tier)
} else {
None
(None, None)
};
let result = self
.deps
Expand All @@ -4412,6 +4433,12 @@ impl OpenLessBackend {
if let Some(value) = old_format {
self.deps.credential_store.write(key, value).await?;
}
if let Some(value) = old_service_tier {
self.deps
.credential_store
.write(service_tier_key, value)
.await?;
}
}
result?;
self.refresh_and_publish_credentials().await.map(|_| ())
Expand Down Expand Up @@ -10298,7 +10325,7 @@ mod tests {
}

#[tokio::test]
async fn llm_protocol_mutations_reset_only_the_format_and_invalidate_tests() {
async fn llm_protocol_mutations_reset_protocol_overrides_and_invalidate_tests() {
use crate::credentials::{CredentialNamespace, InMemoryCredentialStore, SecretValue};
use crate::llm_protocol::*;
let backend = OpenLessBackend::new(
Expand Down Expand Up @@ -10333,6 +10360,19 @@ mod tests {
.set_credential(key(REQUEST_FORMAT_ACCOUNT), SecretValue::new("messages"))
.await
.unwrap();
backend
.set_credential(key(SERVICE_TIER_ACCOUNT), SecretValue::new("fast"))
.await
.unwrap();
assert_eq!(
backend
.read_credential(key(SERVICE_TIER_ACCOUNT))
.await
.unwrap()
.unwrap()
.expose_secret(),
"fast"
);
backend
.set_credential(
key(crate::credentials::LLM_API_KEY_ACCOUNT),
Expand Down Expand Up @@ -10379,6 +10419,11 @@ mod tests {
.await
.unwrap()
.is_none());
assert!(backend
.read_credential(key(SERVICE_TIER_ACCOUNT))
.await
.unwrap()
.is_none());
assert_eq!(
backend
.read_credential(key(crate::credentials::LLM_API_KEY_ACCOUNT))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,7 @@ pub const LLM_ACCOUNTS: &[&str] = &[
"ark.messages_thinking",
"ark.max_tokens",
"ark.thinking_budget",
"ark.service_tier",
];
pub const OMNI_ACCOUNTS: &[&str] = &[
"omni.api_key",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ struct LegacyEntry {
messages_thinking: Option<String>,
max_tokens: Option<String>,
thinking_budget: Option<String>,
service_tier: Option<String>,
}

impl Default for LegacyEntry {
Expand Down Expand Up @@ -141,6 +142,7 @@ impl Default for LegacyEntry {
messages_thinking: None,
max_tokens: None,
thinking_budget: None,
service_tier: None,
}
}
}
Expand Down Expand Up @@ -169,6 +171,7 @@ impl LegacyEntry {
&self.messages_thinking,
&self.max_tokens,
&self.thinking_budget,
&self.service_tier,
]
.into_iter()
.any(|value| value.as_deref().is_some_and(|value| !value.is_empty()))
Expand Down Expand Up @@ -378,6 +381,7 @@ fn decode_entry(
(MESSAGES_THINKING_ACCOUNT, entry.messages_thinking),
(MAX_TOKENS_ACCOUNT, entry.max_tokens),
(THINKING_BUDGET_ACCOUNT, entry.thinking_budget),
(SERVICE_TIER_ACCOUNT, entry.service_tier),
]);
}
if namespace == CredentialNamespace::Asr {
Expand Down
59 changes: 58 additions & 1 deletion openless-all/app/crates/openless-core/src/llm_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ pub const REQUEST_FORMAT_ACCOUNT: &str = "ark.request_format";
pub const MESSAGES_THINKING_ACCOUNT: &str = "ark.messages_thinking";
pub const MAX_TOKENS_ACCOUNT: &str = "ark.max_tokens";
pub const THINKING_BUDGET_ACCOUNT: &str = "ark.thinking_budget";
pub const CONFIG_ACCOUNTS: [&str; 4] = [
pub const SERVICE_TIER_ACCOUNT: &str = "ark.service_tier";
pub const CONFIG_ACCOUNTS: [&str; 5] = [
REQUEST_FORMAT_ACCOUNT,
MESSAGES_THINKING_ACCOUNT,
MAX_TOKENS_ACCOUNT,
THINKING_BUDGET_ACCOUNT,
SERVICE_TIER_ACCOUNT,
];

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
Expand Down Expand Up @@ -115,12 +117,21 @@ pub enum MessagesThinking {
Budget,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LlmServiceTier {
#[default]
Off,
Fast,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LlmProtocolConfig {
pub format: LlmRequestFormat,
pub messages_thinking: MessagesThinking,
pub max_tokens: u32,
pub thinking_budget: u32,
pub service_tier: LlmServiceTier,
}

impl Default for LlmProtocolConfig {
Expand All @@ -130,6 +141,7 @@ impl Default for LlmProtocolConfig {
messages_thinking: MessagesThinking::Adaptive,
max_tokens: 8192,
thinking_budget: 1024,
service_tier: LlmServiceTier::Off,
}
}
}
Expand Down Expand Up @@ -194,6 +206,12 @@ impl LlmProtocolConfig {
return Err(config_error("llmThinkingBudgetInvalid"));
}
}
SERVICE_TIER_ACCOUNT => {
self.service_tier = match value {
"fast" => LlmServiceTier::Fast,
_ => return Err(config_error("llmServiceTierInvalid")),
}
}
_ => return Err(config_error("llmRequestFormatInvalid")),
}
Ok(())
Expand All @@ -209,6 +227,12 @@ impl LlmProtocolConfig {
}
Ok(())
}

pub(crate) fn apply_service_tier(&self, provider_id: &str, body: &mut Value) {
if provider_id.trim() == "ark" && self.service_tier == LlmServiceTier::Fast {
body["service_tier"] = json!("fast");
}
}
}

fn positive_tokens(value: &str) -> Result<u32, BackendError> {
Expand Down Expand Up @@ -293,6 +317,9 @@ pub(crate) fn request_body(
body["temperature"] = crate::polish::temperature_json(temperature);
}
}
config
.protocol
.apply_service_tier(&config.provider_id, &mut body);
body
}

Expand Down Expand Up @@ -726,6 +753,36 @@ mod tests {
}
}

#[test]
fn service_tier_is_validated_and_applied_only_to_ark_requests() {
let mut protocol = LlmProtocolConfig::default();
assert!(protocol.apply(SERVICE_TIER_ACCOUNT, "invalid").is_err());
protocol.apply(SERVICE_TIER_ACCOUNT, "fast").unwrap();
assert_eq!(protocol.service_tier, LlmServiceTier::Fast);

let messages = vec![json!({"role":"user","content":"hi"})];
for format in [LlmRequestFormat::Responses, LlmRequestFormat::Messages] {
let mut config = OpenAICompatibleConfig::new(
"ark",
"test",
"https://ark.cn-beijing.volces.com/api/v3",
"key",
"test-model",
)
.with_protocol(LlmProtocolConfig {
format,
service_tier: LlmServiceTier::Fast,
..Default::default()
});
let body = request_body(&config, false, messages.clone());
assert_eq!(body["service_tier"], "fast");

config.provider_id = "openai".into();
let body = request_body(&config, false, messages.clone());
assert!(body.get("service_tier").is_none());
}
}

#[test]
fn budgets_are_validated_and_non_streaming_extracts_only_complete_text() {
let mut config = LlmProtocolConfig {
Expand Down
31 changes: 31 additions & 0 deletions openless-all/app/crates/openless-core/src/polish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,9 @@ impl OpenAICompatibleLLMProvider {
&self.config.model,
self.config.thinking_enabled,
);
self.config
.protocol
.apply_service_tier(&self.config.provider_id, &mut body);
body
}

Expand Down Expand Up @@ -2507,6 +2510,7 @@ mod tests {
.into_iter()
.flat_map(|format| {
[
("ark", "/api/v3"),
("custom", "/gateway/v1"),
("opencode", "/zen/v1"),
("opencode", "/zen/go/v1"),
Expand Down Expand Up @@ -2534,6 +2538,11 @@ mod tests {
let split = request.windows(4).position(|w| w == b"\r\n\r\n").unwrap();
let headers = String::from_utf8_lossy(&request[..split]).to_ascii_lowercase();
let body: Value = serde_json::from_slice(&request[split + 4..]).unwrap();
if preset == "ark" {
assert_eq!(body["service_tier"], "fast");
} else {
assert!(body.get("service_tier").is_none());
}
if format == LlmRequestFormat::Responses {
assert!(body.get("temperature").is_none());
} else {
Expand Down Expand Up @@ -2626,6 +2635,7 @@ mod tests {
.with_thinking_enabled(thinking_enabled)
.with_protocol(LlmProtocolConfig {
format,
service_tier: crate::llm_protocol::LlmServiceTier::Fast,
..Default::default()
});
let provider = OpenAICompatibleLLMProvider::new(config);
Expand Down Expand Up @@ -3364,6 +3374,27 @@ mod tests {
);
}

#[test]
fn ark_chat_body_includes_fast_service_tier() {
let provider = OpenAICompatibleLLMProvider::new(
OpenAICompatibleConfig::new(
"ark",
"Ark",
"https://ark.cn-beijing.volces.com/api/v3",
"k",
"doubao-seed-2-1-lite-260915",
)
.with_protocol(LlmProtocolConfig {
service_tier: crate::llm_protocol::LlmServiceTier::Fast,
..Default::default()
}),
);

let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]);

assert_eq!(body["service_tier"], "fast");
}

#[test]
fn openai_chat_body_adds_reasoning_effort_for_openai_reasoning_model() {
let provider = OpenAICompatibleLLMProvider::new(
Expand Down
5 changes: 5 additions & 0 deletions openless-all/app/crates/openless-core/src/shared_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2558,6 +2558,11 @@ pub enum CapsuleStyle {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CapsulePayload {
/// Session generation that owns this capsule frame. Keeping this on the
/// payload lets a Recording -> Recording transition reset the transcript
/// without depending on the order of backend:event and capsule:state.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
pub state: CapsuleState,
pub level: f32, // 0..1 RMS
pub elapsed_ms: u64,
Expand Down
Loading
Loading