feat(llm): 支持渠道级 Chat Completions、Responses 和 Messages (#1042)

* feat(llm): add channel request formats and compatibility presets

* fix(llm): harden channel protocol handling
This commit is contained in:
Chris233 authored and GitHub committed 2026-09-08 18:52:45 +08:00
1 parent a9ee22f334
commit 2b7c9d13c1
32 files changed
+2107 -339

No files matched your search

@@ -3438,7 +3438,40 @@ impl OpenLessBackend {
key: CredentialKey,
value: SecretValue,
) -> Result<CredentialsStatus, BackendError> {
if key.namespace == crate::CredentialNamespace::Llm
&& crate::llm_protocol::CONFIG_ACCOUNTS.contains(&key.account.as_str())
{
crate::llm_protocol::LlmProtocolConfig::default()
.apply(&key.account, value.expose_secret())?;
}
let invalidate = if key.namespace == crate::CredentialNamespace::Llm {
let id = match &key.provider_id {
Some(id) => id.clone(),
None => {
self.deps
.credential_store
.active_provider(crate::ProviderSlot::Llm)
.await?
}
};
self.list_channels(ChannelKind::Llm)
.await?
.into_iter()
.find(|channel| channel.id == id)
.map(|_| id)
} else {
None
};
self.deps.credential_store.write(key, value).await?;
if let Some(id) = invalidate {
self.deps
.credential_store
.mutate_channel(ChannelMutation::InvalidateTest {
kind: ChannelKind::Llm,
id,
})
.await?;
}
self.refresh_and_publish_credentials().await
}
@@ -3485,13 +3518,51 @@ impl OpenLessBackend {
id: String,
provider_type: String,
) -> Result<(), BackendError> {
self.apply_channel_mutation(ChannelMutation::SetProviderType {
kind,
id,
provider_type,
})
.await
.map(|_| ())
let provider_type = provider_type.trim().to_string();
if provider_type.trim().is_empty() {
return Err(BackendError::new(
BackendErrorCode::InvalidArgument,
"provider type must not be blank",
));
}
let previous = self
.list_channels(kind)
.await?
.into_iter()
.find(|channel| channel.id == id)
.ok_or_else(|| {
BackendError::new(BackendErrorCode::InvalidArgument, "unknown channel")
})?;
let key = CredentialKey::new(
crate::CredentialNamespace::Llm,
Some(id.clone()),
crate::llm_protocol::REQUEST_FORMAT_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?;
self.deps.credential_store.remove(key.clone()).await?;
value
} else {
None
};
let result = self
.deps
.credential_store
.mutate_channel(ChannelMutation::SetProviderType {
kind,
id,
provider_type,
})
.await
.map(|_| ());
if result.is_err() {
if let Some(value) = old_format {
self.deps.credential_store.write(key, value).await?;
}
}
result?;
self.refresh_and_publish_credentials().await.map(|_| ())
}
pub async fn delete_channel_if_blank(
@@ -3573,6 +3644,12 @@ impl OpenLessBackend {
.map(|_| ())
}
pub async fn invalidate_channel_tests(&self, kind: ChannelKind) -> Result<(), BackendError> {
self.apply_channel_mutation(ChannelMutation::InvalidateTests { kind })
.await
.map(|_| ())
}
pub async fn active_provider(&self, slot: ProviderSlot) -> Result<String, BackendError> {
self.deps.credential_store.active_provider(slot).await
}
@@ -8252,6 +8329,142 @@ mod tests {
));
}
#[tokio::test]
async fn invalidating_llm_tests_preserves_asr_test_results() {
let (backend, _) = backend();
for (kind, provider, name) in [
(ChannelKind::Llm, "custom", "first"),
(ChannelKind::Llm, "custom_messages", "second"),
(ChannelKind::Asr, "openai-compatible", "asr"),
] {
let id = backend
.create_channel(kind, provider.into(), name.into())
.await
.unwrap();
backend
.record_channel_test(kind, id, true, Some(1), None)
.await
.unwrap();
}
backend
.invalidate_channel_tests(ChannelKind::Llm)
.await
.unwrap();
assert!(backend
.list_channels(ChannelKind::Llm)
.await
.unwrap()
.iter()
.all(|channel| channel.last_test.is_none()));
assert!(backend.list_channels(ChannelKind::Asr).await.unwrap()[0]
.last_test
.is_some());
}
#[tokio::test]
async fn llm_protocol_mutations_reset_only_the_format_and_invalidate_tests() {
use crate::credentials::{CredentialNamespace, InMemoryCredentialStore, SecretValue};
use crate::llm_protocol::*;
let backend = OpenLessBackend::new(
BackendConfig {
data_dir: std::env::temp_dir()
.join(format!("openless-protocol-{}", uuid::Uuid::new_v4())),
..BackendConfig::default()
},
BackendDependencies {
host_actions: Arc::new(FakeHost::default()),
text_inserter: Arc::new(FakeInserter),
dictation_engine: Arc::new(FakeEngine),
task_spawner: Arc::new(TokioTaskSpawner),
credential_store: Arc::new(InMemoryCredentialStore::default()),
services: crate::domains::BackendServices::unsupported(),
local_asr_runtime: None,
marketplace_config: None,
selection_runtime: None,
selection_polisher: None,
qa_runtime: None,
},
)
.unwrap();
let id = backend
.create_channel(ChannelKind::Llm, "custom".into(), "test".into())
.await
.unwrap();
let key = |account: &str| {
CredentialKey::new(CredentialNamespace::Llm, Some(id.clone()), account).unwrap()
};
backend
.set_credential(key(REQUEST_FORMAT_ACCOUNT), SecretValue::new("messages"))
.await
.unwrap();
backend
.set_credential(
key(crate::credentials::LLM_API_KEY_ACCOUNT),
SecretValue::new("fixture-key"),
)
.await
.unwrap();
backend
.record_channel_test(ChannelKind::Llm, id.clone(), true, Some(1), None)
.await
.unwrap();
assert!(backend.list_channels(ChannelKind::Llm).await.unwrap()[0]
.last_test
.is_some());
backend
.set_credential(
key(crate::credentials::LLM_MODEL_ACCOUNT),
SecretValue::new("new-model"),
)
.await
.unwrap();
assert!(backend.list_channels(ChannelKind::Llm).await.unwrap()[0]
.last_test
.is_none());
assert_eq!(
backend
.read_credential(key(REQUEST_FORMAT_ACCOUNT))
.await
.unwrap()
.unwrap()
.expose_secret(),
"messages"
);
assert!(backend
.set_credential(key(REQUEST_FORMAT_ACCOUNT), SecretValue::new("invalid"))
.await
.is_err());
backend
.set_channel_provider_type(ChannelKind::Llm, id.clone(), "custom_responses".into())
.await
.unwrap();
assert!(backend
.read_credential(key(REQUEST_FORMAT_ACCOUNT))
.await
.unwrap()
.is_none());
assert_eq!(
backend
.read_credential(key(crate::credentials::LLM_API_KEY_ACCOUNT))
.await
.unwrap()
.unwrap()
.expose_secret(),
"fixture-key"
);
assert_eq!(
backend
.read_credential(key(crate::credentials::LLM_MODEL_ACCOUNT))
.await
.unwrap()
.unwrap()
.expose_secret(),
"new-model"
);
}
#[tokio::test]
async fn lifecycle_is_idempotent_and_emits_started_once_per_transition() {
let (backend, _) = backend();
@@ -77,6 +77,8 @@ pub const SHARED_CLOUD_LLM_PROVIDER_TYPES: &[&str] = &[
"minimax",
"stepfun",
"custom",
"custom_responses",
"custom_messages",
];
pub const SHARED_OMNI_PROVIDER_TYPES: &[&str] = &["openai", "gemini", "dashscope-omni", "custom"];
@@ -1037,12 +1039,9 @@ async fn build_cloud_polisher_provider(
return Ok(CloudPolisherProvider::Gemini(provider));
}
let base_url = endpoint
.trim()
.trim_end_matches('/')
.trim_end_matches("/chat/completions")
.trim_end_matches('/')
.to_string();
let protocol =
crate::llm_protocol::LlmProtocolConfig::load(credentials, channel_id, provider_type)
.await?;
let temperature = read_channel_credential(
credentials,
CredentialNamespace::Llm,
@@ -1068,10 +1067,11 @@ async fn build_cloud_polisher_provider(
let config = crate::polish::OpenAICompatibleConfig::new(
provider_type,
"OpenLess LLM",
base_url,
endpoint,
api_key,
model,
)
.with_protocol(protocol)
.with_thinking_enabled(context.polish.llm_thinking_enabled)
.with_temperature(crate::polish::openai_compatible_temperature_for_provider(
provider_type,
@@ -129,6 +129,13 @@ pub struct ChannelTestSummary {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChannelMutation {
InvalidateTest {
kind: ChannelKind,
id: String,
},
InvalidateTests {
kind: ChannelKind,
},
/// Commit a prepared local runtime and its channel in one metadata revision.
ActivateLocalAsr {
id: Option<String>,
@@ -519,6 +526,8 @@ impl CredentialMetadata {
ChannelMutation::ActivateLocalAsr { .. } => ChannelKind::Asr,
ChannelMutation::Create { kind, .. }
| ChannelMutation::SetProviderType { kind, .. }
| ChannelMutation::InvalidateTest { kind, .. }
| ChannelMutation::InvalidateTests { kind }
| ChannelMutation::DeleteIfBlank { kind, .. }
| ChannelMutation::Rename { kind, .. }
| ChannelMutation::Delete { kind, .. }
@@ -630,6 +639,16 @@ impl CredentialMetadata {
channel.last_test = None;
(kind, ChannelMutationResult::Applied)
}
ChannelMutation::InvalidateTest { kind, id } => {
find_channel_mut(&mut self.channels, kind, &id)?.last_test = None;
(kind, ChannelMutationResult::Applied)
}
ChannelMutation::InvalidateTests { kind } => {
for channel in self.channels.entry(kind).or_default() {
channel.last_test = None;
}
(kind, ChannelMutationResult::Applied)
}
ChannelMutation::DeleteIfBlank { kind, id } => {
let channels = self.channels.entry(kind).or_default();
let before = channels.len();
@@ -101,6 +101,10 @@ struct LegacyEntry {
xfyun_api_key: Option<String>,
temperature: Option<f64>,
extra_headers: Option<BTreeMap<String, String>>,
request_format: Option<String>,
messages_thinking: Option<String>,
max_tokens: Option<String>,
thinking_budget: Option<String>,
}
impl Default for LegacyEntry {
@@ -125,6 +129,10 @@ impl Default for LegacyEntry {
xfyun_api_key: None,
temperature: None,
extra_headers: None,
request_format: None,
messages_thinking: None,
max_tokens: None,
thinking_budget: None,
}
}
}
@@ -145,6 +153,10 @@ impl LegacyEntry {
&self.advanced_config,
&self.xfyun_app_id,
&self.xfyun_api_key,
&self.request_format,
&self.messages_thinking,
&self.max_tokens,
&self.thinking_budget,
]
.into_iter()
.any(|value| value.as_deref().is_some_and(|value| !value.is_empty()))
@@ -347,6 +359,15 @@ fn decode_entry(
(endpoint, entry.base_url),
(model, entry.model),
];
if namespace == CredentialNamespace::Llm {
use crate::llm_protocol::*;
fields.extend([
(REQUEST_FORMAT_ACCOUNT, entry.request_format),
(MESSAGES_THINKING_ACCOUNT, entry.messages_thinking),
(MAX_TOKENS_ACCOUNT, entry.max_tokens),
(THINKING_BUDGET_ACCOUNT, entry.thinking_budget),
]);
}
if namespace == CredentialNamespace::Asr {
fields.extend([
(VOLCENGINE_APP_KEY_ACCOUNT, entry.app_key),
@@ -44,6 +44,8 @@ pub enum ProviderKind {
#[serde(rename_all = "camelCase")]
pub struct ProviderRequest {
pub kind: ProviderKind,
#[serde(default)]
pub thinking_enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channel_id: Option<String>,
}
@@ -31,6 +31,7 @@ pub mod host_document;
mod hotkey_interpreter;
mod less_computer;
pub mod llm_gemini;
pub mod llm_protocol;
mod marketplace;
pub mod model_store;
pub mod net;
@@ -0,0 +1,772 @@
//! 渠道级文本协议:请求格式、鉴权和正文事件由 Core 统一解释。
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::credentials::{CredentialKey, CredentialNamespace, CredentialStore};
use crate::polish::{LLMError, OpenAICompatibleConfig};
use crate::{BackendError, BackendErrorCode};
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] = [
REQUEST_FORMAT_ACCOUNT,
MESSAGES_THINKING_ACCOUNT,
MAX_TOKENS_ACCOUNT,
THINKING_BUDGET_ACCOUNT,
];
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LlmRequestFormat {
#[default]
ChatCompletions,
Responses,
Messages,
}
impl LlmRequestFormat {
pub const ALL: [Self; 3] = [Self::ChatCompletions, Self::Responses, Self::Messages];
pub fn default_for(provider: &str) -> Self {
match provider {
"custom_responses" => Self::Responses,
"custom_messages" => Self::Messages,
_ => Self::ChatCompletions,
}
}
pub fn selectable(provider: &str) -> bool {
!matches!(provider, "gemini" | "codex_oauth")
}
pub fn parse(value: &str) -> Result<Self, BackendError> {
match value.trim() {
"chat_completions" => Ok(Self::ChatCompletions),
"responses" => Ok(Self::Responses),
"messages" => Ok(Self::Messages),
_ => Err(config_error("llmRequestFormatInvalid")),
}
}
pub fn url(self, endpoint: &str) -> Result<String, LLMError> {
endpoint_url(
endpoint,
match self {
Self::ChatCompletions => "/chat/completions",
Self::Responses => "/responses",
Self::Messages => "/messages",
},
)
}
pub fn headers(self, api_key: &str) -> Vec<(String, String)> {
let mut headers = Vec::new();
if self == Self::Messages {
headers.push(("anthropic-version".into(), "2023-06-01".into()));
}
if !api_key.trim().is_empty() {
headers.push(if self == Self::Messages {
("x-api-key".into(), api_key.to_string())
} else {
("Authorization".into(), format!("Bearer {api_key}"))
});
}
headers
}
}
/// 更换格式只替换已知的末端路径,不破坏网关前缀及查询参数。
pub fn endpoint_url(endpoint: &str, suffix: &str) -> Result<String, LLMError> {
let mut url = url::Url::parse(endpoint.trim())
.map_err(|_| LLMError::ParseError("invalid LLM endpoint".into()))?;
let path = url.path().trim_end_matches('/');
let prefix = ["/chat/completions", "/responses", "/messages", "/models"]
.iter()
.find_map(|suffix| path.strip_suffix(suffix))
.unwrap_or(path);
url.set_path(&format!("{prefix}{suffix}"));
Ok(url.to_string())
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MessagesThinking {
#[default]
Adaptive,
Budget,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LlmProtocolConfig {
pub format: LlmRequestFormat,
pub messages_thinking: MessagesThinking,
pub max_tokens: u32,
pub thinking_budget: u32,
}
impl Default for LlmProtocolConfig {
fn default() -> Self {
Self {
format: LlmRequestFormat::ChatCompletions,
messages_thinking: MessagesThinking::Adaptive,
max_tokens: 8192,
thinking_budget: 1024,
}
}
}
impl LlmProtocolConfig {
pub fn validate_headers(
&self,
headers: &std::collections::HashMap<String, String>,
) -> Result<(), BackendError> {
if self.format == LlmRequestFormat::Messages
&& headers.keys().any(|name| {
name.eq_ignore_ascii_case("x-api-key")
|| name.eq_ignore_ascii_case("anthropic-version")
})
{
return Err(config_error("llmProtocolHeaderConflict"));
}
Ok(())
}
pub async fn load(
store: &dyn CredentialStore,
channel: &str,
provider: &str,
) -> Result<Self, BackendError> {
let mut config = Self {
format: LlmRequestFormat::default_for(provider),
..Self::default()
};
if !LlmRequestFormat::selectable(provider) {
return Ok(config);
}
for account in CONFIG_ACCOUNTS {
let key =
CredentialKey::new(CredentialNamespace::Llm, Some(channel.to_string()), account)?;
if let Some(value) = store.read(key).await? {
config.apply(account, value.expose_secret())?;
}
}
config.validate()?;
Ok(config)
}
pub fn apply(&mut self, account: &str, value: &str) -> Result<(), BackendError> {
let value = value.trim();
if value.is_empty() {
return Ok(());
}
match account {
REQUEST_FORMAT_ACCOUNT => self.format = LlmRequestFormat::parse(value)?,
MESSAGES_THINKING_ACCOUNT => {
self.messages_thinking = match value {
"adaptive" => MessagesThinking::Adaptive,
"budget" => MessagesThinking::Budget,
_ => return Err(config_error("llmThinkingModeInvalid")),
}
}
MAX_TOKENS_ACCOUNT => self.max_tokens = positive_tokens(value)?,
THINKING_BUDGET_ACCOUNT => {
self.thinking_budget = positive_tokens(value)?;
if self.thinking_budget < 1024 {
return Err(config_error("llmThinkingBudgetInvalid"));
}
}
_ => return Err(config_error("llmRequestFormatInvalid")),
}
Ok(())
}
pub fn validate(&self) -> Result<(), BackendError> {
if self.format == LlmRequestFormat::Messages
&& (self.max_tokens == 0
|| self.messages_thinking == MessagesThinking::Budget
&& (self.thinking_budget < 1024 || self.thinking_budget >= self.max_tokens))
{
return Err(config_error("llmThinkingBudgetInvalid"));
}
Ok(())
}
}
fn positive_tokens(value: &str) -> Result<u32, BackendError> {
value
.parse::<u32>()
.ok()
.filter(|value| *value > 0)
.ok_or_else(|| config_error("llmTokenLimitInvalid"))
}
fn config_error(message: &str) -> BackendError {
BackendError::new(BackendErrorCode::InvalidArgument, message)
}
pub(crate) fn request_body(
config: &OpenAICompatibleConfig,
stream: bool,
messages: Vec<Value>,
) -> Value {
let mut body = match config.protocol.format {
LlmRequestFormat::ChatCompletions => {
unreachable!("Chat Completions retains its provider rules")
}
LlmRequestFormat::Responses => {
let mut body =
json!({"model": config.model, "stream": stream, "store": false, "input": messages});
let model = config
.model
.trim()
.strip_prefix("openai/")
.unwrap_or(config.model.trim())
.to_ascii_lowercase();
// 已知普通模型不接受 reasoning;未知网关模型按所选兼容协议声明参数。
if !(model.starts_with("gpt-4")
|| model.starts_with("gpt-3.5")
|| model.starts_with("chatgpt-4"))
{
let effort = if model.starts_with("gpt-5-pro")
|| model.contains("-pro") && model.starts_with("gpt-5.")
{
"high"
} else if config.thinking_enabled {
"medium"
} else {
"low"
};
body["reasoning"] = json!({"effort": effort});
}
body
}
LlmRequestFormat::Messages => {
let mut system = Vec::new();
let mut turns = Vec::new();
for message in messages {
if matches!(message["role"].as_str(), Some("system" | "developer")) {
if let Some(text) = message["content"].as_str() {
system.push(text.to_string());
}
} else {
turns.push(message);
}
}
let mut body = json!({"model": config.model, "stream": stream, "messages": turns, "max_tokens": config.protocol.max_tokens});
if !system.is_empty() {
body["system"] = json!(system.join("\n\n"));
}
if config.thinking_enabled {
body["thinking"] =
if config.protocol.messages_thinking == MessagesThinking::Adaptive {
json!({"type": "adaptive"})
} else {
json!({"type": "enabled", "budget_tokens": config.protocol.thinking_budget})
};
}
body
}
};
if body.get("reasoning").is_none()
&& !(config.protocol.format == LlmRequestFormat::Messages && config.thinking_enabled)
{
if let Some(temperature) = config.temperature {
body["temperature"] = json!(temperature);
}
}
body
}
fn response_error(message: &str) -> LLMError {
LLMError::ParseError(message.to_string())
}
fn check_stop_reason(value: &Value) -> Result<bool, LLMError> {
if let Some(reason) = value.as_str() {
if !matches!(reason, "end_turn" | "stop_sequence") {
return Err(response_error("llmResponseIncomplete"));
}
return Ok(true);
}
Ok(false)
}
pub(crate) fn extract_text(format: LlmRequestFormat, text: &str) -> Result<String, LLMError> {
if format == LlmRequestFormat::ChatCompletions {
return crate::polish::extract_assistant_content(text);
}
let value: Value =
serde_json::from_str(text).map_err(|_| response_error("invalid LLM JSON"))?;
if !value["error"].is_null() {
return Err(response_error("llmStreamError"));
}
let mut output = String::new();
match format {
LlmRequestFormat::Responses => {
if value["status"] != "completed" {
return Err(response_error("llmResponseIncomplete"));
}
if let Some(items) = value["output"].as_array() {
for item in items {
if item["type"] == "message" && item["role"] == "assistant" {
append_blocks(&mut output, &item["content"], "output_text");
}
}
}
}
LlmRequestFormat::Messages => {
if !check_stop_reason(&value["stop_reason"])? {
return Err(response_error("llmResponseIncomplete"));
}
append_blocks(&mut output, &value["content"], "text");
}
LlmRequestFormat::ChatCompletions => unreachable!(),
}
if output.is_empty() {
return Err(response_error("empty LLM response"));
}
Ok(crate::polish::clean_polish_output(&output))
}
fn append_blocks(output: &mut String, blocks: &Value, kind: &str) {
if let Some(blocks) = blocks.as_array() {
for block in blocks {
if block["type"] == kind {
if let Some(text) = block["text"].as_str() {
output.push_str(text);
}
}
}
}
}
pub(crate) enum StreamEvent {
Text(String),
Done,
Ignore,
}
/// 共用 SSE 分帧;保留未完整的 UTF-8 字节,不能逐个 HTTP chunk 有损解码。
pub(crate) struct TextEventStream {
format: LlmRequestFormat,
buffer: String,
pending: Vec<u8>,
messages_complete: bool,
pub done: bool,
}
impl TextEventStream {
pub fn new(format: LlmRequestFormat) -> Self {
Self {
format,
buffer: String::new(),
pending: Vec::new(),
messages_complete: false,
done: false,
}
}
pub fn push(&mut self, chunk: &[u8]) -> Result<(), LLMError> {
crate::polish::append_utf8_sse_chunk(&mut self.buffer, &mut self.pending, chunk)?;
// 在完整字符串上替换,兼容 CR 与 LF 分属不同网络块。
if self.buffer.contains("\r\n") {
self.buffer = self.buffer.replace("\r\n", "\n");
}
Ok(())
}
pub fn next(&mut self) -> Result<Option<StreamEvent>, LLMError> {
if self.done {
return Ok(None);
}
let Some(end) = self.buffer.find("\n\n") else {
return Ok(None);
};
let event = self.buffer[..end].to_string();
self.buffer.drain(..end + 2);
let data = event
.lines()
.filter_map(|line| line.strip_prefix("data:").map(str::trim_start))
.collect::<Vec<_>>()
.join("\n");
if data.is_empty() {
return Ok(Some(StreamEvent::Ignore));
}
if self.format == LlmRequestFormat::ChatCompletions && data.trim() == "[DONE]" {
self.done = true;
return Ok(Some(StreamEvent::Done));
}
let value: Value = match serde_json::from_str(&data) {
Ok(value) => value,
Err(_) if self.format == LlmRequestFormat::ChatCompletions => {
return Ok(Some(StreamEvent::Ignore))
}
Err(_) => return Err(response_error("invalid LLM SSE JSON")),
};
let kind = value["type"]
.as_str()
.or_else(|| {
event
.lines()
.find_map(|line| line.strip_prefix("event:").map(str::trim))
})
.unwrap_or("");
if kind == "error" || !value["error"].is_null() {
return Err(response_error("llmStreamError"));
}
let text = match self.format {
LlmRequestFormat::ChatCompletions => value["choices"][0]["delta"]["content"].as_str(),
LlmRequestFormat::Responses => match kind {
"response.output_text.delta" => value["delta"].as_str(),
"response.completed" => {
if value["response"]["status"]
.as_str()
.is_some_and(|s| s != "completed")
{
return Err(response_error("llmResponseIncomplete"));
}
self.done = true;
None
}
"response.failed" | "response.incomplete" => {
return Err(response_error("llmResponseIncomplete"))
}
_ => None,
},
LlmRequestFormat::Messages => match kind {
"content_block_start" if value["content_block"]["type"] == "text" => {
value["content_block"]["text"].as_str()
}
"content_block_delta" if value["delta"]["type"] == "text_delta" => {
value["delta"]["text"].as_str()
}
"message_delta" => {
if check_stop_reason(&value["delta"]["stop_reason"])? {
self.messages_complete = true;
}
None
}
"message_stop" => {
if !self.messages_complete {
return Err(response_error("llmResponseIncomplete"));
}
self.done = true;
None
}
_ => None,
},
};
Ok(Some(
if let Some(text) = text.filter(|text| !text.is_empty()) {
StreamEvent::Text(text.to_string())
} else if self.done {
StreamEvent::Done
} else {
StreamEvent::Ignore
},
))
}
pub fn finish(&mut self) -> Result<(), LLMError> {
crate::polish::finish_utf8_sse_chunks(&mut self.buffer, &mut self.pending)?;
if self.format != LlmRequestFormat::ChatCompletions && !self.done {
return Err(response_error("llmResponseIncomplete"));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::credentials::{InMemoryCredentialStore, SecretValue};
#[tokio::test]
async fn protocol_defaults_overrides_and_channels_are_independent() {
let store = InMemoryCredentialStore::default();
for (provider, expected) in [
("custom", LlmRequestFormat::ChatCompletions),
("custom_responses", LlmRequestFormat::Responses),
("custom_messages", LlmRequestFormat::Messages),
("deepseek", LlmRequestFormat::ChatCompletions),
] {
assert_eq!(
LlmProtocolConfig::load(&store, "a", provider)
.await
.unwrap()
.format,
expected
);
}
let key = CredentialKey::new(
CredentialNamespace::Llm,
Some("a".into()),
REQUEST_FORMAT_ACCOUNT,
)
.unwrap();
store
.write(key.clone(), SecretValue::new("messages"))
.await
.unwrap();
assert_eq!(
LlmProtocolConfig::load(&store, "a", "openai")
.await
.unwrap()
.format,
LlmRequestFormat::Messages
);
assert_eq!(
LlmProtocolConfig::load(&store, "b", "openai")
.await
.unwrap()
.format,
LlmRequestFormat::ChatCompletions
);
store.write(key, SecretValue::new("invalid")).await.unwrap();
assert!(LlmProtocolConfig::load(&store, "a", "openai")
.await
.is_err());
}
#[test]
fn urls_and_auth_follow_format_without_losing_gateway_paths() {
for descriptor in crate::provider_rules::provider_descriptors(crate::ProviderKind::Llm) {
if LlmRequestFormat::selectable(descriptor.provider_type.as_str()) {
assert_eq!(descriptor.supported_request_formats, LlmRequestFormat::ALL);
assert_eq!(
descriptor.default_request_format,
Some(LlmRequestFormat::default_for(
descriptor.provider_type.as_str()
))
);
} else {
assert!(descriptor.supported_request_formats.is_empty());
assert!(descriptor.default_request_format.is_none());
}
}
for format in LlmRequestFormat::ALL {
let suffix = match format {
LlmRequestFormat::ChatCompletions => "chat/completions",
LlmRequestFormat::Responses => "responses",
LlmRequestFormat::Messages => "messages",
};
for old in [
"",
"/chat/completions/",
"/responses",
"/messages/",
"/models",
] {
let base = format!("https://example.com/gateway/v1{old}?tenant=1#local");
assert_eq!(
format.url(&base).unwrap(),
format!("https://example.com/gateway/v1/{suffix}?tenant=1#local")
);
assert_eq!(
endpoint_url(&base, "/models").unwrap(),
"https://example.com/gateway/v1/models?tenant=1#local"
);
}
let headers = format.headers("test-key");
if format == LlmRequestFormat::Messages {
assert!(headers.contains(&("x-api-key".into(), "test-key".into())));
assert!(!headers.iter().any(|(name, _)| name == "Authorization"));
} else {
assert_eq!(
headers,
vec![("Authorization".into(), "Bearer test-key".into())]
);
}
}
let headers =
crate::provider_rules::parse_extra_headers(r#"{"X-API-Key":"override"}"#).unwrap();
assert!(LlmProtocolConfig::default()
.validate_headers(&headers)
.is_ok());
assert!(LlmProtocolConfig {
format: LlmRequestFormat::Messages,
..Default::default()
}
.validate_headers(&headers)
.is_err());
}
#[test]
fn request_shapes_and_thinking_do_not_leak_between_protocols() {
let messages = vec![
json!({"role":"system","content":"rules"}),
json!({"role":"user","content":"old"}),
json!({"role":"assistant","content":"answer"}),
json!({"role":"user","content":"new"}),
];
let mut config = OpenAICompatibleConfig::new(
"deepseek",
"test",
"https://example.com/v1",
"key",
"gateway-model",
)
.with_temperature(Some(0.5));
config.protocol.format = LlmRequestFormat::Responses;
for enabled in [false, true] {
config.thinking_enabled = enabled;
let body = request_body(&config, true, messages.clone());
assert_eq!(body["input"], json!(messages));
assert_eq!(body["store"], false);
assert_eq!(
body["reasoning"]["effort"],
if enabled { "medium" } else { "low" }
);
for absent in [
"messages",
"thinking",
"enable_thinking",
"reasoning_effort",
"temperature",
] {
assert!(body.get(absent).is_none());
}
}
config.model = "gpt-4o".into();
let body = request_body(&config, false, messages.clone());
assert!(body.get("reasoning").is_none());
assert_eq!(body["temperature"], 0.5);
config.model = "gpt-5-pro".into();
assert_eq!(
request_body(&config, false, messages.clone())["reasoning"]["effort"],
"high"
);
config.protocol.format = LlmRequestFormat::Messages;
for enabled in [false, true] {
config.thinking_enabled = enabled;
for mode in [MessagesThinking::Adaptive, MessagesThinking::Budget] {
config.protocol.messages_thinking = mode;
let body = request_body(&config, false, messages.clone());
assert_eq!(body["system"], "rules");
assert_eq!(body["messages"], json!(&messages[1..]));
assert_eq!(body["max_tokens"], 8192);
if enabled {
assert_eq!(
body["thinking"]["type"],
if mode == MessagesThinking::Adaptive {
"adaptive"
} else {
"enabled"
}
);
} else {
assert!(body.get("thinking").is_none());
}
assert_eq!(body.get("temperature").is_none(), enabled);
if enabled && mode == MessagesThinking::Budget {
assert_eq!(body["thinking"]["budget_tokens"], 1024);
}
for absent in ["input", "reasoning", "reasoning_effort", "enable_thinking"] {
assert!(body.get(absent).is_none());
}
}
}
}
#[test]
fn budgets_are_validated_and_non_streaming_extracts_only_complete_text() {
let mut config = LlmProtocolConfig {
format: LlmRequestFormat::Messages,
messages_thinking: MessagesThinking::Budget,
..Default::default()
};
for value in ["0", "-1", "1.5", "4294967296"] {
assert!(config.apply(MAX_TOKENS_ACCOUNT, value).is_err());
}
assert!(config.apply(THINKING_BUDGET_ACCOUNT, "1023").is_err());
config.apply(THINKING_BUDGET_ACCOUNT, "8192").unwrap();
assert!(config.validate().is_err());
config.apply(MAX_TOKENS_ACCOUNT, "10000").unwrap();
config.validate().unwrap();
for (format, body) in [
(
LlmRequestFormat::Responses,
json!({"status":"completed", "output":[{"type":"reasoning","summary":"secret"},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"你"},{"type":"output_text","text":"好"}]}]}),
),
(
LlmRequestFormat::Messages,
json!({"stop_reason":"end_turn", "content":[{"type":"thinking","thinking":"secret"},{"type":"text","text":"你"},{"type":"text","text":"好"}]}),
),
] {
assert_eq!(extract_text(format, &body.to_string()).unwrap(), "你好");
}
assert!(extract_text(
LlmRequestFormat::Responses,
r#"{"status":"incomplete","output":[]}"#
)
.is_err());
assert!(extract_text(
LlmRequestFormat::Messages,
r#"{"stop_reason":"max_tokens","content":[{"type":"text","text":"partial"}]}"#
)
.is_err());
}
#[test]
fn sse_handles_every_byte_boundary_and_requires_successful_termination() {
for (format, fixture) in [
(LlmRequestFormat::Responses, "event: response.output_text.delta\r\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"你好🙂\"}\r\n\r\ndata: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"secret\"}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n"),
(LlmRequestFormat::Messages, "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"你好🙂\"}}\r\n\r\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"secret\"}}\n\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n"),
] {
for size in 1..=fixture.len() {
let mut stream = TextEventStream::new(format);
let mut text = String::new();
for chunk in fixture.as_bytes().chunks(size) {
stream.push(chunk).unwrap();
while let Some(event) = stream.next().unwrap() {
if let StreamEvent::Text(delta) = event { text.push_str(&delta); }
}
}
stream.finish().unwrap();
assert_eq!(text, "你好🙂");
}
assert!(TextEventStream::new(format).finish().is_err());
}
for (format, event) in [
(LlmRequestFormat::Responses, r#"{"type":"response.failed"}"#),
(
LlmRequestFormat::Responses,
r#"{"type":"response.incomplete"}"#,
),
(
LlmRequestFormat::Messages,
r#"{"type":"message_delta","delta":{"stop_reason":"max_tokens"}}"#,
),
(
LlmRequestFormat::Messages,
r#"{"type":"error","error":{"message":"secret"}}"#,
),
] {
let mut stream = TextEventStream::new(format);
stream
.push(format!("data: {event}\n\n").as_bytes())
.unwrap();
let error = stream
.next()
.err()
.expect("must reject unsuccessful streams");
assert!(!error.to_string().contains("secret"));
}
let mut stream = TextEventStream::new(LlmRequestFormat::Messages);
stream
.push(
b"data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"partial\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n",
)
.unwrap();
assert!(matches!(stream.next().unwrap(), Some(StreamEvent::Text(_))));
let error = match stream.next() {
Err(error) => error,
Ok(_) => panic!("message_stop without stop_reason must fail"),
};
assert!(error.to_string().contains("llmResponseIncomplete"));
}
}
@@ -1,6 +1,6 @@
#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))]
#![allow(clippy::too_many_arguments)]
//! OpenAI-compatible chat completions client + polish prompts.
//! 渠道级文本协议客户端与润色提示词。
//!
//! 提示词在 `prompts` 模块中维护:使用 `# 角色 / # 任务 / # 通用规则 / # 输出 / # 示例`
//! 段落式结构,每个 mode 有独立的 1-shot 示例。重写背景见 issue #47。
@@ -13,6 +13,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::{json, Value};
use thiserror::Error;
use crate::llm_protocol::{LlmProtocolConfig, LlmRequestFormat, StreamEvent, TextEventStream};
use crate::shared_types::{ChineseScriptPreference, OutputLanguagePreference, QaChatMessage};
use crate::types::PolishMode;
@@ -94,6 +95,7 @@ pub(crate) fn polish_total_timeout_secs(input_chars: usize) -> Duration {
#[derive(Clone, Debug)]
pub struct OpenAICompatibleConfig {
pub protocol: LlmProtocolConfig,
pub provider_id: String,
pub display_name: String,
pub base_url: String,
@@ -120,6 +122,10 @@ impl OpenAICompatibleConfig {
let temperature = openai_compatible_temperature_for_provider(&provider_id, None);
Self {
protocol: LlmProtocolConfig {
format: LlmRequestFormat::default_for(&provider_id),
..Default::default()
},
provider_id,
display_name: display_name.into(),
base_url: base_url.into(),
@@ -137,6 +143,11 @@ impl OpenAICompatibleConfig {
self
}
pub fn with_protocol(mut self, protocol: LlmProtocolConfig) -> Self {
self.protocol = protocol;
self
}
pub fn with_extra_headers(mut self, extra_headers: HashMap<String, String>) -> Self {
self.extra_headers = extra_headers;
self
@@ -234,8 +245,7 @@ impl ActiveLLMProvider {
}
}
/// 流式润色仅支持 OpenAI-compatible;Codex 使用 Responses API,保留 1.x 的
/// 非流式润色能力。调用方须先检查此能力,不能把不同的 SSE 协议混用。
/// 普通渠道的三种文本协议均支持流式润色;Codex OAuth 保留专用调用路径。
/// Gemini 由共享 cloud_providers 单独分流,不进入 ActiveLLMProvider 枚举。
pub fn supports_streaming_polish(&self) -> bool {
matches!(self, Self::OpenAI(_))
@@ -418,7 +428,6 @@ impl ActiveLLMProvider {
pub struct OpenAICompatibleLLMProvider {
config: OpenAICompatibleConfig,
client: reqwest::Client,
/// 润色专用客户端:**不带**按输入长度变化的整请求超时,只留一个防连接泄漏的
/// 硬顶。真正的判据在调用点(流式两把尺子 / 非流式一个总预算)。
///
@@ -434,15 +443,8 @@ impl OpenAICompatibleLLMProvider {
// pool survives across utterances instead of paying a fresh TLS handshake
// every polish. Falls back to a default client if the builder somehow fails
// so we still surface a useful error at request time.
let timeout = config.request_timeout_secs;
let no_proxy =
crate::net::should_bypass_proxy(&config.base_url, crate::net::use_system_proxy());
let base_url = config.base_url.clone();
let client = crate::net::cached_client((timeout, no_proxy), || {
http_client_builder(&base_url, timeout)
.build()
.unwrap_or_else(|_| reqwest::Client::new())
});
let polish_base_url = config.base_url.clone();
let polish_client =
crate::net::cached_client((POLISH_CLIENT_HARD_CAP_SECS, no_proxy), || {
@@ -452,7 +454,6 @@ impl OpenAICompatibleLLMProvider {
});
Self {
config,
client,
polish_client,
}
}
@@ -632,7 +633,7 @@ impl OpenAICompatibleLLMProvider {
user_prompt: &str,
budget: Duration,
) -> Result<String, LLMError> {
let url = chat_completions_url(&self.config.base_url);
let url = self.config.protocol.format.url(&self.config.base_url)?;
let messages = build_polish_history_messages(system_prompt, prior_turns, user_prompt);
let body = self.chat_body(false, messages);
@@ -654,7 +655,7 @@ impl OpenAICompatibleLLMProvider {
user_prompt: &str,
budget: Duration,
) -> Result<String, LLMError> {
let url = chat_completions_url(&self.config.base_url);
let url = self.config.protocol.format.url(&self.config.base_url)?;
let body = self.chat_body(
false,
vec![
@@ -674,6 +675,9 @@ impl OpenAICompatibleLLMProvider {
}
fn chat_body(&self, stream: bool, messages: Vec<Value>) -> Value {
if self.config.protocol.format != LlmRequestFormat::ChatCompletions {
return crate::llm_protocol::request_body(&self.config, stream, messages);
}
let mut body = json!({
"model": self.config.model,
"stream": stream,
@@ -724,17 +728,19 @@ impl OpenAICompatibleLLMProvider {
url: &str,
body: &serde_json::Value,
) -> Result<String, LLMError> {
let mut request = self
.polish_client
.post(url)
.header("Content-Type", "application/json");
if !self.config.api_key.trim().is_empty() {
request = request.header("Authorization", format!("Bearer {}", self.config.api_key));
}
for (k, v) in &self.config.extra_headers {
request = request.header(k.as_str(), v.as_str());
}
let request = request.json(body);
self.config
.protocol
.validate()
.and_then(|_| {
self.config
.protocol
.validate_headers(&self.config.extra_headers)
})
.map_err(|error| LLMError::ParseError(error.message))?;
let request = self
.authorize(self.polish_client.post(url))
.header("Content-Type", "application/json")
.json(body);
let response = send_with_transient_retry(request).await?;
@@ -752,12 +758,10 @@ impl OpenAICompatibleLLMProvider {
});
}
extract_assistant_content(&body_text)
crate::llm_protocol::extract_text(self.config.protocol.format, &body_text)
}
/// 与 `chat_completion` 同条 HTTP 通路,但开 `stream: true` 并把 SSE chunk 一边
/// 解析、一边通过 `on_delta` 推给调用方(用于实时把答案塞进浮窗气泡)。
/// 最终返回拼好的完整字符串供调用方写入对话历史。
/// 问答与润色共用协议解码,但问答保留配置中的整请求预算。
async fn chat_completion_history_streaming<F, C>(
&self,
system_prompt: &str,
@@ -769,125 +773,27 @@ impl OpenAICompatibleLLMProvider {
F: Fn(&str) + Send + Sync,
C: Fn() -> bool + Send + Sync,
{
let mut msgs: Vec<Value> = Vec::with_capacity(history.len() + 1);
msgs.push(json!({ "role": "system", "content": system_prompt }));
for m in history {
msgs.push(json!({ "role": m.role, "content": m.content }));
let mut messages = vec![json!({ "role": "system", "content": system_prompt })];
for message in history {
messages.push(json!({ "role": message.role, "content": message.content }));
}
let url = chat_completions_url(&self.config.base_url);
let body = self.chat_body(true, msgs);
log::info!(
"[llm] POST {} provider={} model={} chat_turns={} stream=true",
crate::net::sanitized_url_for_logs(&url),
self.config.provider_id,
self.config.model,
history.len()
);
let mut request = self
.client
.post(&url)
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream");
if !self.config.api_key.trim().is_empty() {
request = request.header("Authorization", format!("Bearer {}", self.config.api_key));
}
for (k, v) in &self.config.extra_headers {
request = request.header(k.as_str(), v.as_str());
}
let request = request.json(&body);
let response = send_with_transient_retry(request).await?;
let status = response.status();
if !status.is_success() {
// 失败时仍把 body 读一遍方便诊断
let body_text = response.text().await.map_err(llm_error_from_reqwest)?;
let preview_end = BODY_PREVIEW_LIMIT.min(body_text.len());
let preview = safe_str_slice(&body_text, preview_end);
log::error!("[llm] HTTP {} body={}", status.as_u16(), preview);
return Err(LLMError::InvalidResponse {
status: status.as_u16(),
body: preview.to_string(),
});
}
// SSE 流:一帧 = 若干行,以 `\n\n` 分隔。每行如 `data: {...}` 或 `data: [DONE]`。
// 一个 chunk() 可能包含半帧或多帧;用 buffer 累积后再按 `\n\n` 切。
let mut response = response;
let mut buffer = String::new();
let mut utf8_pending: Vec<u8> = Vec::new();
let mut full_text = String::new();
let mut cancelled = false;
loop {
// 取消旗标:用户取消 / 关浮窗时立即 break,不再 drain HTTP body。
// 否则 reqwest 会读完整个流(包括 LLM 后续 token)烧 quota。详见 issue #161。
if should_cancel() {
log::info!("[llm] stream cancelled by caller; breaking SSE loop");
cancelled = true;
break;
}
let chunk_opt = response.chunk().await.map_err(llm_error_from_reqwest)?;
let Some(chunk) = chunk_opt else { break };
append_utf8_sse_chunk(&mut buffer, &mut utf8_pending, &chunk)?;
while let Some(idx) = buffer.find("\n\n") {
let event = buffer[..idx].to_string();
buffer.drain(..idx + 2);
for line in event.lines() {
let Some(payload) = line
.strip_prefix("data: ")
.or_else(|| line.strip_prefix("data:"))
else {
continue;
};
let payload = payload.trim();
if payload.is_empty() || payload == "[DONE]" {
continue;
}
let v: Value = match serde_json::from_str(payload) {
Ok(v) => v,
Err(e) => {
log::warn!(
"[llm] SSE parse skip: {e}; payload preview: {}",
safe_str_slice(payload, 80)
);
continue;
}
};
if let Some(delta) = v["choices"][0]["delta"]["content"].as_str() {
if !delta.is_empty() {
full_text.push_str(delta);
on_delta(delta);
}
}
}
}
}
if !cancelled {
finish_utf8_sse_chunks(&mut buffer, &mut utf8_pending)?;
}
log::info!(
"[llm] HTTP 200 stream done; total chars={}",
full_text.chars().count()
);
if full_text.is_empty() {
return Err(LLMError::InvalidResponse {
status: 200,
body: "empty stream".to_string(),
});
}
Ok(full_text)
let budget = Duration::from_secs(self.config.request_timeout_secs);
tokio::time::timeout(
budget,
self.chat_completion_messages_streaming(
messages,
StreamingTimeouts {
first_token: budget,
idle: budget,
},
on_delta,
should_cancel,
),
)
.await
.map_err(|_| LLMError::Timeout)?
}
/// 把已经构造好的 `messages` 列表(包含 system + 历史 + 当前 user)作为
/// `stream: true` 的 body 发出去,SSE 一帧一帧解析。供 `polish_streaming` 复用,
/// 跟 `chat_completion_history_streaming` 的 SSE 解析逻辑同款 —— 后者多了一步从
/// `QaChatMessage[]` 装配 messages 的工作。
async fn chat_completion_messages_streaming<F, C>(
&self,
messages: Vec<Value>,
@@ -899,151 +805,132 @@ impl OpenAICompatibleLLMProvider {
F: Fn(&str) + Send + Sync,
C: Fn() -> bool + Send + Sync,
{
let url = chat_completions_url(&self.config.base_url);
if should_cancel() {
return Err(LLMError::Network("cancelled".into()));
}
self.config
.protocol
.validate()
.and_then(|_| {
self.config
.protocol
.validate_headers(&self.config.extra_headers)
})
.map_err(|error| LLMError::ParseError(error.message))?;
let url = self.config.protocol.format.url(&self.config.base_url)?;
let body = self.chat_body(true, messages);
let mut request = self
.polish_client
.post(&url)
log::info!(
"[llm] POST {} provider={} model={} format={:?} stream=true",
crate::net::sanitized_url_for_logs(&url),
self.config.provider_id,
self.config.model,
self.config.protocol.format
);
let request = self
.authorize(self.polish_client.post(&url))
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream");
if !self.config.api_key.trim().is_empty() {
request = request.header("Authorization", format!("Bearer {}", self.config.api_key));
}
for (k, v) in &self.config.extra_headers {
request = request.header(k.as_str(), v.as_str());
}
let request = request.json(&body);
let response = send_with_transient_retry(request).await?;
.header("Accept", "text/event-stream")
.json(&body);
let started = std::time::Instant::now();
// 取消要能唤醒正在等待网络数据的请求,不能只在 chunk 之间检查。
let cancellation = async {
while !should_cancel() {
tokio::time::sleep(Duration::from_millis(25)).await;
}
};
tokio::pin!(cancellation);
let mut response = tokio::select! {
_ = &mut cancellation => return Err(LLMError::Network("cancelled".into())),
result = tokio::time::timeout(timeouts.first_token, send_with_transient_retry(request)) => {
result.map_err(|_| LLMError::Timeout)??
}
};
let status = response.status();
if !status.is_success() {
let body_text = response.text().await.map_err(llm_error_from_reqwest)?;
let preview_end = BODY_PREVIEW_LIMIT.min(body_text.len());
let preview = safe_str_slice(&body_text, preview_end);
log::error!("[llm] streaming HTTP {} body={}", status.as_u16(), preview);
let body_text = tokio::select! {
_ = &mut cancellation => return Err(LLMError::Network("cancelled".into())),
result = tokio::time::timeout(timeouts.first_token.saturating_sub(started.elapsed()), response.text()) => {
result.map_err(|_| LLMError::Timeout)?.map_err(llm_error_from_reqwest)?
}
};
return Err(LLMError::InvalidResponse {
status: status.as_u16(),
body: preview.to_string(),
body: safe_str_slice(&body_text, BODY_PREVIEW_LIMIT.min(body_text.len()))
.to_string(),
});
}
let mut response = response;
let mut buffer = String::new();
let mut utf8_pending: Vec<u8> = Vec::new();
let mut events = TextEventStream::new(self.config.protocol.format);
let mut full_text = String::new();
let mut delta_count: u64 = 0;
let mut cancelled = false;
let stream_started = std::time::Instant::now();
let mut first_content_at: Option<Duration> = None;
loop {
while !events.done {
if should_cancel() {
log::info!(
"[llm] polish stream cancelled by caller after {} deltas ({} chars); breaking SSE loop",
delta_count,
full_text.chars().count()
);
cancelled = true;
break;
}
// 首字之前用「还剩多少首字预算」,首字之后用「两个 chunk 之间能空多久」。
// 注意首字预算是从请求发出起算的**总量**,不随 chunk 到达而重置——推理模型
// 思考期的 reasoning_content 是一串正常 chunk,若让它续命,用户干等就没有上限。
let budget = match first_content_at {
None => timeouts
.first_token
.saturating_sub(stream_started.elapsed()),
Some(_) => timeouts.idle,
let budget = if full_text.is_empty() {
timeouts.first_token.saturating_sub(started.elapsed())
} else {
timeouts.idle
};
let chunk_opt = match tokio::time::timeout(budget, response.chunk()).await {
Ok(result) => result.map_err(llm_error_from_reqwest)?,
Err(_) => {
// 已经交给 on_delta 的字此刻就在用户屏幕上;上层 dictation 的 Failed
// 分支拿 typed_text 当 final_text,屏幕 / history / 剪贴板保持一致。
match first_content_at {
None => log::error!(
"[llm] polish stream timed out waiting for first content delta (budget {:?}); \
模型可能仍在思考——加长首字预算或换非推理模型",
timeouts.first_token
),
Some(first) => log::error!(
"[llm] polish stream stalled {:?} after {} chars (first delta at {:?}); \
已落屏的字保留",
timeouts.idle,
full_text.chars().count(),
first
),
}
return Err(LLMError::Timeout);
let chunk = tokio::select! {
_ = &mut cancellation => { cancelled = true; break; }
result = tokio::time::timeout(budget, response.chunk()) => {
result.map_err(|_| LLMError::Timeout)?.map_err(llm_error_from_reqwest)?
}
};
let Some(chunk) = chunk_opt else { break };
append_utf8_sse_chunk(&mut buffer, &mut utf8_pending, &chunk)?;
while let Some(idx) = buffer.find("\n\n") {
let event = buffer[..idx].to_string();
buffer.drain(..idx + 2);
for line in event.lines() {
let Some(payload) = line
.strip_prefix("data: ")
.or_else(|| line.strip_prefix("data:"))
else {
continue;
};
let payload = payload.trim();
if payload.is_empty() || payload == "[DONE]" {
continue;
}
let v: Value = match serde_json::from_str(payload) {
Ok(v) => v,
Err(e) => {
log::warn!(
"[llm] polish SSE parse skip: {e}; payload preview: {}",
safe_str_slice(payload, 80)
);
continue;
}
};
if let Some(delta) = v["choices"][0]["delta"]["content"].as_str() {
if !delta.is_empty() {
if first_content_at.is_none() {
let elapsed = stream_started.elapsed();
first_content_at = Some(elapsed);
// 首字延迟是判断「模型思考太久」还是「网络卡住」的关键读数。
// 之前日志里没有它,7 分钟录音那次只能靠外部实测才量出 43s。
log::info!(
"[llm] polish stream first content delta after {:.2}s (budget {:?})",
elapsed.as_secs_f64(),
timeouts.first_token
);
}
full_text.push_str(delta);
delta_count += 1;
on_delta(delta);
}
}
let Some(chunk) = chunk else {
break;
};
events.push(&chunk)?;
loop {
if should_cancel() {
cancelled = true;
break;
}
let Some(event) = events.next()? else {
break;
};
if let StreamEvent::Text(delta) = event {
if full_text.is_empty() {
log::info!(
"[llm] first content delta after {:.2}s",
started.elapsed().as_secs_f64()
);
}
full_text.push_str(&delta);
on_delta(&delta);
}
}
if cancelled {
break;
}
}
if !cancelled {
finish_utf8_sse_chunks(&mut buffer, &mut utf8_pending)?;
events.finish()?;
}
log::info!(
"[llm] polish stream done; total deltas={} chars={}",
delta_count,
"[llm] stream done; cancelled={} chars={}",
cancelled,
full_text.chars().count()
);
if full_text.is_empty() {
return Err(LLMError::InvalidResponse {
status: 200,
body: "empty polish stream".to_string(),
body: "empty polish stream".into(),
});
}
Ok(full_text)
}
fn authorize(&self, mut request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
for (name, value) in self.config.protocol.format.headers(&self.config.api_key) {
request = request.header(name, value);
}
for (name, value) in &self.config.extra_headers {
request = request.header(name, value);
}
request
}
}
#[derive(Clone, Debug)]
@@ -1941,7 +1828,7 @@ mod tests {
"https://user:pass@example.com/v1/chat/completions?token=query-secret#client-fragment"
);
}
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Mutex as StdMutex;
use std::thread;
@@ -2236,6 +2123,166 @@ mod tests {
request
}
#[tokio::test]
async fn all_text_entrypoints_use_the_selected_protocol_over_http() {
for format in LlmRequestFormat::ALL {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let server = thread::spawn(move || {
for index in 0..5 {
let (mut stream, _) = listener.accept().unwrap();
let request = read_http_request(&mut stream);
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();
let path = match format {
LlmRequestFormat::ChatCompletions => "chat/completions",
LlmRequestFormat::Responses => "responses",
LlmRequestFormat::Messages => "messages",
};
assert!(headers.starts_with(&format!("post /gateway/v1/{path}?tenant=1 ")));
if format == LlmRequestFormat::Messages {
assert!(headers.contains("x-api-key: fixture-key"));
assert!(headers.contains("anthropic-version: 2023-06-01"));
assert!(!headers.contains("authorization:"));
assert!(body["system"].as_str().is_some_and(|text| !text.is_empty()));
} else {
assert!(headers.contains("authorization: bearer fixture-key"));
}
assert!(!headers.contains("chatgpt-account-id"));
let messages = if format == LlmRequestFormat::Responses {
&body["input"]
} else {
&body["messages"]
};
assert!(messages
.as_array()
.is_some_and(|messages| !messages.is_empty()));
if index == 1 {
assert!(messages
.as_array()
.unwrap()
.iter()
.any(|m| m["role"] == "assistant" && m["content"] == "prior answer"));
}
if index < 3 {
assert_eq!(body["stream"], false);
let response = match format {
LlmRequestFormat::ChatCompletions => json!({"choices":[{"message":{"content":"你好"}}]}),
LlmRequestFormat::Responses => json!({"status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"你好"}]}]}),
LlmRequestFormat::Messages => json!({"stop_reason":"end_turn","content":[{"type":"text","text":"你好"}]}),
}.to_string();
write!(stream, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", response.len()).unwrap();
} else {
assert_eq!(body["stream"], true);
let response = match format {
LlmRequestFormat::ChatCompletions => "data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\r\n\r\ndata: [DONE]\r\n\r\n",
LlmRequestFormat::Responses => "data: {\"type\":\"response.output_text.delta\",\"delta\":\"你好\"}\r\n\r\ndata: {\"type\":\"response.completed\"}\r\n\r\n",
LlmRequestFormat::Messages => "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"你好\"}}\r\n\r\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\r\n\r\ndata: {\"type\":\"message_stop\"}\r\n\r\n",
};
let split = response.find('好').unwrap() + 1;
write_chunked_sse_response(
&mut stream,
&[&response.as_bytes()[..split], &response.as_bytes()[split..]],
);
}
}
});
let config = OpenAICompatibleConfig::new(
"custom",
"test",
format!("http://{address}/gateway/v1/chat/completions?tenant=1"),
"fixture-key",
"test",
)
.with_protocol(LlmProtocolConfig {
format,
..Default::default()
});
let provider = OpenAICompatibleLLMProvider::new(config);
for history in [vec![], vec![("prior input".into(), "prior answer".into())]] {
assert_eq!(
provider
.polish(
"input",
PolishMode::Light,
&[],
"",
&[],
ChineseScriptPreference::Auto,
OutputLanguagePreference::Auto,
None,
None,
&history
)
.await
.unwrap(),
"你好"
);
}
assert_eq!(
provider
.translate_to(
"hello",
"Chinese",
&[],
ChineseScriptPreference::Auto,
OutputLanguagePreference::Auto,
None
)
.await
.unwrap(),
"你好"
);
let output = std::sync::Mutex::new(String::new());
let delta = |text: &str| output.lock().unwrap().push_str(text);
let history = vec![QaChatMessage {
role: "user".into(),
content: "hello".into(),
selection_text: None,
}];
assert_eq!(
provider
.answer_chat_streaming(
&history,
&[],
ChineseScriptPreference::Auto,
OutputLanguagePreference::Auto,
None,
delta,
|| false
)
.await
.unwrap(),
"你好"
);
assert_eq!(*output.lock().unwrap(), "你好");
output.lock().unwrap().clear();
assert_eq!(
provider
.polish_streaming(
"input",
PolishMode::Light,
&[],
"",
&[],
ChineseScriptPreference::Auto,
OutputLanguagePreference::Auto,
None,
None,
&[],
delta,
|| false
)
.await
.unwrap(),
"你好"
);
assert_eq!(*output.lock().unwrap(), "你好");
server.join().unwrap();
}
}
fn write_chunked_sse_response(stream: &mut std::net::TcpStream, chunks: &[&[u8]]) {
stream
.write_all(
@@ -2250,6 +2297,86 @@ mod tests {
stream.write_all(b"0\r\n\r\n").unwrap();
}
#[tokio::test]
async fn protocol_stream_errors_and_cancellation_keep_already_emitted_text() {
let cancelled_provider = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new(
"custom",
"test",
"invalid endpoint",
"",
"test",
));
let error = cancelled_provider
.chat_completion_messages_streaming(
Vec::new(),
StreamingTimeouts::for_input(0),
|_| panic!("cancelled request emitted text"),
|| true,
)
.await
.unwrap_err();
assert!(matches!(error, LLMError::Network(ref message) if message == "cancelled"));
for (format, delta, terminal_error) in [
(
LlmRequestFormat::Responses,
r#"{"type":"response.output_text.delta","delta":"partial"}"#,
r#"{"type":"response.failed"}"#,
),
(
LlmRequestFormat::Messages,
r#"{"type":"content_block_delta","delta":{"type":"text_delta","text":"partial"}}"#,
r#"{"type":"message_delta","delta":{"stop_reason":"max_tokens"}}"#,
),
] {
for cancel in [false, true] {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
read_http_request(&mut stream);
let fixture = format!("data: {delta}\n\ndata: {terminal_error}\n\n");
write_chunked_sse_response(&mut stream, &[fixture.as_bytes()]);
});
let provider = OpenAICompatibleLLMProvider::new(
OpenAICompatibleConfig::new(
"custom",
"test",
format!("http://{address}"),
"",
"test",
)
.with_protocol(LlmProtocolConfig {
format,
..Default::default()
}),
);
let cancelled = AtomicBool::new(false);
let output = std::sync::Mutex::new(String::new());
let result = provider
.chat_completion_messages_streaming(
vec![json!({"role":"user","content":"hi"})],
StreamingTimeouts::for_input(2),
|text| {
output.lock().unwrap().push_str(text);
cancelled.store(cancel, Ordering::SeqCst);
},
|| cancelled.load(Ordering::SeqCst),
)
.await;
assert_eq!(*output.lock().unwrap(), "partial");
if cancel {
assert_eq!(result.unwrap(), "partial");
} else {
assert!(result
.unwrap_err()
.to_string()
.contains("llmResponseIncomplete"));
}
server.join().unwrap();
}
}
}
/// 带间隔的 SSE 发送:每个 chunk 前先睡一段,用来模拟「思考很久才出字」和
/// 「出字中途卡死」两种真实流。
fn write_chunked_sse_response_with_delays(
@@ -62,7 +62,9 @@ const LLM_PROVIDER_TYPES: &[(&str, &str)] = &[
("codingPlanX", "codingPlanX"),
("minimax", "minimax"),
("stepfun", "stepfun"),
("custom", "custom"),
("custom", "customChatCompletions"),
("custom_responses", "customResponses"),
("custom_messages", "customMessages"),
];
const OMNI_PROVIDER_TYPES: &[(&str, &str)] = &[
@@ -133,6 +135,8 @@ pub struct ProviderDescriptor {
pub auth_requirement: AuthRequirement,
pub validation_probe: ValidationProbe,
pub static_models: Vec<String>,
pub default_request_format: Option<crate::llm_protocol::LlmRequestFormat>,
pub supported_request_formats: Vec<crate::llm_protocol::LlmRequestFormat>,
}
pub fn provider_descriptors(kind: ProviderKind) -> Vec<ProviderDescriptor> {
@@ -210,6 +214,16 @@ fn provider_descriptor_with_label(
),
};
Some(ProviderDescriptor {
default_request_format: (kind == ProviderKind::Llm
&& crate::llm_protocol::LlmRequestFormat::selectable(&id))
.then(|| crate::llm_protocol::LlmRequestFormat::default_for(&id)),
supported_request_formats: if kind == ProviderKind::Llm
&& crate::llm_protocol::LlmRequestFormat::selectable(&id)
{
crate::llm_protocol::LlmRequestFormat::ALL.to_vec()
} else {
Vec::new()
},
kind,
provider_type,
label_key: label_key.to_string(),
@@ -422,6 +436,8 @@ pub fn equivalent_endpoint(left: &str, right: &str) -> bool {
.trim()
.trim_end_matches('/')
.trim_end_matches("/chat/completions")
.trim_end_matches("/responses")
.trim_end_matches("/messages")
.trim_end_matches('/')
}
normalize(left).eq_ignore_ascii_case(normalize(right))
@@ -23,6 +23,7 @@ use crate::domains::{
ProviderApi, ProviderCheckResult, ProviderKind, ProviderModelsResult, ProviderRequest,
};
use crate::errors::{BackendError, BackendErrorCode};
use crate::llm_protocol::{LlmProtocolConfig, LlmRequestFormat};
use crate::ports::{TextPolisher, TextStreamChunk, TextStreamSink, TranscriptionEngine};
use crate::provider_rules::{
api_key_required, default_asr_endpoint, default_asr_model, default_llm_endpoint,
@@ -158,6 +159,13 @@ impl ProviderService {
};
Ok(ResolvedProvider {
thinking_enabled: request.thinking_enabled,
protocol: if request.kind == ProviderKind::Llm {
LlmProtocolConfig::load(self.credentials.as_ref(), &provider_id, &provider_type)
.await?
} else {
LlmProtocolConfig::default()
},
kind: request.kind,
provider_id,
provider_type,
@@ -350,6 +358,8 @@ impl ProviderApi for ProviderService {
#[derive(Debug, Clone)]
struct ResolvedProvider {
thinking_enabled: bool,
protocol: LlmProtocolConfig,
kind: ProviderKind,
provider_id: String,
provider_type: String,
@@ -362,6 +372,7 @@ struct ResolvedProvider {
impl ResolvedProvider {
fn context(&self) -> DictationContext {
let mut context = DictationContext::default();
context.polish.llm_thinking_enabled = self.thinking_enabled;
let invocation = ProviderInvocation {
provider_id: self.provider_id.clone(),
provider_type: self.provider_type.clone(),
@@ -448,7 +459,9 @@ fn validate_configuration(resolved: &ResolvedProvider) -> Result<(), BackendErro
validate_provider_endpoint(endpoint, resolved.kind == ProviderKind::Asr)?;
}
if let Some(headers) = resolved.extra_headers.as_deref() {
parse_extra_headers(headers)?;
resolved
.protocol
.validate_headers(&parse_extra_headers(headers)?)?;
}
}
Ok(())
@@ -585,6 +598,19 @@ fn sanitize_validation_error(error: BackendError) -> BackendError {
return error;
}
let message = error.message.as_str();
for code in [
"llmResponseIncomplete",
"llmStreamError",
"llmRequestFormatInvalid",
"llmThinkingModeInvalid",
"llmTokenLimitInvalid",
"llmThinkingBudgetInvalid",
"llmProtocolHeaderConflict",
] {
if message == code || message == format!("parse error: {code}") {
return provider_error(code);
}
}
if message.ends_with("is not configured") {
return error;
}
@@ -620,8 +646,7 @@ async fn fetch_models(
.or_else(|| default_omni_endpoint(&resolved.provider_type))
.ok_or_else(|| provider_error("provider endpoint is not configured"))?;
let url = models_url(endpoint)?;
let is_gemini =
crate::net::sanitized_url_for_logs(&url).contains("generativelanguage.googleapis.com");
let is_gemini = resolved.provider_type == "gemini";
let mut request_headers = Vec::new();
if let Some(api_key) = resolved
.api_key
@@ -631,9 +656,16 @@ async fn fetch_models(
if is_gemini {
request_headers.push(("x-goog-api-key".to_string(), api_key.to_string()));
} else {
request_headers.push(("Authorization".to_string(), format!("Bearer {api_key}")));
request_headers.extend(resolved.protocol.format.headers(api_key));
}
}
if resolved.protocol.format == LlmRequestFormat::Messages
&& !request_headers
.iter()
.any(|(name, _)| name == "anthropic-version")
{
request_headers.extend(resolved.protocol.format.headers(""));
}
if let Some(extra_headers) = resolved.extra_headers.as_deref() {
for (name, value) in parse_extra_headers(extra_headers)? {
request_headers.push((name, value));
@@ -710,18 +742,8 @@ fn parse_model_list(body: &[u8], is_gemini: bool) -> Result<Vec<String>, Backend
}
fn models_url(endpoint: &str) -> Result<String, BackendError> {
let mut url = url::Url::parse(endpoint.trim())
.map_err(|_| invalid_request("provider endpoint is invalid"))?;
let path = url.path().trim_end_matches('/');
let next_path = if path.ends_with("/models") {
path.to_string()
} else if let Some(prefix) = path.strip_suffix("/chat/completions") {
format!("{prefix}/models")
} else {
format!("{path}/models")
};
url.set_path(&next_path);
Ok(url.to_string())
crate::llm_protocol::endpoint_url(endpoint, "/models")
.map_err(|_| invalid_request("provider endpoint is invalid"))
}
fn map_transport_error(error: ProviderTransportError) -> BackendError {
@@ -866,6 +888,45 @@ mod tests {
id
}
#[tokio::test]
async fn validation_and_model_lists_use_channel_protocol_and_thinking() {
use crate::llm_protocol::*;
for (format, preset, sse, path) in [
("responses", "custom_responses", "data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\ndata: {\"type\":\"response.completed\"}\n\n", "/v1/responses"),
("messages", "custom_messages", "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n", "/v1/messages"),
] {
for enabled in [false, true] {
let (endpoint, request) = spawn_http_response("200 OK", "text/event-stream", sse);
let credentials = Arc::new(InMemoryCredentialStore::default());
let channel = create_channel_with_values(&credentials, ChannelKind::Llm, preset, &[
(LLM_ENDPOINT_ACCOUNT, &endpoint), (LLM_MODEL_ACCOUNT, "test"), (LLM_API_KEY_ACCOUNT, "fixture-key"),
(REQUEST_FORMAT_ACCOUNT, format),
]).await;
let service = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner));
service.validate(ProviderRequest { kind: ProviderKind::Llm, channel_id: Some(channel), thinking_enabled: enabled }).await.unwrap();
let request = request.recv_timeout(Duration::from_secs(2)).unwrap();
let request = String::from_utf8(request).unwrap();
assert!(request.starts_with(&format!("POST {path} ")));
let body: serde_json::Value = serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap();
if format == "responses" { assert_eq!(body["reasoning"]["effort"], if enabled { "medium" } else { "low" }); }
else if enabled { assert_eq!(body["thinking"]["type"], "adaptive"); }
else { assert!(body.get("thinking").is_none()); }
}
let (endpoint, request) = spawn_http_response("200 OK", "application/json", r#"{"data":[{"id":"model"}]}"#);
let credentials = Arc::new(InMemoryCredentialStore::default());
let channel = create_channel_with_values(&credentials, ChannelKind::Llm, "openai", &[
(LLM_ENDPOINT_ACCOUNT, &format!("{endpoint}/{format}")), (LLM_MODEL_ACCOUNT, "test"),
(LLM_API_KEY_ACCOUNT, "fixture-key"), (REQUEST_FORMAT_ACCOUNT, format),
]).await;
let service = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner));
assert_eq!(service.list_models(ProviderRequest { kind: ProviderKind::Llm, channel_id: Some(channel), thinking_enabled: false }).await.unwrap().models, vec!["model"]);
let request = String::from_utf8(request.recv_timeout(Duration::from_secs(2)).unwrap()).unwrap().to_ascii_lowercase();
assert!(request.starts_with("get /v1/models "));
if format == "messages" { assert!(request.contains("x-api-key: fixture-key")); }
else { assert!(request.contains("authorization: bearer fixture-key")); }
}
}
#[tokio::test]
async fn openai_compatible_asr_without_key_reaches_the_configured_endpoint() {
let (endpoint, request) =
@@ -885,6 +946,7 @@ mod tests {
service
.validate(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Asr,
channel_id: Some(channel),
})
@@ -918,6 +980,7 @@ mod tests {
service
.validate(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Llm,
channel_id: Some(channel),
})
@@ -951,6 +1014,7 @@ mod tests {
let error = service
.validate(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Llm,
channel_id: Some(channel),
})
@@ -981,6 +1045,7 @@ mod tests {
let result = service
.list_models(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Asr,
channel_id: Some(channel),
})
@@ -1024,6 +1089,7 @@ mod tests {
service
.validate(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Asr,
channel_id: Some(channel),
})
@@ -1092,6 +1158,7 @@ mod tests {
let (service, credentials) = service_with_channel().await;
let error = service
.list_models(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Llm,
channel_id: Some("missing".to_string()),
})
@@ -1108,6 +1175,7 @@ mod tests {
let service = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner));
let error = service
.validate(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Omni,
channel_id: Some("channel".to_string()),
})
@@ -1166,6 +1234,7 @@ mod tests {
let first_resolved = service
.resolve(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Llm,
channel_id: Some(first_id.clone()),
})
@@ -1173,6 +1242,7 @@ mod tests {
.unwrap();
let second_resolved = service
.resolve(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Llm,
channel_id: Some(second_id.clone()),
})
@@ -1180,6 +1250,7 @@ mod tests {
.unwrap();
let active_resolved = service
.resolve(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Llm,
channel_id: None,
})
@@ -1286,6 +1357,7 @@ mod tests {
let result = service
.list_models(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Llm,
channel_id: Some(channel),
})
@@ -1327,6 +1399,7 @@ mod tests {
transport.push_response(status, br#"{"data":[]}"#);
let error = service
.list_models(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Llm,
channel_id: Some(channel.clone()),
})
@@ -1340,6 +1413,7 @@ mod tests {
transport.push_response(200, br#"not-json secret-body"#);
let error = service
.list_models(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Llm,
channel_id: Some(channel.clone()),
})
@@ -1351,6 +1425,7 @@ mod tests {
transport.push_response(200, vec![b'x'; MODEL_LIST_MAX_BYTES + 1]);
let error = service
.list_models(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Llm,
channel_id: Some(channel.clone()),
})
@@ -1389,6 +1464,7 @@ mod tests {
transport.push_error(transport_error);
let error = service
.list_models(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Llm,
channel_id: Some(channel.clone()),
})
@@ -1409,6 +1485,7 @@ mod tests {
let error = service
.list_models_with_cancellation(
ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Llm,
channel_id: Some(channel),
},
@@ -1441,6 +1518,7 @@ mod tests {
let error = service
.list_models_with_cancellation(
ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Asr,
channel_id: Some(channel),
},
@@ -1490,6 +1568,8 @@ mod tests {
];
for (provider_type, expected_models) in expected {
let resolved = ResolvedProvider {
thinking_enabled: false,
protocol: LlmProtocolConfig::default(),
kind: ProviderKind::Asr,
provider_id: provider_type.to_string(),
provider_type: provider_type.to_string(),
@@ -1108,6 +1108,7 @@ mod tests {
.services()
.provider
.list_models(ProviderRequest {
thinking_enabled: false,
kind: ProviderKind::Llm,
channel_id: None,
})
+2
View File
@@ -349,6 +349,7 @@ mod linux_app {
.services()
.provider
.list_models(openless_core::ProviderRequest {
thinking_enabled: backend.get_preferences().llm_thinking_enabled,
kind: provider_kind(kind),
channel_id: Some(channel_id.clone()),
})
@@ -1981,6 +1982,7 @@ mod linux_app {
.services()
.provider
.validate(openless_core::ProviderRequest {
thinking_enabled: backend.get_preferences().llm_thinking_enabled,
kind: provider_kind(kind),
channel_id: Some(channel_id.clone()),
})
@@ -243,6 +243,11 @@ fn read_vault_credential(
key: &openless_core::CredentialKey,
) -> Result<Option<String>, openless_core::BackendError> {
let result = match (key.namespace, key.account.as_str()) {
(openless_core::CredentialNamespace::Llm, account)
if openless_core::llm_protocol::CONFIG_ACCOUNTS.contains(&account) =>
{
CredentialsVault::get_llm_protocol_option(key.provider_id.as_deref(), account)
}
(openless_core::CredentialNamespace::Llm, LLM_EXTRA_HEADERS_ACCOUNT) => {
match key.provider_id.as_deref() {
Some(provider) => serde_json::to_string(
@@ -309,6 +314,11 @@ fn write_vault_credential(
value: &str,
) -> Result<(), openless_core::BackendError> {
let result = match (key.namespace, key.account.as_str()) {
(openless_core::CredentialNamespace::Llm, account)
if openless_core::llm_protocol::CONFIG_ACCOUNTS.contains(&account) =>
{
CredentialsVault::set_llm_protocol_option(key.provider_id.as_deref(), account, value)
}
(openless_core::CredentialNamespace::Llm, LLM_EXTRA_HEADERS_ACCOUNT) => {
match key.provider_id.as_deref() {
Some(provider) => {
@@ -738,6 +748,9 @@ fn credential_key(
provider: Option<String>,
) -> Result<openless_core::CredentialKey, String> {
let namespace = match account {
account if openless_core::llm_protocol::CONFIG_ACCOUNTS.contains(&account) => {
openless_core::CredentialNamespace::Llm
}
LLM_EXTRA_HEADERS_ACCOUNT | LLM_TEMPERATURE_ACCOUNT => {
openless_core::CredentialNamespace::Llm
}
@@ -853,6 +866,11 @@ mod tests {
#[test]
fn core_llm_accounts_are_supported_by_the_tauri_vault_adapter() {
for account in openless_core::llm_protocol::CONFIG_ACCOUNTS {
let key = credential_key(account, Some("channel-b".into())).unwrap();
assert_eq!(key.namespace, openless_core::CredentialNamespace::Llm);
assert_eq!(key.provider_id.as_deref(), Some("channel-b"));
}
for account in [
openless_core::credentials::LLM_API_KEY_ACCOUNT,
openless_core::credentials::LLM_MODEL_ACCOUNT,
@@ -29,7 +29,11 @@ pub async fn validate_provider_credentials(
let kind = parse_provider_kind(&kind)?;
core.services()
.provider
.validate(openless_core::ProviderRequest { kind, channel_id })
.validate(openless_core::ProviderRequest {
kind,
channel_id,
thinking_enabled: core.get_preferences().llm_thinking_enabled,
})
.await
.map_err(|error| error.message)
}
@@ -43,7 +47,11 @@ pub async fn list_provider_models(
let kind = parse_provider_kind(&kind)?;
core.services()
.provider
.list_models(openless_core::ProviderRequest { kind, channel_id })
.list_models(openless_core::ProviderRequest {
kind,
channel_id,
thinking_enabled: core.get_preferences().llm_thinking_enabled,
})
.await
.map_err(|error| error.message)
}
@@ -194,6 +194,21 @@ pub(crate) fn persist_strict_settings(
.map_err(|error| error.to_string())
}
async fn invalidate_llm_tests_if_thinking_changed(
coord: &Coordinator,
previous: &UserPreferences,
next: &UserPreferences,
) -> Result<(), String> {
if previous.llm_thinking_enabled != next.llm_thinking_enabled {
coord
.backend()
.invalidate_channel_tests(openless_core::ChannelKind::Llm)
.await
.map_err(|error| error.to_string())?;
}
Ok(())
}
#[cfg(not(mobile))]
#[tauri::command]
pub async fn set_settings(
@@ -210,6 +225,7 @@ pub async fn set_settings(
.map_err(|e| e.to_string())?;
sync_style_pack_preferences(&mut prefs, &packs);
prefs.android_overlay_trigger = prefs.android_overlay_trigger.normalized();
invalidate_llm_tests_if_thinking_changed(&coord, &remote_prev, &prefs).await?;
// 广播给所有 webview。issue #205:QaPanel 跑在独立 webview,
// 没有 HotkeySettingsContext,必须靠事件感知录音键变化,否则面板可见时
// 用户改键会让浮窗里的 "{recordHotkey}" 文案一直停留在旧值。
@@ -264,7 +280,10 @@ pub async fn set_settings(
#[cfg(mobile)]
#[tauri::command]
pub fn set_settings(coord: CoordinatorState<'_>, mut prefs: UserPreferences) -> Result<(), String> {
pub async fn set_settings(
coord: CoordinatorState<'_>,
mut prefs: UserPreferences,
) -> Result<(), String> {
let previous = coord.backend().get_preferences();
let packs = coord
.backend()
@@ -272,6 +291,7 @@ pub fn set_settings(coord: CoordinatorState<'_>, mut prefs: UserPreferences) ->
.map_err(|e| e.to_string())?;
sync_style_pack_preferences(&mut prefs, &packs);
prefs.android_overlay_trigger = prefs.android_overlay_trigger.normalized();
invalidate_llm_tests_if_thinking_changed(&coord, &previous, &prefs).await?;
persist_settings_preserving_update_channel(&*coord, prefs)?;
let prefs = coord.backend().get_preferences();
// 保存即同步胶囊样式原子(Android 通知胶囊 payload 同源,见 emit_capsule)。
@@ -398,9 +398,28 @@ struct CredsLlmEntry {
temperature: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
extraHeaders: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
requestFormat: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
messagesThinking: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
maxTokens: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
thinkingBudget: Option<String>,
}
impl CredsLlmEntry {
fn protocol_option(&mut self, account: &str) -> Result<&mut Option<String>> {
use openless_core::llm_protocol::*;
match account {
REQUEST_FORMAT_ACCOUNT => Ok(&mut self.requestFormat),
MESSAGES_THINKING_ACCOUNT => Ok(&mut self.messagesThinking),
MAX_TOKENS_ACCOUNT => Ok(&mut self.maxTokens),
THINKING_BUDGET_ACCOUNT => Ok(&mut self.thinkingBudget),
_ => anyhow::bail!("unsupported LLM protocol option"),
}
}
fn is_empty(&self) -> bool {
// 同 CredsAsrEntry::is_empty —— 渠道卡片只能由用户显式删除。
if self.channel.providerType.is_some() {
@@ -416,6 +435,10 @@ impl CredsLlmEntry {
&& self.baseURL.as_deref().unwrap_or("").is_empty()
&& self.model.as_deref().unwrap_or("").is_empty()
&& self.temperature.is_none()
&& self.requestFormat.is_none()
&& self.messagesThinking.is_none()
&& self.maxTokens.is_none()
&& self.thinkingBudget.is_none()
&& self
.extraHeaders
.as_ref()
@@ -2360,6 +2383,29 @@ impl CredentialsVault {
save_credentials(&root)
}
pub fn get_llm_protocol_option(id: Option<&str>, account: &str) -> Result<Option<String>> {
let _guard = credentials_lock().lock();
let mut root = load_credentials_for_update()?;
let id = id.unwrap_or(&root.active.llm).to_string();
match root.providers.llm.get_mut(&id) {
Some(entry) => Ok(entry.protocol_option(account)?.clone()),
None => Ok(None),
}
}
pub fn set_llm_protocol_option(id: Option<&str>, account: &str, value: &str) -> Result<()> {
let _guard = credentials_lock().lock();
let mut config = openless_core::llm_protocol::LlmProtocolConfig::default();
config.apply(account, value)?;
let mut root = load_credentials_for_update()?;
let id = id.unwrap_or(&root.active.llm).to_string();
let entry = root.providers.llm.entry(id).or_default();
*entry.protocol_option(account)? =
(!value.trim().is_empty()).then(|| value.trim().to_string());
entry.channel.lastTest = None;
save_credentials(&root)
}
/// 写入指定 LLM 渠道的采样温度,不改变 active 渠道。
pub fn set_llm_temperature_for_provider(id: &str, value: &str) -> Result<()> {
let _guard = credentials_lock().lock();
@@ -2442,6 +2488,48 @@ mod tests {
.all(|chunk| chunk.encode_utf16().count() <= KEYRING_CHUNK_MAX_UTF16_UNITS));
}
#[test]
fn llm_protocol_options_survive_vault_reload_and_core_projection() {
use openless_core::llm_protocol::*;
let mut root = CredsRoot::default();
let entry = root.providers.llm.entry("channel-b".into()).or_default();
let values = ["messages", "budget", "8192", "2048"];
for (account, value) in CONFIG_ACCOUNTS.into_iter().zip(values) {
*entry.protocol_option(account).unwrap() = Some(value.into());
}
assert!(!entry.has_no_content());
let serialized = serde_json::to_string(&root).unwrap();
let mut restored: CredsRoot = serde_json::from_str(&serialized).unwrap();
for (account, value) in CONFIG_ACCOUNTS.into_iter().zip(values) {
assert_eq!(
restored
.providers
.llm
.get_mut("channel-b")
.unwrap()
.protocol_option(account)
.unwrap()
.as_deref(),
Some(value)
);
}
let decoded =
openless_core::credentials_legacy::decode_legacy_credentials(&serialized).unwrap();
for (account, value) in CONFIG_ACCOUNTS.into_iter().zip(values) {
assert!(decoded
.secrets
.iter()
.any(
|(key, secret)| key.provider_id.as_deref() == Some("channel-b")
&& key.account == account
&& secret.expose_secret() == value
));
}
let old: CredsLlmEntry = serde_json::from_str(r#"{"apiKey":"old-key"}"#).unwrap();
assert!(old.requestFormat.is_none());
assert!(!restored.providers.llm.contains_key("channel-a"));
}
#[test]
fn omni_accounts_route_to_omni_namespace_only() {
// 多模态(Omni)凭据必须与 LLM/ASR 命名空间完全隔离(issue #902):
+20 -1
View File
@@ -1010,6 +1010,9 @@ export const en: typeof zhCN = {
codingPlanX: 'CodingPlanX',
minimax: 'MiniMax (M3)',
stepfun: 'StepFun',
customChatCompletions: "Custom · Chat Completions",
customResponses: "Custom · Responses",
customMessages: "Custom · Messages",
custom: 'Custom',
asrVolcengine: 'Volcengine bigasr',
asrBailian: 'Alibaba Bailian realtime ASR',
@@ -1084,7 +1087,23 @@ export const en: typeof zhCN = {
thinkingModeLabel: 'Thinking',
thinkingModeOn: 'On',
thinkingModeOff: 'Off',
thinkingModeHint: 'Off disables or minimizes thinking with provider-level official parameters. On enables thinking by channel defaults. No prompt injection or per-model adapters.',
requestFormatLabel: "Request format",
messagesThinkingLabel: "Thinking mode",
thinkingAdaptive: "Adaptive",
thinkingBudget: "Fixed budget",
maxTokensLabel: "Maximum output tokens",
thinkingBudgetLabel: "Thinking budget tokens",
responsesThinkingHint: "Some models can only reduce thinking, not turn it off. Reasoning requests omit temperature.",
messagesThinkingHint: "Older models or compatible gateways may need a fixed budget below the output limit. Thinking requests omit temperature.",
llmRequestFormatInvalid: "Invalid request format. Select a supported format.",
llmThinkingModeInvalid: "Invalid thinking mode. Select a supported mode.",
llmTokenLimitInvalid: "Token limits must be positive integers.",
llmThinkingBudgetInvalid: "Thinking budget must be at least 1024 and below the output limit in fixed-budget mode.",
llmResponseIncomplete: "The response was incomplete or reached its output limit. Already emitted text is retained.",
llmProtocolHeaderConflict: "Messages sets authentication and version headers automatically. Remove x-api-key and anthropic-version from extra headers.",
llmStreamError: "The server returned a stream error. Check the model and request parameters.",
saveProtocol: "Save protocol settings",
thinkingModeHint: "Enable, disable, or reduce thinking using parameters supported by the selected request format and model. No control instructions are injected into prompts.",
bailianVocabularyIdLabel: 'Hotword Vocabulary ID (optional)',
bailianVocabularyIdNote: 'If you have created a DashScope hotword vocabulary, enter its vocab-... ID. Leave blank to skip hotwords.',
bailianModelRealtimeHint: 'Realtime model · transcribes as you speak.',
+20 -1
View File
@@ -1012,6 +1012,9 @@ export const ja: typeof zhCN = {
codingPlanX: 'CodingPlanX',
minimax: 'MiniMax(M3)',
stepfun: 'StepFun(階躍星辰)',
customChatCompletions: "カスタム · Chat Completions",
customResponses: "カスタム · Responses",
customMessages: "カスタム · Messages",
custom: 'カスタム',
asrVolcengine: 'Volcengine bigasr',
asrBailian: 'Alibaba Bailian リアルタイム ASR',
@@ -1086,7 +1089,23 @@ export const ja: typeof zhCN = {
thinkingModeLabel: '思考',
thinkingModeOn: 'オン',
thinkingModeOff: 'オフ',
thinkingModeHint: 'オフではチャネル単位の公式パラメーターで思考を無効化または最小化します。オンではチャネル既定で思考を有効化します。prompt 注入やモデル別適配は行いません。',
requestFormatLabel: "リクエスト形式",
messagesThinkingLabel: "思考方式",
thinkingAdaptive: "適応型",
thinkingBudget: "固定予算",
maxTokensLabel: "最大出力トークン数",
thinkingBudgetLabel: "思考トークン予算",
responsesThinkingHint: "一部のモデルでは思考を軽減できますが、完全には無効にできません。推論リクエストでは温度を送信しません。",
messagesThinkingHint: "旧モデルや互換ゲートウェイでは固定予算が必要な場合があります。予算は最大出力未満にしてください。思考時は温度を送信しません。",
llmRequestFormatInvalid: "リクエスト形式が無効です。選択し直してください。",
llmThinkingModeInvalid: "思考方式が無効です。選択し直してください。",
llmTokenLimitInvalid: "トークン上限は正の整数にしてください。",
llmThinkingBudgetInvalid: "思考予算は1024以上、固定予算では最大出力未満にしてください。",
llmResponseIncomplete: "応答が未完了か出力上限に達しました。出力済みテキストは保持されます。",
llmProtocolHeaderConflict: "Messages の認証とバージョンヘッダーは自動設定されます。追加ヘッダーから x-api-key と anthropic-version を削除してください。",
llmStreamError: "サーバーがストリームエラーを返しました。モデルとリクエスト設定を確認してください。",
saveProtocol: "プロトコル設定を保存",
thinkingModeHint: "選択したリクエスト形式とモデルが対応するパラメータで思考を有効化、無効化または軽減します。プロンプトに制御指示は追加しません。",
bailianVocabularyIdLabel: 'ホットワード Vocabulary ID(任意)',
bailianVocabularyIdNote: 'DashScope でホットワード辞書を作成済みの場合は vocab-... ID を入力します。空欄なら送信しません。',
bailianModelRealtimeHint: 'リアルタイムモデル · 話しながら文字起こし。',
+20 -1
View File
@@ -1012,6 +1012,9 @@ export const ko: typeof zhCN = {
codingPlanX: 'CodingPlanX',
minimax: 'MiniMax (M3)',
stepfun: 'StepFun',
customChatCompletions: "사용자 지정 · Chat Completions",
customResponses: "사용자 지정 · Responses",
customMessages: "사용자 지정 · Messages",
custom: '사용자 정의',
asrVolcengine: 'Volcengine bigasr',
asrBailian: 'Alibaba Bailian 실시간 ASR',
@@ -1086,7 +1089,23 @@ export const ko: typeof zhCN = {
thinkingModeLabel: '사고',
thinkingModeOn: '켜짐',
thinkingModeOff: '꺼짐',
thinkingModeHint: '꺼짐은 채널 단위 공식 파라미터로 사고를 끄거나 최소화합니다. 켜짐은 채널 기본값으로 사고를 켭니다. prompt 주입이나 모델별 어댑터는 사용하지 않습니다.',
requestFormatLabel: "요청 형식",
messagesThinkingLabel: "사고 방식",
thinkingAdaptive: "적응형",
thinkingBudget: "고정 예산",
maxTokensLabel: "최대 출력 토큰",
thinkingBudgetLabel: "사고 토큰 예산",
responsesThinkingHint: "일부 모델은 사고를 줄일 수만 있으며 완전히 끌 수 없습니다. 추론 요청에는 온도를 보내지 않습니다.",
messagesThinkingHint: "이전 모델이나 호환 게이트웨이는 고정 예산이 필요할 수 있습니다. 예산은 출력 한도보다 작아야 합니다. 사고 요청에는 온도를 보내지 않습니다.",
llmRequestFormatInvalid: "잘못된 요청 형식입니다. 다시 선택하세요.",
llmThinkingModeInvalid: "잘못된 사고 방식입니다. 다시 선택하세요.",
llmTokenLimitInvalid: "토큰 한도는 양의 정수여야 합니다.",
llmThinkingBudgetInvalid: "사고 예산은 1024 이상이며 고정 예산 모드에서는 출력 한도보다 작아야 합니다.",
llmResponseIncomplete: "응답이 완료되지 않았거나 출력 한도에 도달했습니다. 이미 출력된 텍스트는 유지됩니다.",
llmProtocolHeaderConflict: "Messages 인증 및 버전 헤더는 자동 설정됩니다. 추가 헤더에서 x-api-key와 anthropic-version을 제거하세요.",
llmStreamError: "서버가 스트림 오류를 반환했습니다. 모델과 요청 설정을 확인하세요.",
saveProtocol: "프로토콜 설정 저장",
thinkingModeHint: "선택한 요청 형식과 모델이 지원하는 매개변수로 사고를 켜거나 끄거나 줄입니다. 프롬프트에 제어 지시를 추가하지 않습니다.",
bailianVocabularyIdLabel: '핫워드 Vocabulary ID(선택)',
bailianVocabularyIdNote: 'DashScope에서 핫워드 사전을 만들었다면 vocab-... ID를 입력하세요. 비워 두면 핫워드를 전송하지 않습니다.',
bailianModelRealtimeHint: '실시간 모델 · 말하는 동안 바로 전사.',
+20 -1
View File
@@ -1008,6 +1008,9 @@ export const zhCN = {
codingPlanX: 'CodingPlanX',
minimax: 'MiniMax(M3)',
stepfun: 'StepFun(阶跃星辰)',
customChatCompletions: "自定义 · Chat Completions",
customResponses: "自定义 · Responses",
customMessages: "自定义 · Messages",
custom: '自定义',
asrVolcengine: '火山引擎 bigasr',
asrBailian: '阿里云百炼实时 ASR',
@@ -1082,7 +1085,23 @@ export const zhCN = {
thinkingModeLabel: '思考',
thinkingModeOn: '开启',
thinkingModeOff: '关闭',
thinkingModeHint: '关闭时按渠道级官方参数关闭或压低思考;开启时按渠道默认启用思考。不注入 prompt,也不做单模型适配。',
requestFormatLabel: "请求格式",
messagesThinkingLabel: "思考方式",
thinkingAdaptive: "自适应",
thinkingBudget: "固定预算",
maxTokensLabel: "最大输出 tokens",
thinkingBudgetLabel: "思考预算 tokens",
responsesThinkingHint: "部分模型只能降低思考,不能完全关闭。推理请求不发送温度参数。",
messagesThinkingHint: "旧模型或兼容网关可能需要固定预算;思考预算必须小于最大输出。开启思考时不发送温度参数。",
llmRequestFormatInvalid: "请求格式无效,请重新选择。",
llmThinkingModeInvalid: "思考方式无效,请重新选择。",
llmTokenLimitInvalid: "Token 上限必须为正整数。",
llmThinkingBudgetInvalid: "思考预算至少为 1024,且固定预算必须小于最大输出。",
llmResponseIncomplete: "响应未完整结束或达到输出上限;已输出正文会保留。",
llmProtocolHeaderConflict: "Messages 已自动设置鉴权和版本请求头,请移除额外 Headers 中的 x-api-key 和 anthropic-version。",
llmStreamError: "服务端返回流式错误,请检查模型和请求参数。",
saveProtocol: "保存协议设置",
thinkingModeHint: "按所选请求格式和模型支持的参数启用、关闭或降低思考,不向提示词注入控制指令。",
bailianVocabularyIdLabel: '热词 Vocabulary ID(可选)',
bailianVocabularyIdNote: '如已在百炼创建热词表,可填写 vocab-...;留空则不下发热词。',
bailianModelRealtimeHint: '实时模型 · 边说边出字。',
+20 -1
View File
@@ -1010,6 +1010,9 @@ export const zhTW: typeof zhCN = {
codingPlanX: 'CodingPlanX',
minimax: 'MiniMax(M3)',
stepfun: 'StepFun(階躍星辰)',
customChatCompletions: "自訂 · Chat Completions",
customResponses: "自訂 · Responses",
customMessages: "自訂 · Messages",
custom: '自定義',
asrVolcengine: '火山引擎 bigasr',
asrBailian: '阿里雲百煉即時 ASR',
@@ -1084,7 +1087,23 @@ export const zhTW: typeof zhCN = {
thinkingModeLabel: '思考',
thinkingModeOn: '開啟',
thinkingModeOff: '關閉',
thinkingModeHint: '關閉時按渠道級官方參數關閉或降低思考;開啟時按渠道預設啟用思考。不注入 prompt,也不做單模型適配。',
requestFormatLabel: "請求格式",
messagesThinkingLabel: "思考方式",
thinkingAdaptive: "自適應",
thinkingBudget: "固定預算",
maxTokensLabel: "最大輸出 tokens",
thinkingBudgetLabel: "思考預算 tokens",
responsesThinkingHint: "部分模型只能降低思考,無法完全關閉。推理請求不傳送溫度參數。",
messagesThinkingHint: "舊模型或相容閘道可能需要固定預算;思考預算必須小於最大輸出。開啟思考時不傳送溫度參數。",
llmRequestFormatInvalid: "請求格式無效,請重新選擇。",
llmThinkingModeInvalid: "思考方式無效,請重新選擇。",
llmTokenLimitInvalid: "Token 上限必須為正整數。",
llmThinkingBudgetInvalid: "思考預算至少為 1024,且固定預算必須小於最大輸出。",
llmResponseIncomplete: "回應未完整結束或達到輸出上限;已輸出正文會保留。",
llmProtocolHeaderConflict: "Messages 已自動設定驗證與版本標頭,請移除額外 Headers 中的 x-api-key 與 anthropic-version。",
llmStreamError: "伺服器回傳串流錯誤,請檢查模型和請求參數。",
saveProtocol: "儲存協定設定",
thinkingModeHint: "依所選請求格式與模型支援的參數啟用、關閉或降低思考,不在提示詞注入控制指令。",
bailianVocabularyIdLabel: '熱詞 Vocabulary ID(可選)',
bailianVocabularyIdNote: '如已在百煉建立熱詞表,可填寫 vocab-...;留空則不下發熱詞。',
bailianModelRealtimeHint: '即時模型 · 邊說邊出字。',
@@ -1,6 +1,7 @@
import type { CredentialsStatus } from "../types"
import { invokeOrMock } from "./shared"
import { mockCredentialsStatus } from "./mock-data"
import { mockCredentialsStatus, mockCredentialValues } from "./mock-data"
import { invalidateMockChannelTest } from "./channels"
export interface ProviderCheckResult {
ok: boolean
@@ -19,7 +20,10 @@ export function getCredentials(): Promise<CredentialsStatus> {
}
export function setCredential(account: string, value: string, provider?: string): Promise<void> {
return invokeOrMock("set_credential", { account, value, provider }, () => undefined)
return invokeOrMock("set_credential", { account, value, provider }, () => {
mockCredentialValues.set(`${provider ?? ''}:${account}`, value)
if (provider && account.startsWith('ark.')) invalidateMockChannelTest(provider)
})
}
export function setActiveAsrProvider(provider: string): Promise<void> {
@@ -50,7 +54,7 @@ export function readCredential(account: string, provider?: string): Promise<stri
return invokeOrMock<string | null>(
"read_credential",
{ account, provider },
() => null,
() => mockCredentialValues.get(`${provider ?? ''}:${account}`) ?? null,
)
}
+43 -5
View File
@@ -7,6 +7,7 @@
// 凭据不走这里:按渠道 id 调 readCredential/setCredential(account, value, id)。
import { invokeOrMock } from "./shared"
import { mockCredentialValues } from "./mock-data"
export type ChannelKind = "llm" | "asr"
@@ -82,6 +83,15 @@ export function listChannels(kind: ChannelKind): Promise<Channel[]> {
return invokeOrMock("list_channels", { kind }, () => mockChannels[kind])
}
export function invalidateMockChannelTest(id: string): void {
const channel = mockChannels.llm.find(channel => channel.id === id)
if (channel) channel.lastTest = null
}
export function invalidateMockChannelTests(kind: ChannelKind): void {
for (const channel of mockChannels[kind]) channel.lastTest = null
}
/** 返回后端分配的渠道 id。 */
export function createChannel(
kind: ChannelKind,
@@ -91,7 +101,11 @@ export function createChannel(
return invokeOrMock(
"create_channel",
{ kind, providerType, name },
() => providerType,
() => {
const id = `${providerType}-${Date.now()}-${mockChannels[kind].length}`
mockChannels[kind].push({ id, name, providerType, enabled: true, order: mockChannels[kind].length, lastTest: null })
return id
},
)
}
@@ -104,7 +118,14 @@ export function setChannelProviderType(
return invokeOrMock(
"set_channel_provider_type",
{ kind, id, providerType },
() => undefined,
() => {
const channel = mockChannels[kind].find(channel => channel.id === id)
if (channel && channel.providerType !== providerType) {
channel.providerType = providerType
channel.lastTest = null
if (kind === 'llm') mockCredentialValues.delete(`${id}:ark.request_format`)
}
},
)
}
@@ -113,7 +134,18 @@ export function deleteChannelIfBlank(
kind: ChannelKind,
id: string,
): Promise<boolean> {
return invokeOrMock("delete_channel_if_blank", { kind, id }, () => true)
return invokeOrMock("delete_channel_if_blank", { kind, id }, () => {
const channel = mockChannels[kind].find(channel => channel.id === id)
const prefix = `${id}:`
const hasCredentials = [...mockCredentialValues]
.some(([key, value]) => key.startsWith(prefix) && value.length > 0)
if (!channel || channel.name.trim() || hasCredentials) return false
mockChannels[kind] = mockChannels[kind].filter(channel => channel.id !== id)
for (const key of mockCredentialValues.keys()) {
if (key.startsWith(prefix)) mockCredentialValues.delete(key)
}
return true
})
}
export function renameChannel(
@@ -125,7 +157,10 @@ export function renameChannel(
}
export function deleteChannel(kind: ChannelKind, id: string): Promise<void> {
return invokeOrMock("delete_channel", { kind, id }, () => undefined)
return invokeOrMock("delete_channel", { kind, id }, () => {
mockChannels[kind] = mockChannels[kind].filter(channel => channel.id !== id)
for (const key of mockCredentialValues.keys()) if (key.startsWith(`${id}:`)) mockCredentialValues.delete(key)
})
}
export function setChannelEnabled(
@@ -171,6 +206,9 @@ export function recordChannelTest(
return invokeOrMock(
"record_channel_test",
{ kind, id, ok, latencyMs, error },
() => undefined,
() => {
const channel = mockChannels[kind].find(channel => channel.id === id)
if (channel) channel.lastTest = { ok, latencyMs, error, at: Math.floor(Date.now() / 1000) }
},
)
}
@@ -1,3 +1,4 @@
export const mockCredentialValues = new Map<string, string>()
import type {
ActivityDay,
CorrectionRule,
+13 -1
View File
@@ -1,6 +1,7 @@
import { invokeOrMock } from './shared'
export type ProviderKind = 'asr' | 'llm' | 'omni'
export type LlmRequestFormat = 'chat_completions' | 'responses' | 'messages'
export type AuthRequirement =
| 'none'
@@ -20,9 +21,20 @@ export interface ProviderDescriptor {
authRequirement: AuthRequirement
validationProbe: string
staticModels: string[]
defaultRequestFormat: LlmRequestFormat | null
supportedRequestFormats: LlmRequestFormat[]
}
/** Core owns protocol, defaults, and credential requirements. */
export function listProviderDescriptors(kind: ProviderKind): Promise<ProviderDescriptor[]> {
return invokeOrMock('list_provider_descriptors', { kind }, () => [])
return invokeOrMock('list_provider_descriptors', { kind }, () => kind === 'llm' ? [
['custom', 'customChatCompletions', 'chat_completions'],
['custom_responses', 'customResponses', 'responses'],
['custom_messages', 'customMessages', 'messages'],
].map(([providerType, labelKey, format]) => ({
kind, providerType, labelKey, defaultEndpoint: null, defaultModel: null,
authRequirement: 'api_key_unless_custom_endpoint', validationProbe: 'llm_text', staticModels: [],
defaultRequestFormat: format as LlmRequestFormat,
supportedRequestFormats: ['chat_completions', 'responses', 'messages'],
})) : [])
}
+3
View File
@@ -7,6 +7,7 @@ import {
type StartupSnapshot,
} from "./shared"
import { mockSettings, mockDefaultStyleSystemPrompts, mockSetSettings } from "./mock-data"
import { invalidateMockChannelTests } from "./channels"
export { BACKEND_CONTRACT_VERSION }
export type { StartupSnapshot }
@@ -27,7 +28,9 @@ export function getDefaultStyleSystemPrompts(): Promise<StyleSystemPrompts> {
export function setSettings(prefs: UserPreferences): Promise<void> {
return invokeOrMock("set_settings", { prefs }, () => {
const thinkingChanged = mockSettings.llmThinkingEnabled !== prefs.llmThinkingEnabled
mockSetSettings(prefs)
if (thinkingChanged) invalidateMockChannelTests("llm")
return undefined
})
}
@@ -38,6 +38,8 @@ const descriptors: ProviderDescriptor[] = [
: 'api_key',
validationProbe: 'unsupported',
staticModels: [],
defaultRequestFormat: null,
supportedRequestFormats: [],
}));
const asrPresets = (os: OS, supportsQwen3Mlx = true, currentProviderId?: string) =>
@@ -51,6 +51,8 @@ interface PresetOption {
defaultModel?: string;
authRequirement?: ProviderDescriptor['authRequirement'];
staticModels?: string[];
defaultRequestFormat?: ProviderDescriptor['defaultRequestFormat'];
supportedRequestFormats?: ProviderDescriptor['supportedRequestFormats'];
}
/** 「添加渠道」下拉里的供应商清单。本地引擎与 Codex OAuth 也在其中 —— 它们不是预置的
@@ -69,6 +71,8 @@ export function presetsFor(
defaultModel: descriptor.defaultModel ?? undefined,
authRequirement: descriptor.authRequirement,
staticModels: descriptor.staticModels,
defaultRequestFormat: descriptor.defaultRequestFormat,
supportedRequestFormats: descriptor.supportedRequestFormats,
}));
if (kind === 'llm') return descriptorPresets;
const available = descriptorPresets;
@@ -725,6 +729,7 @@ function ChannelModal({
const { t } = useTranslation();
const [name, setName] = useState(channel.name);
const [providerType, setProviderType] = useState(channel.providerType);
const [changingProvider, setChangingProvider] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const saveName = async () => {
@@ -761,16 +766,17 @@ function ChannelModal({
const changeProvider = async (next: string) => {
const previous = providerType;
onUserMutation();
setProviderType(next);
setChangingProvider(true);
try {
await setChannelProviderType(kind, channel.id, next);
await fillProviderDefaults(next);
setProviderType(next);
await onChanged();
} catch (error) {
console.error('[channels] change provider failed', error);
setProviderType(previous);
emitSaved('failed', t('common.operationFailed'));
}
} finally { setChangingProvider(false); }
};
const remove = async () => {
@@ -796,6 +802,7 @@ function ChannelModal({
<label style={fieldLabel}>{t('settings.channels.providerLabel')}</label>
<SelectLite
value={providerType}
disabled={changingProvider}
onChange={next => void changeProvider(next)}
options={presets.map(p => ({
value: p.id,
@@ -818,7 +825,7 @@ function ChannelModal({
/>
{/* key 决定:换供应商时整组凭据字段重挂载,读的是新厂商对应的槽位。 */}
<ChannelCredentialFields
{!changingProvider && <ChannelCredentialFields
key={`${channel.id}:${providerType}`}
kind={kind}
providerType={providerType}
@@ -826,7 +833,7 @@ function ChannelModal({
descriptor={descriptor}
onTested={() => void onChanged()}
onUserMutation={onUserMutation}
/>
/>}
{isLocalEngine && (
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6, marginTop: 6 }}>
@@ -0,0 +1,54 @@
import { protocolValidationError, type ProtocolValues } from './LlmProtocolFields';
import { listProviderDescriptors } from '../../lib/ipc/providers';
import { createChannel, deleteChannel, deleteChannelIfBlank, listChannels, recordChannelTest, setChannelProviderType } from '../../lib/ipc/channels';
import { readCredential, setCredential } from '../../lib/ipc/asr-credentials';
import { getSettings, setSettings } from '../../lib/ipc/settings';
import { presetsFor } from './ChannelList';
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
const values: ProtocolValues = { 'ark.request_format': '', 'ark.messages_thinking': '', 'ark.max_tokens': '', 'ark.thinking_budget': '' };
assert(protocolValidationError(values, 'messages') === null, 'Core-compatible defaults must be valid');
assert(protocolValidationError({ ...values, 'ark.request_format': 'invalid' }, 'messages') === 'llmRequestFormatInvalid', 'Unknown formats must not silently fall back');
assert(protocolValidationError({ ...values, 'ark.max_tokens': '0' }, 'messages') === 'llmTokenLimitInvalid', 'Zero output tokens must be rejected');
assert(protocolValidationError({ ...values, 'ark.messages_thinking': 'budget', 'ark.max_tokens': '1024' }, 'messages') === 'llmThinkingBudgetInvalid', 'Fixed thinking must leave room for output');
assert(protocolValidationError({ ...values, 'ark.messages_thinking': 'budget', 'ark.max_tokens': '4096', 'ark.thinking_budget': '2048' }, 'messages') === null, 'Valid fixed budget must be accepted');
const descriptors = await listProviderDescriptors('llm');
const presets = presetsFor('llm', 'win', true, undefined, descriptors);
assert(presets.length === 3, 'Browser catalog should expose three compatibility presets');
assert(presets.find(p => p.id === 'custom_messages')?.defaultRequestFormat === 'messages', 'Picker must retain Core protocol defaults');
for (const preset of presets) assert(preset.supportedRequestFormats?.length === 3, 'All compatibility presets allow switching');
const first = await createChannel('llm', 'custom', 'first');
const second = await createChannel('llm', 'custom', 'second');
await setCredential('ark.request_format', 'messages', first);
await setCredential('ark.api_key', 'fixture-key', first);
await recordChannelTest('llm', first, true, 1, null);
await setCredential('ark.model_id', 'new-model', first);
assert(await readCredential('ark.request_format', first) === 'messages', 'Changing model must preserve format');
assert(await readCredential('ark.request_format', second) === null, 'Formats must be scoped by channel');
assert((await listChannels('llm')).find(c => c.id === first)?.lastTest === null, 'Credential mutation invalidates old validation');
await setChannelProviderType('llm', first, 'custom_responses');
assert(await readCredential('ark.request_format', first) === null, 'Changing preset resets the format override');
assert(await readCredential('ark.api_key', first) === 'fixture-key', 'Changing preset preserves the key');
await recordChannelTest('llm', first, true, 1, null);
const settings = await getSettings();
const asrTestAt = (await listChannels('asr'))[0].lastTest?.at;
await setSettings({ ...settings, llmThinkingEnabled: !settings.llmThinkingEnabled });
assert((await listChannels('llm')).find(c => c.id === first)?.lastTest === null, 'Changing thinking invalidates LLM tests');
assert((await listChannels('asr'))[0].lastTest?.at === asrTestAt, 'Changing thinking preserves ASR tests');
await setSettings(settings);
const blank = await createChannel('llm', 'custom', '');
assert(await deleteChannelIfBlank('llm', blank), 'An empty browser draft should be recycled');
assert(!(await listChannels('llm')).some(channel => channel.id === blank), 'A recycled browser draft must leave no channel');
const configured = await createChannel('llm', 'custom', '');
await setCredential('ark.api_key', 'keep-me', configured);
assert(!(await deleteChannelIfBlank('llm', configured)), 'A configured browser draft must be preserved');
assert((await listChannels('llm')).some(channel => channel.id === configured), 'A configured browser draft must remain visible');
await deleteChannel('llm', configured);
await deleteChannel('llm', first);
await deleteChannel('llm', second);
@@ -0,0 +1,124 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SelectLite } from '../../components/ui/SelectLite';
import { readCredential, setCredential } from '../../lib/ipc';
import type { LlmRequestFormat } from '../../lib/ipc/providers';
import { emitSaved } from '../../lib/savedEvent';
import { SettingRow, inputStyle } from './shared';
const accounts = ['ark.request_format', 'ark.messages_thinking', 'ark.max_tokens', 'ark.thinking_budget'] as const;
type Account = typeof accounts[number];
export type ProtocolValues = Record<Account, string>;
const emptyValues: ProtocolValues = {
'ark.request_format': '', 'ark.messages_thinking': '', 'ark.max_tokens': '', 'ark.thinking_budget': '',
};
/** 界面即时提示;Core 仍对实际存储和发出的请求做同样的校验。 */
export function protocolValidationError(values: ProtocolValues, defaultFormat: LlmRequestFormat): string | null {
const format = values['ark.request_format'] || defaultFormat;
if (!['chat_completions', 'responses', 'messages'].includes(format)) return 'llmRequestFormatInvalid';
const mode = values['ark.messages_thinking'] || 'adaptive';
if (!['adaptive', 'budget'].includes(mode)) return 'llmThinkingModeInvalid';
const max = values['ark.max_tokens'] || '8192';
const budget = values['ark.thinking_budget'] || '1024';
if (![max, budget].every(value => /^\d+$/.test(value) && Number(value) > 0 && Number(value) <= 4294967295)) return 'llmTokenLimitInvalid';
if (Number(budget) < 1024 || (format === 'messages' && mode === 'budget' && Number(budget) >= Number(max))) return 'llmThinkingBudgetInvalid';
return null;
}
export function LlmProtocolFields({ channelId, defaultFormat, formats, onUserMutation, onBlockedChange, onSaved }: {
channelId: string;
defaultFormat: LlmRequestFormat;
formats: LlmRequestFormat[];
onUserMutation: () => void;
onBlockedChange: (account: string, blocked: boolean) => void;
onSaved?: () => void;
}) {
const { t } = useTranslation();
const [values, setValues] = useState<ProtocolValues>(emptyValues);
const [saved, setSaved] = useState<ProtocolValues>(emptyValues);
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<'read' | 'save' | null>(null);
const mounted = useRef(true);
const writing = useRef(false);
const dirty = accounts.some(account => values[account] !== saved[account]);
const validation = protocolValidationError(values, defaultFormat);
useEffect(() => {
mounted.current = true;
Promise.all(accounts.map(account => readCredential(account, channelId)))
.then(result => {
if (!mounted.current) return;
const next = Object.fromEntries(accounts.map((account, index) => [account, result[index] ?? ''])) as ProtocolValues;
setValues(next); setSaved(next); setLoaded(true);
})
.catch(() => { if (mounted.current) setError('read'); });
return () => { mounted.current = false; };
}, [channelId]);
useEffect(() => {
onBlockedChange('protocol', !loaded || saving || dirty || error !== null || validation !== null);
}, [loaded, saving, dirty, error, validation, onBlockedChange]);
const save = async (next: ProtocolValues) => {
if (!loaded || writing.current || protocolValidationError(next, defaultFormat)) return;
writing.current = true;
setSaving(true); setError(null);
try {
for (const account of accounts) {
if (next[account] !== saved[account]) await setCredential(account, next[account], channelId);
}
if (mounted.current) {
setSaved(next);
emitSaved('saved', t('common.saved'));
onSaved?.();
}
} catch {
if (mounted.current) { setError('save'); emitSaved('failed', t('common.operationFailed')); }
} finally {
writing.current = false;
if (mounted.current) setSaving(false);
}
};
const change = (account: Account, value: string, immediate = false) => {
onUserMutation();
const next = { ...values, [account]: value };
setValues(next);
if (immediate) void save(next);
};
const format = values['ark.request_format'] || defaultFormat;
const mode = values['ark.messages_thinking'] || 'adaptive';
const disabled = !loaded || saving;
const numberField = (account: Account, label: string, placeholder: string) => (
<SettingRow label={label}>
<input type="number" min={account === 'ark.thinking_budget' ? 1024 : 1} step={1}
aria-label={label} value={values[account]} placeholder={placeholder} disabled={disabled}
onChange={event => change(account, event.target.value)} onBlur={() => { if (dirty) void save(values); }}
style={{ ...inputStyle, width: '100%', maxWidth: 420 }} />
</SettingRow>
);
return <>
<SettingRow label={t('settings.providers.requestFormatLabel')}>
<SelectLite value={format} disabled={disabled} ariaLabel={t('settings.providers.requestFormatLabel')}
options={formats.map(value => ({ value, label: ({ chat_completions: 'Chat Completions', responses: 'Responses', messages: 'Messages' })[value] }))}
onChange={value => change('ark.request_format', value, true)} />
</SettingRow>
{format === 'responses' && <p style={{ fontSize: 11.5, color: 'var(--ol-ink-4)' }}>{t('settings.providers.responsesThinkingHint')}</p>}
{format === 'messages' && <>
<SettingRow label={t('settings.providers.messagesThinkingLabel')}>
<SelectLite value={mode} disabled={disabled} ariaLabel={t('settings.providers.messagesThinkingLabel')}
options={[{ value: 'adaptive', label: t('settings.providers.thinkingAdaptive') }, { value: 'budget', label: t('settings.providers.thinkingBudget') }]}
onChange={value => change('ark.messages_thinking', value, true)} />
</SettingRow>
{numberField('ark.max_tokens', t('settings.providers.maxTokensLabel'), '8192')}
{mode === 'budget' && numberField('ark.thinking_budget', t('settings.providers.thinkingBudgetLabel'), '1024')}
<p style={{ fontSize: 11.5, color: 'var(--ol-ink-4)' }}>{t('settings.providers.messagesThinkingHint')}</p>
</>}
{(validation || error) && <p role="alert" style={{ fontSize: 12, color: 'var(--ol-warn)' }}>
{validation ? t(`settings.providers.${validation}`) : t(error === 'read' ? 'settings.providers.readFailed' : 'common.operationFailed')}
</p>}
{dirty && <button disabled={disabled || !!validation} onClick={() => void save(values)}>{t('settings.providers.saveProtocol')}</button>}
</>;
}
@@ -29,6 +29,8 @@ const coreAsr = presetsFor('asr', 'win', true, undefined, [{
authRequirement: 'endpoint_model_optional_api_key',
validationProbe: 'asr_silence',
staticModels: [],
defaultRequestFormat: null,
supportedRequestFormats: [],
}]);
if (coreAsr.length !== 1 || coreAsr[0].authRequirement !== 'endpoint_model_optional_api_key') {
@@ -1,7 +1,7 @@
// 服务 → AI 提供商:LLM 润色模型 + ASR 语音转写两张卡片。
// 自 Settings.tsx 整体迁出,逻辑零改动;i18n key 全部保持 `settings.providers.*`。
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from '../../components/Icon';
import { detectOS } from '../../components/WindowChrome';
@@ -15,6 +15,7 @@ import {
validateProviderCredentials,
type ProviderDescriptor,
} from '../../lib/ipc';
import { LlmProtocolFields } from './LlmProtocolFields';
import { emitSaved } from '../../lib/savedEvent';
import { useLayoutStack, useConservativeLayout } from '../../lib/useMobileLayout';
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
@@ -90,7 +91,7 @@ export const LLM_LABELS = [
['codex_oauth', 'codexOAuth'], ['mimo', 'mimo'], ['cometapi', 'cometapi'],
['openrouterFree', 'openrouterFree'], ['alibabaCoding', 'alibabaCoding'],
['codingPlanX', 'codingPlanX'], ['minimax', 'minimax'], ['stepfun', 'stepfun'],
['custom', 'custom'],
['custom', 'customChatCompletions'], ['custom_responses', 'customResponses'], ['custom_messages', 'customMessages'],
].map(([id, nameKey]) => ({ id, nameKey })) as readonly { id: string; nameKey: string }[];
// 多模态(Omni)模型预设(issue #902):一个模型同时接收「提示词 + 音频」一步输出
@@ -122,7 +123,7 @@ export function ChannelCredentialFields({
kind: 'llm' | 'asr';
providerType: string;
channelId: string;
descriptor?: Partial<Pick<ProviderDescriptor, 'authRequirement' | 'defaultEndpoint' | 'defaultModel' | 'staticModels'>>;
descriptor?: Partial<Pick<ProviderDescriptor, 'authRequirement' | 'defaultEndpoint' | 'defaultModel' | 'staticModels' | 'defaultRequestFormat' | 'supportedRequestFormats'>>;
/** 测试连通出结果后通知外层刷新卡片上的延迟/标红。 */
onTested?: () => void;
/** 新建草稿发生用户交互时同步通知外层,避免关闭流程误删。 */
@@ -134,6 +135,13 @@ export function ChannelCredentialFields({
const conservative = useConservativeLayout();
const layoutStack = conservative || baseLayoutStack;
const [llmModelRevision, setLlmModelRevision] = useState(0);
const [configRevision, setConfigRevision] = useState(0);
const [blockedFields, setBlockedFields] = useState<Record<string, boolean>>({});
const trackField = useCallback((account: string, blocked: boolean) => {
setBlockedFields(previous => previous[account] === blocked ? previous : { ...previous, [account]: blocked });
}, []);
const onLlmMutation = () => { onUserMutation?.(); setConfigRevision(value => value + 1); };
const [asrModelRevision, setAsrModelRevision] = useState(0);
const unifiedBailian = providerType === 'bailian';
const [bailianModel, setBailianModel] = useState('');
@@ -156,10 +164,15 @@ export function ChannelCredentialFields({
const onLlmThinkingToggle = (enabled: boolean) => {
if (!prefs) return;
void updatePrefs(current => ({ ...current, llmThinkingEnabled: enabled })).catch(error => {
console.error('[settings] failed to update LLM thinking mode', error);
emitSaved('failed', t('common.operationFailed'));
});
onLlmMutation();
trackField('thinking', true);
void updatePrefs(current => ({ ...current, llmThinkingEnabled: enabled }))
.then(() => onTested?.())
.catch(error => {
console.error('[settings] failed to update LLM thinking mode', error);
emitSaved('failed', t('common.operationFailed'));
})
.finally(() => trackField('thinking', false));
};
// Provider policy 必须 fail-closed:Core descriptor 尚未返回或加载失败时,
@@ -174,6 +187,11 @@ export function ChannelCredentialFields({
const codexOAuthSelected = descriptor?.authRequirement === 'o_auth';
return (
<>
{!!descriptor.supportedRequestFormats?.length && descriptor.defaultRequestFormat && (
<LlmProtocolFields channelId={channelId} defaultFormat={descriptor.defaultRequestFormat}
formats={descriptor.supportedRequestFormats} onUserMutation={onLlmMutation}
onBlockedChange={trackField} onSaved={onTested} />
)}
{codexOAuthSelected ? (
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6, margin: '2px 0 10px' }}>
{t('settings.providers.codexOAuthNotice')}
@@ -181,29 +199,29 @@ export function ChannelCredentialFields({
) : (
<>
<CredentialField key={`${channelId}:api_key`} label={t('settings.providers.apiKeyLabel')}
account="ark.api_key" provider={channelId} mono mask onUserMutation={onUserMutation} />
account="ark.api_key" provider={channelId} mono mask onUserMutation={onLlmMutation} onBlockedChange={trackField} />
<CredentialField key={`${channelId}:endpoint`} label={t('settings.providers.baseUrlLabel')}
account="ark.endpoint" provider={channelId}
placeholder={defaultEndpoint || 'https://your-endpoint/v1'}
defaultValue={defaultEndpoint || undefined} onUserMutation={onUserMutation} />
{providerType === 'custom' && (
defaultValue={defaultEndpoint || undefined} onUserMutation={onLlmMutation} onBlockedChange={trackField} />
{['custom', 'custom_responses', 'custom_messages'].includes(providerType) && (
<>
<CredentialField
key={`${channelId}:temperature`}
label={t('settings.providers.temperatureLabel')}
account="ark.temperature"
account="ark.temperature" provider={channelId}
placeholder={t('settings.providers.temperaturePlaceholder')}
mono
onUserMutation={onUserMutation}
onUserMutation={onLlmMutation} onBlockedChange={trackField}
/>
<CredentialField
key={`${channelId}:extra_headers`}
label={t('settings.providers.extraHeadersLabel')}
account="ark.extra_headers"
account="ark.extra_headers" provider={channelId}
placeholder={t('settings.providers.extraHeadersPlaceholder')}
mono
mask
onUserMutation={onUserMutation}
onUserMutation={onLlmMutation} onBlockedChange={trackField}
/>
</>
)}
@@ -213,7 +231,7 @@ export function ChannelCredentialFields({
account="ark.model_id" provider={channelId}
placeholder={defaultModel || 'model-name'} mono
defaultValue={defaultModel || undefined}
onUserMutation={onUserMutation}
onUserMutation={onLlmMutation} onBlockedChange={trackField}
trailing={(
<LlmThinkingToggle
enabled={prefs?.llmThinkingEnabled ?? false}
@@ -221,7 +239,7 @@ export function ChannelCredentialFields({
/>
)}
/>
<ProviderTools kind="llm" modelAccount="ark.model_id" provider={channelId}
<ProviderTools key={configRevision} disabled={Object.values(blockedFields).some(Boolean) || (!!descriptor.supportedRequestFormats?.length && blockedFields.protocol === undefined)} kind="llm" modelAccount="ark.model_id" provider={channelId}
onModelSelected={() => setLlmModelRevision(v => v + 1)} onTested={onTested}
onUserMutation={onUserMutation} />
</>
@@ -571,17 +589,20 @@ function BailianProtocolHint({ currentModel }: { currentModel: string }) {
type ProviderToolStatus = 'idle' | 'loading' | 'success' | 'empty' | 'error';
function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested, onUserMutation, showFetchModels = true }: { kind: 'llm' | 'asr' | 'omni'; modelAccount: string; provider?: string; onModelSelected: () => void; onTested?: () => void; onUserMutation?: () => void; showFetchModels?: boolean }) {
function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested, onUserMutation, showFetchModels = true, disabled = false }: { disabled?: boolean; kind: 'llm' | 'asr' | 'omni'; modelAccount: string; provider?: string; onModelSelected: () => void; onTested?: () => void; onUserMutation?: () => void; showFetchModels?: boolean }) {
const { t } = useTranslation();
const baseLayoutStack = useLayoutStack();
const conservative = useConservativeLayout();
const layoutStack = conservative || baseLayoutStack;
const mounted = useRef(true);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
const [models, setModels] = useState<string[]>([]);
const [selectedModel, setSelectedModel] = useState('');
const [status, setStatus] = useState<ProviderToolStatus>('idle');
const [message, setMessage] = useState('');
const setResult = (next: ProviderToolStatus, nextMessage: string) => {
if (!mounted.current) return;
setStatus(next);
setMessage(nextMessage);
};
@@ -590,7 +611,7 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested
// 测试本身已经在按钮旁给出结论,记录不上只是卡片少一行历史。
const persistTest = async (ok: boolean, latencyMs: number | null, message: string | null) => {
// Omni 不走渠道化(独立命名空间),没有可落测试结果的渠道卡片。
if (!provider || kind === 'omni') return;
if (!mounted.current || !provider || kind === 'omni') return;
try {
await recordChannelTest(kind, provider, ok, latencyMs, message);
onTested?.();
@@ -600,6 +621,7 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested
};
const validate = async () => {
if (disabled) return;
onUserMutation?.();
setModels([]);
setSelectedModel('');
@@ -631,6 +653,7 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested
};
const loadModels = async () => {
if (disabled) return;
onUserMutation?.();
setResult('loading', t('settings.providers.loadingModels'));
try {
@@ -665,15 +688,15 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested
<SettingRow label={t('settings.providers.toolsLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, width: '100%', maxWidth: layoutStack ? '100%' : 420 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap', width: '100%' }}>
<button onClick={validate} style={miniBtnStyle} disabled={status === 'loading'}>{t('settings.providers.validate')}</button>
<button onClick={validate} style={miniBtnStyle} disabled={disabled || status === 'loading'}>{t('settings.providers.validate')}</button>
{showFetchModels && (
<button onClick={loadModels} style={miniBtnStyle} disabled={status === 'loading'}>{t('settings.providers.fetchModels')}</button>
<button onClick={loadModels} style={miniBtnStyle} disabled={disabled || status === 'loading'}>{t('settings.providers.fetchModels')}</button>
)}
{showFetchModels && models.length > 0 && (
<SelectLite
value={selectedModel}
onChange={applyModel}
disabled={status === 'loading'}
disabled={disabled || status === 'loading'}
options={models.map(model => ({ value: model, label: model }))}
placeholder={t('settings.providers.selectModel')}
ariaLabel={t('settings.providers.selectModel')}
@@ -693,6 +716,9 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested
function providerErrorMessage(error: unknown, t: ReturnType<typeof useTranslation>['t']): string {
const message = error instanceof Error ? error.message : String(error);
for (const code of ['llmRequestFormatInvalid', 'llmThinkingModeInvalid', 'llmTokenLimitInvalid', 'llmThinkingBudgetInvalid', 'llmResponseIncomplete', 'llmStreamError', 'llmProtocolHeaderConflict']) {
if (message.includes(code)) return t(`settings.providers.${code}`);
}
if (message.startsWith('providerHttpStatus:')) {
return t('settings.providers.providerHttpStatus', { status: message.split(':')[1] || '?' });
}
@@ -723,6 +749,7 @@ function providerErrorMessage(error: unknown, t: ReturnType<typeof useTranslatio
type CredentialFieldStatus = 'idle' | 'saving' | 'saved' | 'readError' | 'saveError' | 'copied' | 'copyError';
interface CredentialFieldProps {
onBlockedChange?: (account: string, blocked: boolean) => void;
label: string;
account: string;
provider?: string;
@@ -738,7 +765,7 @@ interface CredentialFieldProps {
options?: SelectOption[];
}
function CredentialField({ label, account, provider, placeholder, mono, mask, defaultValue, trailing, onValueChange, onUserMutation, options }: CredentialFieldProps) {
function CredentialField({ label, account, provider, placeholder, mono, mask, defaultValue, trailing, onValueChange, onUserMutation, options, onBlockedChange }: CredentialFieldProps) {
const { t } = useTranslation();
const baseLayoutStack = useLayoutStack();
const conservative = useConservativeLayout();
@@ -750,9 +777,16 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de
const [status, setStatus] = useState<CredentialFieldStatus>('idle');
// 预设下拉的「自定义模型…」逃生口:选中后切回输入框,保证后端支持的任意模型名都能手输。
const [customModelMode, setCustomModelMode] = useState(false);
useEffect(() => {
onBlockedChange?.(account, !loaded || dirty || status === 'saving' || status === 'readError' || status === 'saveError');
}, [account, loaded, dirty, status, onBlockedChange]);
const debounceRef = useRef<number | null>(null);
const statusRef = useRef<number | null>(null);
const mountedRef = useRef(true);
const editRevision = useRef(0);
const saveQueue = useRef<Promise<void>>(Promise.resolve());
const markMutation = () => { editRevision.current += 1; onUserMutation?.(); };
useEffect(() => {
let cancelled = false;
@@ -816,22 +850,26 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de
const save = async (v: string, force = false) => {
if (!loaded || (!dirty && !force)) return;
if (!mountedRef.current) return;
const revision = editRevision.current;
setStatus('saving');
emitSaved('saving', t('common.saving'));
try {
await setCredential(account, v, provider);
if (!mountedRef.current) return;
// 按编辑顺序写入,旧请求完成不能把新值标记为已保存。
const write = saveQueue.current.catch(() => undefined).then(() => setCredential(account, v, provider));
saveQueue.current = write;
await write;
if (!mountedRef.current || revision !== editRevision.current) return;
setDirty(false);
showTemporaryStatus('saved');
} catch (error) {
if (!mountedRef.current) return;
if (!mountedRef.current || revision !== editRevision.current) return;
console.error('[settings] failed to save credential', account, error);
showTemporaryStatus('saveError');
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onUserMutation?.();
markMutation();
const v = e.target.value;
setValue(v);
onValueChange?.(v);
@@ -852,7 +890,7 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de
const fillDefault = async () => {
if (!loaded || !defaultValue) return;
onUserMutation?.();
markMutation();
setValue(defaultValue);
onValueChange?.(defaultValue);
setDirty(true);
@@ -891,7 +929,7 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de
setCustomModelMode(true);
return;
}
onUserMutation?.();
markMutation();
setValue(v);
onValueChange?.(v);
if (!loaded) return;