From 7a6f8aa5d8350b0294e1f9418767109b4d941d31 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:26:05 -0700 Subject: [PATCH] feat(openless-all): add OpenAI Fast mode on the polish channel OpenAI channels gain a per-channel Fast mode switch. With it on, Chat Completions and Responses bodies include a top-level service_tier of fast beside the existing model and reasoning fields; an off switch, the Messages format, and custom, opencode, and openai-compatible providers leave the field out, while Ark still sends fast on every format. Desktop and Linux settings use OpenAI-specific copy that states the higher price and links to the official pricing page. Blocking and streaming diagnostics record the tier sent and the service_tier on the response, or absent when none is returned; leaving the OpenAI provider clears the saved value, and an unsupported tier still surfaces as the provider's invalid response. Fixes #1165 --- .../crates/openless-core/src/llm_protocol.rs | 119 +++++++- .../app/crates/openless-core/src/polish.rs | 259 +++++++++++++++++- openless-all/app/linux-egui/src/i18n.rs | 20 ++ openless-all/app/linux-egui/src/main.rs | 4 +- .../linux-egui/src/ui/frontend/settings.rs | 17 +- openless-all/app/src/i18n/de.ts | 3 + openless-all/app/src/i18n/en.ts | 3 + openless-all/app/src/i18n/es.ts | 3 + openless-all/app/src/i18n/fr.ts | 3 + openless-all/app/src/i18n/ja.ts | 3 + openless-all/app/src/i18n/ko.ts | 3 + openless-all/app/src/i18n/zh-CN.ts | 3 + openless-all/app/src/i18n/zh-TW.ts | 3 + .../pages/settings/LlmProtocolFields.test.ts | 12 + .../src/pages/settings/LlmProtocolFields.tsx | 21 +- 15 files changed, 458 insertions(+), 18 deletions(-) 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 41d6ad20a..853c4dfdc 100644 --- a/openless-all/app/crates/openless-core/src/llm_protocol.rs +++ b/openless-all/app/crates/openless-core/src/llm_protocol.rs @@ -228,13 +228,42 @@ impl LlmProtocolConfig { Ok(()) } + /// Ark accepts Fast on every format. Official OpenAI accepts it only on + /// Chat Completions and Responses; Messages and every other provider omit it. pub(crate) fn apply_service_tier(&self, provider_id: &str, body: &mut Value) { - if provider_id.trim() == "ark" && self.service_tier == LlmServiceTier::Fast { + if self.service_tier != LlmServiceTier::Fast { + return; + } + let provider_id = provider_id.trim(); + let allowed = provider_id == "ark" + || (provider_id == "openai" + && matches!( + self.format, + LlmRequestFormat::ChatCompletions | LlmRequestFormat::Responses + )); + if allowed { body["service_tier"] = json!("fast"); } } } +/// Last non-empty tier from an SSE object: top-level `service_tier`, else `response.service_tier`. +pub(crate) fn observed_response_service_tier(value: &Value) -> Option<&str> { + non_empty_tier(value.get("service_tier")).or_else(|| { + non_empty_tier( + value + .get("response") + .and_then(|response| response.get("service_tier")), + ) + }) +} + +fn non_empty_tier(value: Option<&Value>) -> Option<&str> { + value + .and_then(Value::as_str) + .filter(|tier| !tier.is_empty()) +} + fn positive_tokens(value: &str) -> Result { value .parse::() @@ -410,6 +439,8 @@ pub(crate) struct TextEventStream { pub done: bool, siwc: bool, request_id: Option, + /// Last non-empty `service_tier` seen on a parsed SSE object. + pub(crate) response_service_tier: Option, } impl TextEventStream { @@ -422,6 +453,7 @@ impl TextEventStream { done: false, siwc: false, request_id: None, + response_service_tier: None, } } @@ -463,6 +495,9 @@ impl TextEventStream { } Err(_) => return Err(response_error("invalid LLM SSE JSON")), }; + if let Some(tier) = observed_response_service_tier(&value) { + self.response_service_tier = Some(tier.to_string()); + } let kind = value["type"] .as_str() .or_else(|| { @@ -789,7 +824,15 @@ 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()); + assert!(protocol.apply(SERVICE_TIER_ACCOUNT, "").is_ok()); + assert_eq!(protocol.service_tier, LlmServiceTier::Off); + assert_eq!( + protocol + .apply(SERVICE_TIER_ACCOUNT, "invalid") + .unwrap_err() + .message, + "llmServiceTierInvalid" + ); protocol.apply(SERVICE_TIER_ACCOUNT, "fast").unwrap(); assert_eq!(protocol.service_tier, LlmServiceTier::Fast); @@ -812,8 +855,78 @@ mod tests { config.provider_id = "openai".into(); let body = request_body(&config, false, messages.clone()); - assert!(body.get("service_tier").is_none()); + if format == LlmRequestFormat::Responses { + assert_eq!(body["service_tier"], "fast"); + } else { + assert!(body.get("service_tier").is_none()); + } + + for provider in ["custom", "opencode", "openai-compatible"] { + config.provider_id = provider.into(); + let body = request_body(&config, false, messages.clone()); + assert!( + body.get("service_tier").is_none(), + "{provider} {format:?} must not send service_tier" + ); + } } + + let config = OpenAICompatibleConfig::new( + "openai", + "test", + "https://api.openai.com/v1", + "key", + "gpt-5.4", + ) + .with_protocol(LlmProtocolConfig { + format: LlmRequestFormat::Responses, + service_tier: LlmServiceTier::Off, + ..Default::default() + }); + assert!(request_body(&config, false, messages) + .get("service_tier") + .is_none()); + } + + #[tokio::test] + async fn fast_service_tier_loads_and_unknown_tier_is_rejected() { + let store = InMemoryCredentialStore::default(); + let key = CredentialKey::new( + CredentialNamespace::Llm, + Some("openai-channel".into()), + SERVICE_TIER_ACCOUNT, + ) + .unwrap(); + store + .write(key.clone(), SecretValue::new("fast")) + .await + .unwrap(); + assert_eq!( + LlmProtocolConfig::load(&store, "openai-channel", "openai") + .await + .unwrap() + .service_tier, + LlmServiceTier::Fast + ); + store + .write(key.clone(), SecretValue::new("turbo")) + .await + .unwrap(); + assert_eq!( + LlmProtocolConfig::load(&store, "openai-channel", "openai") + .await + .unwrap_err() + .message, + "llmServiceTierInvalid" + ); + store.write(key, SecretValue::new("")).await.unwrap(); + assert_eq!( + LlmProtocolConfig::load(&store, "openai-channel", "openai") + .await + .unwrap() + .service_tier, + LlmServiceTier::Off + ); } #[test] diff --git a/openless-all/app/crates/openless-core/src/polish.rs b/openless-all/app/crates/openless-core/src/polish.rs index 4ff06a1f5..427c1d377 100644 --- a/openless-all/app/crates/openless-core/src/polish.rs +++ b/openless-all/app/crates/openless-core/src/polish.rs @@ -856,6 +856,18 @@ impl OpenAICompatibleLLMProvider { body } + /// `fast` only when this request body actually carries the field. + fn requested_service_tier_label(body: &Value) -> &'static str { + if body + .get("service_tier") + .is_some_and(|value| !value.is_null()) + { + "fast" + } else { + "off" + } + } + /// Shared HTTP send + body parsing. chat_completion / chat_completion_with_polish_history /// both call here after building their body, avoiding a 30-line send/parse duplication. /// `budget` is the total budget for this call, chosen by the caller: polish scales @@ -947,8 +959,14 @@ impl OpenAICompatibleLLMProvider { let extracted = crate::llm_protocol::extract_text_raw(self.config.protocol.format, &body_text)?; let cleaned = clean_polish_output(&extracted); + let response_tier = serde_json::from_str::(&body_text) + .ok() + .and_then(|value| { + crate::llm_protocol::observed_response_service_tier(&value).map(str::to_string) + }) + .unwrap_or_else(|| "absent".to_string()); log::info!( - "[llm] request_id={} response provider={} model={} protocol={:?} extracted_chars={} extracted_hash={} cleaned_chars={} cleaned_hash={} extracted_near_duplicates={} cleaned_near_duplicates={}", + "[llm] request_id={} response provider={} model={} protocol={:?} extracted_chars={} extracted_hash={} cleaned_chars={} cleaned_hash={} extracted_near_duplicates={} cleaned_near_duplicates={} requested_service_tier={} response_service_tier={}", request_id, self.config.provider_id, self.config.model, @@ -959,6 +977,8 @@ impl OpenAICompatibleLLMProvider { privacy_text_hash(&cleaned), find_adjacent_near_duplicates(&extracted).len(), find_adjacent_near_duplicates(&cleaned).len(), + Self::requested_service_tier_label(body), + response_tier, ); Ok(cleaned) } @@ -1129,8 +1149,13 @@ impl OpenAICompatibleLLMProvider { events.finish()?; } let cleaned = clean_polish_output(&full_text); + let response_tier = events + .response_service_tier + .as_deref() + .filter(|tier| !tier.is_empty()) + .unwrap_or("absent"); log::info!( - "[llm] stream done request_id={} cancelled={} delta_count={} extracted_chars={} extracted_hash={} cleaned_chars={} cleaned_hash={} extracted_near_duplicates={} cleaned_near_duplicates={}", + "[llm] stream done request_id={} cancelled={} delta_count={} extracted_chars={} extracted_hash={} cleaned_chars={} cleaned_hash={} extracted_near_duplicates={} cleaned_near_duplicates={} requested_service_tier={} response_service_tier={}", request_id, cancelled, delta_count, @@ -1140,6 +1165,8 @@ impl OpenAICompatibleLLMProvider { privacy_text_hash(&cleaned), find_adjacent_near_duplicates(&full_text).len(), find_adjacent_near_duplicates(&cleaned).len(), + Self::requested_service_tier_label(&body), + response_tier, ); if full_text.is_empty() { return Err(LLMError::InvalidResponse { @@ -3559,6 +3586,234 @@ mod tests { assert_eq!(body["service_tier"], "fast"); } + #[test] + fn openai_chat_body_includes_fast_service_tier_beside_reasoning_fields() { + for (tier, fast) in [ + (crate::llm_protocol::LlmServiceTier::Fast, true), + (crate::llm_protocol::LlmServiceTier::Off, false), + ] { + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new( + "openai", + "OpenAI", + "https://api.openai.com/v1", + "k", + "gpt-5.4", + ) + .with_thinking_enabled(true) + .with_protocol(LlmProtocolConfig { + service_tier: tier, + ..Default::default() + }), + ); + let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]); + assert_eq!(body["model"], "gpt-5.4"); + assert_eq!(body["messages"][0]["content"], "hi"); + assert_eq!(body["reasoning_effort"], "medium"); + if fast { + assert_eq!(body["service_tier"], "fast"); + } else { + assert!(body.get("service_tier").is_none()); + } + } + } + + #[tokio::test] + async fn service_tier_diagnostics_follow_the_request_and_response() { + crate::testing::logging::start(); + for (tier, response_has_tier, requested, returned) in [ + ( + crate::llm_protocol::LlmServiceTier::Fast, + true, + "requested_service_tier=fast", + "response_service_tier=fast", + ), + ( + crate::llm_protocol::LlmServiceTier::Off, + false, + "requested_service_tier=off", + "response_service_tier=absent", + ), + ] { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + read_http_request(&mut stream); + let body = if response_has_tier { + r#"{"service_tier":"fast","choices":[{"message":{"content":"ok"}}]}"# + } else { + r#"{"choices":[{"message":{"content":"ok"}}]}"# + }; + let _ = write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ); + }); + let session_id = crate::types::SessionId::new(); + let mut provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new( + "openai", + "OpenAI", + format!("http://{addr}"), + "k", + "gpt-5.4", + ) + .with_protocol(LlmProtocolConfig { + service_tier: tier, + ..Default::default() + }), + ); + provider.set_dictation_session(session_id); + let output = provider + .chat_completion("sys", "user", std::time::Duration::from_secs(30)) + .await + .unwrap(); + assert_eq!(output, "ok"); + server.join().unwrap(); + let line = response_log_line(session_id); + assert!(line.contains(requested), "{line}"); + assert!(line.contains(returned), "{line}"); + } + } + + #[tokio::test] + async fn stream_done_logs_service_tier_literal_or_absent() { + crate::testing::logging::start(); + let cases = [ + ( + LlmRequestFormat::ChatCompletions, + crate::llm_protocol::LlmServiceTier::Fast, + "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}],\"service_tier\":\"fast\"}\n\ndata: [DONE]\n\n", + "requested_service_tier=fast", + "response_service_tier=fast", + ), + ( + LlmRequestFormat::Responses, + crate::llm_protocol::LlmServiceTier::Fast, + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\",\"service_tier\":\"fast\"}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"service_tier\":\"priority\"}}\n\n", + "requested_service_tier=fast", + "response_service_tier=priority", + ), + ( + LlmRequestFormat::ChatCompletions, + crate::llm_protocol::LlmServiceTier::Off, + "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n", + "requested_service_tier=off", + "response_service_tier=absent", + ), + ]; + for (format, tier, sse, requested, returned) in cases { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let payload = sse.to_string(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + read_http_request(&mut stream); + write_chunked_sse_response(&mut stream, &[payload.as_bytes()]); + }); + let session_id = crate::types::SessionId::new(); + let mut provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new( + "openai", + "OpenAI", + format!("http://{addr}"), + "k", + "gpt-5.4", + ) + .with_protocol(LlmProtocolConfig { + format, + service_tier: tier, + ..Default::default() + }), + ); + provider.set_dictation_session(session_id); + let output = provider + .chat_completion_messages_streaming( + test_messages(), + StreamingTimeouts::for_input(1), + |_| {}, + || false, + ) + .await + .unwrap(); + assert_eq!(output, "ok"); + server.join().unwrap(); + let line = stream_done_log_line(session_id); + assert!(line.contains(requested), "{line}"); + assert!(line.contains(returned), "{line}"); + } + } + + #[tokio::test] + async fn unsupported_service_tier_stays_invalid_response() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + read_http_request(&mut stream); + let body = r#"{"error":{"message":"unsupported service_tier","param":"service_tier"}}"#; + let _ = write!( + stream, + "HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ); + }); + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new( + "openai", + "OpenAI", + format!("http://{addr}"), + "k", + "gpt-5.4", + ) + .with_protocol(LlmProtocolConfig { + service_tier: crate::llm_protocol::LlmServiceTier::Fast, + ..Default::default() + }), + ); + let error = provider + .chat_completion("sys", "user", std::time::Duration::from_secs(30)) + .await + .unwrap_err(); + server.join().unwrap(); + let message = error.to_string(); + match error { + LLMError::InvalidResponse { status, body } => { + assert_eq!(status, 400); + assert!(body.contains("unsupported service_tier")); + assert!(message.starts_with("invalid response: status 400, body:")); + assert!(message.contains("service_tier")); + } + other => panic!("expected InvalidResponse, got {other:?}"), + } + } + + fn response_log_line(session_id: crate::types::SessionId) -> String { + let logs = crate::testing::logging::matching(&format!("session_id={session_id} ")); + let request_id = logs[0] + .split_whitespace() + .find(|field| field.starts_with("request_id=")) + .unwrap(); + crate::testing::logging::matching(request_id) + .into_iter() + .find(|line| line.contains(" response provider=")) + .expect("response diagnostic") + } + + fn stream_done_log_line(session_id: crate::types::SessionId) -> String { + let logs = crate::testing::logging::matching(&format!("session_id={session_id} ")); + let request_id = logs[0] + .split_whitespace() + .find(|field| field.starts_with("request_id=")) + .unwrap(); + crate::testing::logging::matching(request_id) + .into_iter() + .find(|line| line.contains("[llm] stream done")) + .expect("stream diagnostic") + } + #[test] fn openai_chat_body_maps_reasoning_effort_for_openai_reasoning_models() { for model in [ diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index 29bc7c752..d570af0f3 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -295,6 +295,26 @@ pub const CATALOG: &[Msg] = &[ "対応する Ark モデルでのみ利用できます。非対応モデルでは 400 が返る場合があります。", "지원되는 Ark 모델에서만 사용할 수 있으며, 지원되지 않는 모델은 400을 반환할 수 있습니다.", ), + }, + Msg { + key: "settings.providers.openaiServiceTierLabel", + text: row( + "OpenAI Fast 模式", + "OpenAI Fast 模式", + "OpenAI Fast mode", + "OpenAI Fast モード", + "OpenAI Fast 모드", + ), + }, + Msg { + key: "settings.providers.openaiServiceTierHint", + text: row( + "Fast 模式费用更高,详见 https://developers.openai.com/api/docs/pricing。不支持的模型或地区会返回错误。", + "Fast 模式費用更高,詳見 https://developers.openai.com/api/docs/pricing。不支援的模型或地區會回傳錯誤。", + "Fast mode costs more. See https://developers.openai.com/api/docs/pricing. Unsupported models or regions return an error.", + "Fast モードは料金が高くなります。https://developers.openai.com/api/docs/pricing を参照してください。未対応のモデルまたはリージョンではエラーが返ります。", + "Fast 모드는 요금이 더 높습니다. https://developers.openai.com/api/docs/pricing 를 참고하세요. 지원하지 않는 모델이나 지역은 오류를 반환합니다.", + ), }, Msg { key: "settings.providers.messagesThinkingLabel", diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 6daf7ac10..22b01b627 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -8548,7 +8548,7 @@ mod linux_app { .zip(&editor.protocol.values) { if *account == openless_core::llm_protocol::SERVICE_TIER_ACCOUNT - && editor.descriptor.provider_type.as_str() != "ark" + && !matches!(editor.descriptor.provider_type.as_str(), "ark" | "openai") { continue; } @@ -8743,7 +8743,7 @@ mod linux_app { .enumerate() { if *account == openless_core::llm_protocol::SERVICE_TIER_ACCOUNT - && descriptor.provider_type.as_str() != "ark" + && !matches!(descriptor.provider_type.as_str(), "ark" | "openai") { continue; } diff --git a/openless-all/app/linux-egui/src/ui/frontend/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs index 5dcd97f23..6def1afad 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/settings.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -5012,12 +5012,23 @@ fn provider_protocol_block( .color(theme::INK_4), ); } - if editor.provider_type == "ark" { + let service_tier_copy = match editor.provider_type.as_str() { + "ark" => Some(( + "settings.providers.serviceTierLabel", + "settings.providers.serviceTierHint", + )), + "openai" => Some(( + "settings.providers.openaiServiceTierLabel", + "settings.providers.openaiServiceTierHint", + )), + _ => None, + }; + if let Some((label_key, hint_key)) = service_tier_copy { let mut changed = false; toggle_row( ui, - tr_l10n(lang, "settings.providers.serviceTierLabel"), - tr_l10n(lang, "settings.providers.serviceTierHint"), + tr_l10n(lang, label_key), + tr_l10n(lang, hint_key), draft.values[4] == "fast", || changed = true, ); diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index f78c03616..3f37fdbe4 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1953,6 +1953,9 @@ export const de: typeof zhCN = { 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.', + openaiServiceTierLabel: 'OpenAI Fast-Modus', + openaiServiceTierHint: + 'Der Fast-Modus kostet mehr. Siehe https://developers.openai.com/api/docs/pricing. Nicht unterstützte Modelle oder Regionen geben einen Fehler zurück.', responsesThinkingHint: 'Bei einigen Modellen lässt sich das Denken nur reduzieren, nicht abschalten. Anfragen mit Denken senden keinen Temperaturparameter.', messagesThinkingHint: diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index dff65f894..5ffd542f8 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1918,6 +1918,9 @@ export const en: typeof zhCN = { serviceTierLabel: 'Ark low-latency inference (fast)', serviceTierHint: 'Only supported Ark models can use this option; unsupported models may return 400.', + openaiServiceTierLabel: 'OpenAI Fast mode', + openaiServiceTierHint: + 'Fast mode costs more. See https://developers.openai.com/api/docs/pricing. Unsupported models or regions return an error.', responsesThinkingHint: 'Some models can only reduce thinking, not turn it off. Reasoning requests omit temperature.', messagesThinkingHint: diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 9f5fa38e3..f3582c598 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1940,6 +1940,9 @@ export const es: typeof zhCN = { serviceTierLabel: 'Inferencia de baja latencia de Ark (fast)', serviceTierHint: 'Solo los modelos Ark compatibles pueden usar esta opción; otros pueden devolver 400.', + openaiServiceTierLabel: 'Modo Fast de OpenAI', + openaiServiceTierHint: + 'El modo Fast cuesta más. Consulta https://developers.openai.com/api/docs/pricing. Los modelos o regiones no compatibles devuelven un error.', responsesThinkingHint: 'Algunos modelos solo permiten reducir el razonamiento, no desactivarlo. Las solicitudes de razonamiento omiten la temperatura.', messagesThinkingHint: diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 432bfa669..f17bbc0ba 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1966,6 +1966,9 @@ export const fr: typeof zhCN = { serviceTierLabel: 'Inférence Ark à faible latence (fast)', serviceTierHint: 'Seuls les modèles Ark compatibles peuvent utiliser cette option ; les autres peuvent renvoyer 400.', + openaiServiceTierLabel: 'Mode Fast OpenAI', + openaiServiceTierHint: + 'Le mode Fast coûte plus cher. Voir https://developers.openai.com/api/docs/pricing. Les modèles ou régions non pris en charge renvoient une erreur.', 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: diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 62197d107..845987c67 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1905,6 +1905,9 @@ export const ja: typeof zhCN = { serviceTierLabel: 'Ark 低レイテンシ推論(fast)', serviceTierHint: '対応する Ark モデルでのみ利用できます。非対応モデルでは 400 が返る場合があります。', + openaiServiceTierLabel: 'OpenAI Fast モード', + openaiServiceTierHint: + 'Fast モードは料金が高くなります。https://developers.openai.com/api/docs/pricing を参照してください。未対応のモデルまたはリージョンではエラーが返ります。', responsesThinkingHint: '一部のモデルでは思考を軽減できますが、完全には無効にできません。推論リクエストでは温度を送信しません。', messagesThinkingHint: diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 37fa2265b..adf663c17 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1892,6 +1892,9 @@ export const ko: typeof zhCN = { serviceTierLabel: 'Ark 저지연 추론(fast)', serviceTierHint: '지원되는 Ark 모델에서만 사용할 수 있으며, 지원되지 않는 모델은 400을 반환할 수 있습니다.', + openaiServiceTierLabel: 'OpenAI Fast 모드', + openaiServiceTierHint: + 'Fast 모드는 요금이 더 높습니다. https://developers.openai.com/api/docs/pricing 를 참고하세요. 지원하지 않는 모델이나 지역은 오류를 반환합니다.', responsesThinkingHint: '일부 모델은 사고를 줄일 수만 있으며 완전히 끌 수 없습니다. 추론 요청에는 온도를 보내지 않습니다.', messagesThinkingHint: diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index ffe1cd040..0ad219d9e 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1819,6 +1819,9 @@ export const zhCN = { thinkingBudgetLabel: '思考预算 tokens', serviceTierLabel: '方舟低延迟推理(fast)', serviceTierHint: '仅适用于支持低延迟的方舟模型;不支持的模型可能返回 400。', + openaiServiceTierLabel: 'OpenAI Fast 模式', + openaiServiceTierHint: + 'Fast 模式费用更高,详见 https://developers.openai.com/api/docs/pricing。不支持的模型或地区会返回错误。', responsesThinkingHint: '部分模型只能降低思考,不能完全关闭。推理请求不发送温度参数。', messagesThinkingHint: '旧模型或兼容网关可能需要固定预算;思考预算必须小于最大输出。开启思考时不发送温度参数。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 388b17a85..e0e9e810a 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1819,6 +1819,9 @@ export const zhTW: typeof zhCN = { thinkingBudgetLabel: '思考預算 tokens', serviceTierLabel: '方舟低延遲推理(fast)', serviceTierHint: '僅適用於支援低延遲的方舟模型;不支援的模型可能回傳 400。', + openaiServiceTierLabel: 'OpenAI Fast 模式', + openaiServiceTierHint: + 'Fast 模式費用更高,詳見 https://developers.openai.com/api/docs/pricing。不支援的模型或地區會回傳錯誤。', responsesThinkingHint: '部分模型只能降低思考,無法完全關閉。推理請求不傳送溫度引數。', messagesThinkingHint: '舊模型或相容閘道可能需要固定預算;思考預算必須小於最大輸出。開啟思考時不傳送溫度引數。', diff --git a/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts b/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts index 536bfa43f..7ee127ba9 100644 --- a/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts +++ b/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts @@ -170,6 +170,18 @@ assert( (await readCredential('ark.service_tier', first)) === null, 'Changing preset clears the ARK-only low-latency option', ); +const openai = await createChannel('llm', 'openai', 'openai-fast'); +await setCredential('ark.service_tier', 'fast', openai); +assert( + (await readCredential('ark.service_tier', openai)) === 'fast', + 'OpenAI Fast mode must survive reload on that channel', +); +await setChannelProviderType('llm', openai, 'custom'); +assert( + (await readCredential('ark.service_tier', openai)) === null, + 'Switching an OpenAI channel to custom clears Fast mode', +); +await deleteChannel('llm', openai); 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 7bafc16bc..f86ec79d1 100644 --- a/openless-all/app/src/pages/settings/LlmProtocolFields.tsx +++ b/openless-all/app/src/pages/settings/LlmProtocolFields.tsx @@ -78,8 +78,16 @@ 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 showsServiceTier = providerType === 'ark' || providerType === 'openai'; + const serviceTierLabel = + providerType === 'openai' + ? 'settings.providers.openaiServiceTierLabel' + : 'settings.providers.serviceTierLabel'; + const serviceTierHint = + providerType === 'openai' + ? 'settings.providers.openaiServiceTierHint' + : 'settings.providers.serviceTierHint'; + const accounts = showsServiceTier ? [...protocolAccounts, serviceTierAccount] : protocolAccounts; const mounted = useRef(true); const writing = useRef(false); const pendingWrite = useRef>(Promise.resolve(true)); @@ -202,15 +210,12 @@ export function LlmProtocolFields({ {t('settings.providers.responsesThinkingHint')}

)} - {arkServiceTier && ( - + {showsServiceTier && ( + change('ark.service_tier', enabled ? 'fast' : '', true)} />