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:
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,
|
||||
})
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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: 'リアルタイムモデル · 話しながら文字起こし。',
|
||||
|
||||
@@ -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: '실시간 모델 · 말하는 동안 바로 전사.',
|
||||
|
||||
@@ -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: '实时模型 · 边说边出字。',
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
})) : [])
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user