From cc53a3d11c4932089ebf61ed18c94acd9db8da56 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 4 Oct 2026 22:23:14 +0800 Subject: [PATCH] feat(ark): add low-latency service tier --- docs/volcengine-setup.md | 1 + .../app/crates/openless-core/src/api.rs | 55 +++++++++++++++-- .../src/cloud_sync_e2ee_documents/registry.rs | 1 + .../openless-core/src/credentials_legacy.rs | 4 ++ .../crates/openless-core/src/llm_protocol.rs | 59 ++++++++++++++++++- .../app/crates/openless-core/src/polish.rs | 24 ++++++++ .../src-tauri/src/persistence/credentials.rs | 12 +++- openless-all/app/src/i18n/de.ts | 5 ++ openless-all/app/src/i18n/en.ts | 4 ++ openless-all/app/src/i18n/es.ts | 5 ++ openless-all/app/src/i18n/fr.ts | 5 ++ openless-all/app/src/i18n/ja.ts | 4 ++ openless-all/app/src/i18n/ko.ts | 4 ++ openless-all/app/src/i18n/zh-CN.ts | 3 + openless-all/app/src/i18n/zh-TW.ts | 3 + openless-all/app/src/lib/ipc/channels.ts | 5 +- .../pages/settings/LlmProtocolFields.test.ts | 16 +++++ .../src/pages/settings/LlmProtocolFields.tsx | 37 +++++++++--- .../src/pages/settings/ProvidersSection.tsx | 2 + 19 files changed, 233 insertions(+), 16 deletions(-) diff --git a/docs/volcengine-setup.md b/docs/volcengine-setup.md index fd5635101..ff16dd6a2 100644 --- a/docs/volcengine-setup.md +++ b/docs/volcengine-setup.md @@ -29,3 +29,4 @@ - Agent Plan:`https://ark.cn-beijing.volces.com/api/plan/v3`;Coding Plan:`https://ark.cn-beijing.volces.com/api/coding/v3`。使用各自套餐的专属 API Key,按所选请求格式适配协议路径。 - 选择套餐后,通过“查看支持的模型”打开对应的 [Agent Plan 控制台](https://console.volcengine.com/ark/subscription/agent-plan)或 [Coding Plan 控制台](https://console.volcengine.com/ark/subscription/coding-plan),复制支持的文本模型 ID,手动填写后执行“验证”。该按钮不拉取在线模型列表,也不验证密钥。 - 预设地址只读,已有自定义地址保持可编辑;新建自定义接口使用自定义供应商入口。普通火山方舟及其他供应商保留原有模型列表获取行为。 +- 方舟通道的协议设置提供“低延迟推理(fast)”开关;启用后请求会携带 `service_tier: "fast"`。该参数只对支持快速服务档位的模型有效,不支持的模型可能返回 400。 diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index a96c1cbe9..383eb8771 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -4390,13 +4390,34 @@ impl OpenLessBackend { Some(id.clone()), crate::llm_protocol::REQUEST_FORMAT_ACCOUNT, )?; + let service_tier_key = CredentialKey::new( + crate::CredentialNamespace::Llm, + Some(id.clone()), + crate::llm_protocol::SERVICE_TIER_ACCOUNT, + )?; let reset = kind == ChannelKind::Llm && previous.provider_type != provider_type; - let old_format = if reset { - let value = self.deps.credential_store.read(key.clone()).await?; + let (old_format, old_service_tier) = if reset { + let old_format = self.deps.credential_store.read(key.clone()).await?; + let old_service_tier = self + .deps + .credential_store + .read(service_tier_key.clone()) + .await?; self.deps.credential_store.remove(key.clone()).await?; - value + if let Err(error) = self + .deps + .credential_store + .remove(service_tier_key.clone()) + .await + { + if let Some(value) = old_format.clone() { + self.deps.credential_store.write(key.clone(), value).await?; + } + return Err(error); + } + (old_format, old_service_tier) } else { - None + (None, None) }; let result = self .deps @@ -4412,6 +4433,12 @@ impl OpenLessBackend { if let Some(value) = old_format { self.deps.credential_store.write(key, value).await?; } + if let Some(value) = old_service_tier { + self.deps + .credential_store + .write(service_tier_key, value) + .await?; + } } result?; self.refresh_and_publish_credentials().await.map(|_| ()) @@ -10298,7 +10325,7 @@ mod tests { } #[tokio::test] - async fn llm_protocol_mutations_reset_only_the_format_and_invalidate_tests() { + async fn llm_protocol_mutations_reset_protocol_overrides_and_invalidate_tests() { use crate::credentials::{CredentialNamespace, InMemoryCredentialStore, SecretValue}; use crate::llm_protocol::*; let backend = OpenLessBackend::new( @@ -10333,6 +10360,19 @@ mod tests { .set_credential(key(REQUEST_FORMAT_ACCOUNT), SecretValue::new("messages")) .await .unwrap(); + backend + .set_credential(key(SERVICE_TIER_ACCOUNT), SecretValue::new("fast")) + .await + .unwrap(); + assert_eq!( + backend + .read_credential(key(SERVICE_TIER_ACCOUNT)) + .await + .unwrap() + .unwrap() + .expose_secret(), + "fast" + ); backend .set_credential( key(crate::credentials::LLM_API_KEY_ACCOUNT), @@ -10379,6 +10419,11 @@ mod tests { .await .unwrap() .is_none()); + assert!(backend + .read_credential(key(SERVICE_TIER_ACCOUNT)) + .await + .unwrap() + .is_none()); assert_eq!( backend .read_credential(key(crate::credentials::LLM_API_KEY_ACCOUNT)) diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs index 3eedc0809..d36eb1300 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs @@ -912,6 +912,7 @@ pub const LLM_ACCOUNTS: &[&str] = &[ "ark.messages_thinking", "ark.max_tokens", "ark.thinking_budget", + "ark.service_tier", ]; pub const OMNI_ACCOUNTS: &[&str] = &[ "omni.api_key", diff --git a/openless-all/app/crates/openless-core/src/credentials_legacy.rs b/openless-all/app/crates/openless-core/src/credentials_legacy.rs index 2193da9d5..6b034f247 100644 --- a/openless-all/app/crates/openless-core/src/credentials_legacy.rs +++ b/openless-all/app/crates/openless-core/src/credentials_legacy.rs @@ -109,6 +109,7 @@ struct LegacyEntry { messages_thinking: Option, max_tokens: Option, thinking_budget: Option, + service_tier: Option, } impl Default for LegacyEntry { @@ -141,6 +142,7 @@ impl Default for LegacyEntry { messages_thinking: None, max_tokens: None, thinking_budget: None, + service_tier: None, } } } @@ -169,6 +171,7 @@ impl LegacyEntry { &self.messages_thinking, &self.max_tokens, &self.thinking_budget, + &self.service_tier, ] .into_iter() .any(|value| value.as_deref().is_some_and(|value| !value.is_empty())) @@ -378,6 +381,7 @@ fn decode_entry( (MESSAGES_THINKING_ACCOUNT, entry.messages_thinking), (MAX_TOKENS_ACCOUNT, entry.max_tokens), (THINKING_BUDGET_ACCOUNT, entry.thinking_budget), + (SERVICE_TIER_ACCOUNT, entry.service_tier), ]); } if namespace == CredentialNamespace::Asr { diff --git a/openless-all/app/crates/openless-core/src/llm_protocol.rs b/openless-all/app/crates/openless-core/src/llm_protocol.rs index e09542507..001caf8ee 100644 --- a/openless-all/app/crates/openless-core/src/llm_protocol.rs +++ b/openless-all/app/crates/openless-core/src/llm_protocol.rs @@ -12,11 +12,13 @@ pub const REQUEST_FORMAT_ACCOUNT: &str = "ark.request_format"; pub const MESSAGES_THINKING_ACCOUNT: &str = "ark.messages_thinking"; pub const MAX_TOKENS_ACCOUNT: &str = "ark.max_tokens"; pub const THINKING_BUDGET_ACCOUNT: &str = "ark.thinking_budget"; -pub const CONFIG_ACCOUNTS: [&str; 4] = [ +pub const SERVICE_TIER_ACCOUNT: &str = "ark.service_tier"; +pub const CONFIG_ACCOUNTS: [&str; 5] = [ REQUEST_FORMAT_ACCOUNT, MESSAGES_THINKING_ACCOUNT, MAX_TOKENS_ACCOUNT, THINKING_BUDGET_ACCOUNT, + SERVICE_TIER_ACCOUNT, ]; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -115,12 +117,21 @@ pub enum MessagesThinking { Budget, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LlmServiceTier { + #[default] + Off, + Fast, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct LlmProtocolConfig { pub format: LlmRequestFormat, pub messages_thinking: MessagesThinking, pub max_tokens: u32, pub thinking_budget: u32, + pub service_tier: LlmServiceTier, } impl Default for LlmProtocolConfig { @@ -130,6 +141,7 @@ impl Default for LlmProtocolConfig { messages_thinking: MessagesThinking::Adaptive, max_tokens: 8192, thinking_budget: 1024, + service_tier: LlmServiceTier::Off, } } } @@ -194,6 +206,12 @@ impl LlmProtocolConfig { return Err(config_error("llmThinkingBudgetInvalid")); } } + SERVICE_TIER_ACCOUNT => { + self.service_tier = match value { + "fast" => LlmServiceTier::Fast, + _ => return Err(config_error("llmServiceTierInvalid")), + } + } _ => return Err(config_error("llmRequestFormatInvalid")), } Ok(()) @@ -209,6 +227,12 @@ impl LlmProtocolConfig { } Ok(()) } + + pub(crate) fn apply_service_tier(&self, provider_id: &str, body: &mut Value) { + if provider_id.trim() == "ark" && self.service_tier == LlmServiceTier::Fast { + body["service_tier"] = json!("fast"); + } + } } fn positive_tokens(value: &str) -> Result { @@ -293,6 +317,9 @@ pub(crate) fn request_body( body["temperature"] = crate::polish::temperature_json(temperature); } } + config + .protocol + .apply_service_tier(&config.provider_id, &mut body); body } @@ -726,6 +753,36 @@ mod tests { } } + #[test] + fn service_tier_is_validated_and_applied_only_to_ark_requests() { + let mut protocol = LlmProtocolConfig::default(); + assert!(protocol.apply(SERVICE_TIER_ACCOUNT, "invalid").is_err()); + protocol.apply(SERVICE_TIER_ACCOUNT, "fast").unwrap(); + assert_eq!(protocol.service_tier, LlmServiceTier::Fast); + + let messages = vec![json!({"role":"user","content":"hi"})]; + for format in [LlmRequestFormat::Responses, LlmRequestFormat::Messages] { + let mut config = OpenAICompatibleConfig::new( + "ark", + "test", + "https://ark.cn-beijing.volces.com/api/v3", + "key", + "test-model", + ) + .with_protocol(LlmProtocolConfig { + format, + service_tier: LlmServiceTier::Fast, + ..Default::default() + }); + let body = request_body(&config, false, messages.clone()); + assert_eq!(body["service_tier"], "fast"); + + config.provider_id = "openai".into(); + let body = request_body(&config, false, messages.clone()); + assert!(body.get("service_tier").is_none()); + } + } + #[test] fn budgets_are_validated_and_non_streaming_extracts_only_complete_text() { let mut config = LlmProtocolConfig { diff --git a/openless-all/app/crates/openless-core/src/polish.rs b/openless-all/app/crates/openless-core/src/polish.rs index 725393e91..a70514e2e 100644 --- a/openless-all/app/crates/openless-core/src/polish.rs +++ b/openless-all/app/crates/openless-core/src/polish.rs @@ -834,6 +834,9 @@ impl OpenAICompatibleLLMProvider { &self.config.model, self.config.thinking_enabled, ); + self.config + .protocol + .apply_service_tier(&self.config.provider_id, &mut body); body } @@ -3364,6 +3367,27 @@ mod tests { ); } + #[test] + fn ark_chat_body_includes_fast_service_tier() { + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new( + "ark", + "Ark", + "https://ark.cn-beijing.volces.com/api/v3", + "k", + "doubao-seed-2-1-lite-260915", + ) + .with_protocol(LlmProtocolConfig { + service_tier: crate::llm_protocol::LlmServiceTier::Fast, + ..Default::default() + }), + ); + + let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]); + + assert_eq!(body["service_tier"], "fast"); + } + #[test] fn openai_chat_body_adds_reasoning_effort_for_openai_reasoning_model() { let provider = OpenAICompatibleLLMProvider::new( diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index 5ef7ef794..5124adbc7 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -955,6 +955,8 @@ struct CredsLlmEntry { maxTokens: Option, #[serde(skip_serializing_if = "Option::is_none")] thinkingBudget: Option, + #[serde(skip_serializing_if = "Option::is_none")] + serviceTier: Option, } impl CredsLlmEntry { @@ -965,6 +967,7 @@ impl CredsLlmEntry { MESSAGES_THINKING_ACCOUNT => Ok(&mut self.messagesThinking), MAX_TOKENS_ACCOUNT => Ok(&mut self.maxTokens), THINKING_BUDGET_ACCOUNT => Ok(&mut self.thinkingBudget), + SERVICE_TIER_ACCOUNT => Ok(&mut self.serviceTier), _ => anyhow::bail!("unsupported LLM protocol option"), } } @@ -990,6 +993,7 @@ impl CredsLlmEntry { && self.messagesThinking.is_none() && self.maxTokens.is_none() && self.thinkingBudget.is_none() + && self.serviceTier.is_none() && self .extraHeaders .as_ref() @@ -3116,7 +3120,8 @@ fn export_sync_credentials_root( )); let mut values = accounts!(entry, "ark.api_key"=>apiKey, "ark.endpoint"=>baseURL, "ark.model_id"=>model, "ark.request_format"=>requestFormat, "ark.messages_thinking"=>messagesThinking, - "ark.max_tokens"=>maxTokens, "ark.thinking_budget"=>thinkingBudget); + "ark.max_tokens"=>maxTokens, "ark.thinking_budget"=>thinkingBudget, + "ark.service_tier"=>serviceTier); if let Some(value) = entry.temperature { values.insert("ark.temperature".into(), value.to_string()); } @@ -3385,6 +3390,7 @@ fn apply_sync_credentials_root( "ark.messages_thinking", "ark.max_tokens", "ark.thinking_budget", + "ark.service_tier", ] { if let Some(value) = values.get(key) { protocol.apply(key, value)?; @@ -3417,6 +3423,7 @@ fn apply_sync_credentials_root( messagesThinking: value("ark.messages_thinking"), maxTokens: value("ark.max_tokens"), thinkingBudget: value("ark.thinking_budget"), + serviceTier: value("ark.service_tier"), }, ); if channel.active { @@ -4273,7 +4280,7 @@ mod tests { 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"]; + let values = ["messages", "budget", "8192", "2048", "fast"]; for (account, value) in CONFIG_ACCOUNTS.into_iter().zip(values) { *entry.protocol_option(account).unwrap() = Some(value.into()); } @@ -5645,6 +5652,7 @@ mod encrypted_sync_tests { messagesThinking: Some("adaptive".into()), maxTokens: Some("4096".into()), thinkingBudget: None, + serviceTier: Some("fast".into()), }, ); root.omni.active = "custom".into(); diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index ed9c03e2e..7b5b4f6c3 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1794,6 +1794,9 @@ export const de: typeof zhCN = { thinkingBudget: 'Festes Budget', maxTokensLabel: 'Maximale Ausgabetokens', thinkingBudgetLabel: 'Tokenbudget für das Denken', + serviceTierLabel: 'Ark-Inferenz mit niedriger Latenz (fast)', + serviceTierHint: + 'Nur unterstützte Ark-Modelle können diese Option verwenden; andere Modelle geben möglicherweise 400 zurück.', responsesThinkingHint: 'Bei einigen Modellen lässt sich das Denken nur reduzieren, nicht abschalten. Anfragen mit Denken senden keinen Temperaturparameter.', messagesThinkingHint: @@ -1803,6 +1806,8 @@ export const de: typeof zhCN = { llmTokenLimitInvalid: 'Tokenlimits müssen positive ganze Zahlen sein.', llmThinkingBudgetInvalid: 'Das Denkbudget muss mindestens 1024 betragen und im festen Modus unter der Ausgabegrenze liegen.', + llmServiceTierInvalid: + 'Ungültige Option für Inferenz mit niedriger Latenz. Wähle sie erneut.', llmResponseIncomplete: 'Die Antwort ist unvollständig oder hat die Ausgabegrenze erreicht. Bereits ausgegebener Text bleibt erhalten.', llmProtocolHeaderConflict: diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 970ab81bc..5f2f32308 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1762,6 +1762,9 @@ export const en: typeof zhCN = { thinkingBudget: 'Fixed budget', maxTokensLabel: 'Maximum output tokens', thinkingBudgetLabel: 'Thinking budget tokens', + serviceTierLabel: 'Ark low-latency inference (fast)', + serviceTierHint: + 'Only supported Ark models can use this option; unsupported models may return 400.', responsesThinkingHint: 'Some models can only reduce thinking, not turn it off. Reasoning requests omit temperature.', messagesThinkingHint: @@ -1771,6 +1774,7 @@ export const en: typeof zhCN = { llmTokenLimitInvalid: 'Token limits must be positive integers.', llmThinkingBudgetInvalid: 'Thinking budget must be at least 1024 and below the output limit in fixed-budget mode.', + llmServiceTierInvalid: 'Invalid low-latency inference option. Select it again.', llmResponseIncomplete: 'The response was incomplete or reached its output limit. Already emitted text is retained.', llmProtocolHeaderConflict: diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index c3ecec806..a73064033 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1783,6 +1783,9 @@ export const es: typeof zhCN = { thinkingBudget: 'Presupuesto fijo', maxTokensLabel: 'Máximo de tokens de salida', thinkingBudgetLabel: 'Presupuesto de tokens de razonamiento', + serviceTierLabel: 'Inferencia de baja latencia de Ark (fast)', + serviceTierHint: + 'Solo los modelos Ark compatibles pueden usar esta opción; otros pueden devolver 400.', responsesThinkingHint: 'Algunos modelos solo permiten reducir el razonamiento, no desactivarlo. Las solicitudes de razonamiento omiten la temperatura.', messagesThinkingHint: @@ -1792,6 +1795,8 @@ export const es: typeof zhCN = { llmTokenLimitInvalid: 'Los límites de tokens deben ser números enteros positivos.', llmThinkingBudgetInvalid: 'El presupuesto de razonamiento debe ser al menos 1024 y, en modo fijo, inferior al límite de salida.', + llmServiceTierInvalid: + 'Opción de inferencia de baja latencia no válida. Selecciónala de nuevo.', llmResponseIncomplete: 'La respuesta no se completó o alcanzó el límite de salida. Se conserva el texto ya mostrado.', llmProtocolHeaderConflict: diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index c9b2bc894..aabf00a1a 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1810,6 +1810,9 @@ export const fr: typeof zhCN = { thinkingBudget: 'Budget fixe', maxTokensLabel: 'Nombre maximal de jetons en sortie', thinkingBudgetLabel: 'Budget de jetons de raisonnement', + serviceTierLabel: 'Inférence Ark à faible latence (fast)', + serviceTierHint: + 'Seuls les modèles Ark compatibles peuvent utiliser cette option ; les autres peuvent renvoyer 400.', responsesThinkingHint: 'Certains modèles permettent seulement de réduire le raisonnement, pas de le désactiver. Les requêtes de raisonnement omettent la température.', messagesThinkingHint: @@ -1821,6 +1824,8 @@ export const fr: typeof zhCN = { llmTokenLimitInvalid: 'Les limites de jetons doivent être des entiers positifs.', llmThinkingBudgetInvalid: 'Le budget de raisonnement doit être au moins de 1024 et, en mode fixe, inférieur à la limite de sortie.', + llmServiceTierInvalid: + 'Option d’inférence à faible latence non valide. Sélectionnez-la à nouveau.', llmResponseIncomplete: 'La réponse est incomplète ou a atteint la limite de sortie. Le texte déjà affiché est conservé.', llmProtocolHeaderConflict: diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 64e9e0363..21a2a58a7 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1749,6 +1749,9 @@ export const ja: typeof zhCN = { thinkingBudget: '固定予算', maxTokensLabel: '最大出力トークン数', thinkingBudgetLabel: '思考トークン予算', + serviceTierLabel: 'Ark 低レイテンシ推論(fast)', + serviceTierHint: + '対応する Ark モデルでのみ利用できます。非対応モデルでは 400 が返る場合があります。', responsesThinkingHint: '一部のモデルでは思考を軽減できますが、完全には無効にできません。推論リクエストでは温度を送信しません。', messagesThinkingHint: @@ -1757,6 +1760,7 @@ export const ja: typeof zhCN = { llmThinkingModeInvalid: '思考方式が無効です。選択し直してください。', llmTokenLimitInvalid: 'トークン上限は正の整数にしてください。', llmThinkingBudgetInvalid: '思考予算は1024以上、固定予算では最大出力未満にしてください。', + llmServiceTierInvalid: '低レイテンシ推論の設定が無効です。もう一度選択してください。', llmResponseIncomplete: '応答が未完了か出力上限に達しました。出力済みテキストは保持されます。', llmProtocolHeaderConflict: 'Messages の認証とバージョンヘッダーは自動設定されます。追加ヘッダーから x-api-key と anthropic-version を削除してください。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 371cc7e2d..7a1c3310c 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1736,6 +1736,9 @@ export const ko: typeof zhCN = { thinkingBudget: '고정 예산', maxTokensLabel: '최대 출력 토큰', thinkingBudgetLabel: '사고 토큰 예산', + serviceTierLabel: 'Ark 저지연 추론(fast)', + serviceTierHint: + '지원되는 Ark 모델에서만 사용할 수 있으며, 지원되지 않는 모델은 400을 반환할 수 있습니다.', responsesThinkingHint: '일부 모델은 사고를 줄일 수만 있으며 완전히 끌 수 없습니다. 추론 요청에는 온도를 보내지 않습니다.', messagesThinkingHint: @@ -1745,6 +1748,7 @@ export const ko: typeof zhCN = { llmTokenLimitInvalid: '토큰 한도는 양의 정수여야 합니다.', llmThinkingBudgetInvalid: '사고 예산은 1024 이상이며 고정 예산 모드에서는 출력 한도보다 작아야 합니다.', + llmServiceTierInvalid: '저지연 추론 옵션이 잘못되었습니다. 다시 선택하세요.', llmResponseIncomplete: '응답이 완료되지 않았거나 출력 한도에 도달했습니다. 이미 출력된 텍스트는 유지됩니다.', llmProtocolHeaderConflict: diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index ee582d872..46e92b37f 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1674,6 +1674,8 @@ export const zhCN = { thinkingBudget: '固定预算', maxTokensLabel: '最大输出 tokens', thinkingBudgetLabel: '思考预算 tokens', + serviceTierLabel: '方舟低延迟推理(fast)', + serviceTierHint: '仅适用于支持低延迟的方舟模型;不支持的模型可能返回 400。', responsesThinkingHint: '部分模型只能降低思考,不能完全关闭。推理请求不发送温度参数。', messagesThinkingHint: '旧模型或兼容网关可能需要固定预算;思考预算必须小于最大输出。开启思考时不发送温度参数。', @@ -1681,6 +1683,7 @@ export const zhCN = { llmThinkingModeInvalid: '思考方式无效,请重新选择。', llmTokenLimitInvalid: 'Token 上限必须为正整数。', llmThinkingBudgetInvalid: '思考预算至少为 1024,且固定预算必须小于最大输出。', + llmServiceTierInvalid: '低延迟推理选项无效,请重新选择。', llmResponseIncomplete: '响应未完整结束或达到输出上限;已输出正文会保留。', llmProtocolHeaderConflict: 'Messages 已自动设置鉴权和版本请求头,请移除额外 Headers 中的 x-api-key 和 anthropic-version。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 5b7cc7b3e..77fce38cd 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1675,6 +1675,8 @@ export const zhTW: typeof zhCN = { thinkingBudget: '固定預算', maxTokensLabel: '最大輸出 tokens', thinkingBudgetLabel: '思考預算 tokens', + serviceTierLabel: '方舟低延遲推理(fast)', + serviceTierHint: '僅適用於支援低延遲的方舟模型;不支援的模型可能回傳 400。', responsesThinkingHint: '部分模型只能降低思考,無法完全關閉。推理請求不傳送溫度引數。', messagesThinkingHint: '舊模型或相容閘道可能需要固定預算;思考預算必須小於最大輸出。開啟思考時不傳送溫度引數。', @@ -1682,6 +1684,7 @@ export const zhTW: typeof zhCN = { llmThinkingModeInvalid: '思考方式無效,請重新選擇。', llmTokenLimitInvalid: 'Token 上限必須為正整數。', llmThinkingBudgetInvalid: '思考預算至少為 1024,且固定預算必須小於最大輸出。', + llmServiceTierInvalid: '低延遲推理選項無效,請重新選擇。', llmResponseIncomplete: '回應未完整結束或達到輸出上限;已輸出正文會保留。', llmProtocolHeaderConflict: 'Messages 已自動設定驗證與版本標頭,請移除額外 Headers 中的 x-api-key 與 anthropic-version。', diff --git a/openless-all/app/src/lib/ipc/channels.ts b/openless-all/app/src/lib/ipc/channels.ts index 64aee51af..858fcea26 100644 --- a/openless-all/app/src/lib/ipc/channels.ts +++ b/openless-all/app/src/lib/ipc/channels.ts @@ -132,7 +132,10 @@ export function setChannelProviderType( if (channel && channel.providerType !== providerType) { channel.providerType = providerType; channel.lastTest = null; - if (kind === 'llm') mockCredentialValues.delete(`${id}:ark.request_format`); + if (kind === 'llm') { + mockCredentialValues.delete(`${id}:ark.request_format`); + mockCredentialValues.delete(`${id}:ark.service_tier`); + } } }); } diff --git a/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts b/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts index 3a913a35f..536bfa43f 100644 --- a/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts +++ b/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts @@ -21,6 +21,7 @@ const values: ProtocolValues = { 'ark.messages_thinking': '', 'ark.max_tokens': '', 'ark.thinking_budget': '', + 'ark.service_tier': '', }; assert( protocolValidationError(values, 'messages') === null, @@ -31,6 +32,11 @@ assert( 'llmRequestFormatInvalid', 'Unknown formats must not silently fall back', ); +assert( + protocolValidationError({ ...values, 'ark.service_tier': 'turbo' }, 'messages') === + 'llmServiceTierInvalid', + 'Unknown Ark service tiers must be rejected', +); assert( protocolValidationError({ ...values, 'ark.max_tokens': '0' }, 'messages') === 'llmTokenLimitInvalid', @@ -149,11 +155,21 @@ assert( (await listChannels('llm')).find((c) => c.id === first)?.lastTest === null, 'Credential mutation invalidates old validation', ); +await setChannelProviderType('llm', first, 'ark'); +await setCredential('ark.service_tier', 'fast', first); +assert( + (await readCredential('ark.service_tier', first)) === 'fast', + 'ARK low-latency option must survive reload', +); await setChannelProviderType('llm', first, 'custom_responses'); assert( (await readCredential('ark.request_format', first)) === null, 'Changing preset resets the format override', ); +assert( + (await readCredential('ark.service_tier', first)) === null, + 'Changing preset clears the ARK-only low-latency option', +); assert( (await readCredential('ark.api_key', first)) === 'fixture-key', 'Changing preset preserves the key', diff --git a/openless-all/app/src/pages/settings/LlmProtocolFields.tsx b/openless-all/app/src/pages/settings/LlmProtocolFields.tsx index a7a418f6d..7bafc16bc 100644 --- a/openless-all/app/src/pages/settings/LlmProtocolFields.tsx +++ b/openless-all/app/src/pages/settings/LlmProtocolFields.tsx @@ -5,22 +5,24 @@ import { readCredential, setCredential } from '../../lib/ipc'; import type { LlmRequestFormat } from '../../lib/ipc/providers'; import { emitSaved } from '../../lib/savedEvent'; import { Btn } from '../_atoms'; -import { SettingRow, inputStyle } from './shared'; +import { SettingRow, Toggle, inputStyle } from './shared'; import { ProviderFormContext } from './ProviderForm'; -const accounts = [ +const protocolAccounts = [ 'ark.request_format', 'ark.messages_thinking', 'ark.max_tokens', 'ark.thinking_budget', ] as const; -type Account = (typeof accounts)[number]; +const serviceTierAccount = 'ark.service_tier' as const; +type Account = (typeof protocolAccounts)[number] | typeof serviceTierAccount; export type ProtocolValues = Record; const emptyValues: ProtocolValues = { 'ark.request_format': '', 'ark.messages_thinking': '', 'ark.max_tokens': '', 'ark.thinking_budget': '', + 'ark.service_tier': '', }; /** Immediate UI hint; Core still validates the same rules for what is stored and sent. */ @@ -29,6 +31,9 @@ export function protocolValidationError( defaultFormat: LlmRequestFormat, ): string | null { const format = values['ark.request_format'] || defaultFormat; + if (values['ark.service_tier'] && values['ark.service_tier'] !== 'fast') { + return 'llmServiceTierInvalid'; + } if (!['chat_completions', 'responses', 'messages'].includes(format)) return 'llmRequestFormatInvalid'; const mode = values['ark.messages_thinking'] || 'adaptive'; @@ -51,6 +56,7 @@ export function protocolValidationError( export function LlmProtocolFields({ channelId, + providerType, defaultFormat, formats, onUserMutation, @@ -58,6 +64,7 @@ export function LlmProtocolFields({ onSaved, }: { channelId: string; + providerType: string; defaultFormat: LlmRequestFormat; formats: LlmRequestFormat[]; onUserMutation: () => void; @@ -71,6 +78,8 @@ export function LlmProtocolFields({ const [loaded, setLoaded] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState<'read' | 'save' | null>(null); + const arkServiceTier = providerType === 'ark'; + const accounts = arkServiceTier ? [...protocolAccounts, serviceTierAccount] : protocolAccounts; const mounted = useRef(true); const writing = useRef(false); const pendingWrite = useRef>(Promise.resolve(true)); @@ -83,9 +92,10 @@ export function LlmProtocolFields({ 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; + const next = { + ...emptyValues, + ...Object.fromEntries(accounts.map((account, index) => [account, result[index] ?? ''])), + } as ProtocolValues; setValues(next); setSaved(next); setLoaded(true); @@ -96,7 +106,7 @@ export function LlmProtocolFields({ return () => { mounted.current = false; }; - }, [channelId]); + }, [channelId, providerType]); useEffect(() => { onBlockedChange( @@ -192,6 +202,19 @@ export function LlmProtocolFields({ {t('settings.providers.responsesThinkingHint')}

)} + {arkServiceTier && ( + + change('ark.service_tier', enabled ? 'fast' : '', true)} + /> + + )} {format === 'messages' && ( <> diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index 15b589c60..6e893b382 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -319,6 +319,7 @@ export function ChannelCredentialFields({ {!!descriptor.supportedRequestFormats?.length && descriptor.defaultRequestFormat && (