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 987f05781..f5aae2130 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, reported_tokens: crate::usage_ledger::ReportedTokens, } @@ -423,6 +454,7 @@ impl TextEventStream { done: false, siwc: false, request_id: None, + response_service_tier: None, reported_tokens: crate::usage_ledger::ReportedTokens::default(), } } @@ -469,6 +501,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()); + } if let Some(reported) = crate::usage_ledger::ReportedTokens::from_response(&value) { self.reported_tokens.merge_report(reported); } @@ -824,7 +859,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); @@ -847,8 +890,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 d193333bc..41fd27572 100644 --- a/openless-all/app/crates/openless-core/src/polish.rs +++ b/openless-all/app/crates/openless-core/src/polish.rs @@ -906,6 +906,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 @@ -1005,8 +1017,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, @@ -1017,6 +1035,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) } @@ -1188,8 +1208,13 @@ impl OpenAICompatibleLLMProvider { } self.report_usage(request_id, events.reported_tokens()); 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, @@ -1199,6 +1224,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 { @@ -3844,6 +3871,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 d196cf6c4..92f55b11b 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -370,6 +370,26 @@ pub const CATALOG: &[Msg] = &[ "지원되는 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", text: row( diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index ee4bf1a70..c5dca58a5 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -8282,7 +8282,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; } @@ -8477,7 +8477,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 78b7ec399..e94a8061a 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/settings.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -4945,12 +4945,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 4b21a369c..191cfa63d 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1854,6 +1854,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 34445f983..56fb9714d 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1819,6 +1819,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 d3ffe03d6..7a9e7fd58 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1840,6 +1840,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 f0d1004f0..22de81c3a 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1864,6 +1864,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 337ca4b29..c908d375c 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1804,6 +1804,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 cb4201439..7e57ef9db 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1794,6 +1794,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 cfb6f4f3b..84e1470ff 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1727,6 +1727,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 9ab9bed2c..8cac59316 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1728,6 +1728,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)} />