diff --git a/README.md b/README.md index 4298c5f9..be2cc935 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ Every item below is one more layer sedimented into a default — a capability yo - **Toggle and push-to-talk** recording modes, plus a **MediaPlayPause trigger** so wired-earbud inline controls can start and stop recording. `Esc` cancels at any phase, including polish and insertion. - **Cloud ASR**: Volcengine streaming ASR (bigasr), iFlytek realtime ASR (RTASR), Alibaba Cloud Bailian (classic realtime / Qwen3 realtime / Fun-ASR-Flash file transcription), StepFun StepAudio (batch + realtime), Zhipu GLM-ASR, Xiaomi MiMo ASR, ElevenLabs Scribe, OpenAI-compatible batch transcription (OpenAI Whisper / Groq / SiliconFlow SenseVoice / OpenRouter / ZenMux), and Apple Speech (macOS). - **Local ASR**: bundled Qwen3-ASR (0.6B / 1.7B) via vendored `Open-Less/qwen-asr` (macOS); Windows Foundry Local Whisper and sherpa-onnx (experimental) variants. -- **Polish providers**: Ark (Volcengine), DeepSeek, OpenAI, Google Gemini, Codex OAuth, SiliconFlow, Atlas Cloud, Xiaomi MiMo, CometAPI, OpenRouter, Alibaba Cloud Coding Plan, CodingPlanX, MiniMax, and StepFun — plus any OpenAI-compatible endpoint you bring. +- **Polish providers**: Ark (Volcengine), DeepSeek, OpenAI, Google Gemini, Codex OAuth, SiliconFlow, Atlas Cloud, Xiaomi MiMo, CometAPI, OpenRouter, Alibaba Cloud Coding Plan, CodingPlanX, MiniMax, StepFun, and OpenCode Zen — plus any OpenAI-compatible endpoint you bring. - **Four output modes**: raw, light polish, structured (**AI-prompt mode**), and formal. Plus a **translation hotkey** that converts speech directly into the configured target language ([#43](../../issues/43)). - **Selection-ask QA panel** — a separate hotkey opens a floating panel that runs voice Q&A against the highlighted text in any app ([#118](../../issues/118)). - **Main window**: Overview / History / Vocab / Style / Marketplace / Settings. Persistent tray icon, plus a mini status capsule that floats on screen and follows the display you are typing on (multi-monitor). diff --git a/README.zh.md b/README.zh.md index 14e93de1..052e317a 100644 --- a/README.zh.md +++ b/README.zh.md @@ -205,7 +205,7 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提 - **切换式与按住说话(push-to-talk)** 两种录音模式,外加 **MediaPlayPause 触发**,让有线耳机的线控也能开始 / 停止录音。`Esc` 可在任意阶段取消,包括润色与插入。 - **云端 ASR**:Volcengine 流式 ASR(bigasr)、讯飞实时语音转写(RTASR)、阿里云百炼(经典实时 / Qwen3 实时 / Fun-ASR-Flash 录音文件)、阶跃星辰 StepAudio(批式 + 实时)、智谱 GLM-ASR、小米 MiMo ASR、ElevenLabs Scribe、OpenAI 兼容批量转写(OpenAI Whisper / Groq / 硅基流动 SenseVoice / OpenRouter / ZenMux),以及 Apple Speech(macOS)。 - **本地 ASR**:通过 vendored 的 `Open-Less/qwen-asr` 内置 Qwen3-ASR(0.6B / 1.7B)(macOS);Windows 上的 Foundry Local Whisper 与 sherpa-onnx(实验性)变体。 -- **润色提供方**:Ark(火山方舟)、DeepSeek、OpenAI、Google Gemini、Codex OAuth、硅基流动、Atlas Cloud、小米 MiMo、CometAPI、OpenRouter、阿里云 Coding Plan、CodingPlanX、MiniMax、StepFun,以及你自带的任意 OpenAI 兼容端点。 +- **润色提供方**:Ark(火山方舟)、DeepSeek、OpenAI、Google Gemini、Codex OAuth、硅基流动、Atlas Cloud、小米 MiMo、CometAPI、OpenRouter、阿里云 Coding Plan、CodingPlanX、MiniMax、StepFun、OpenCode Zen,以及你自带的任意 OpenAI 兼容端点。 - **四种输出模式**:原文、轻度润色、结构化(**AI 提示词模式**)、正式。另有一个**翻译快捷键**,将语音直接转换为所配置的目标语言([#43](../../issues/43))。 - **选区问答面板**——一个独立快捷键打开浮动面板,针对任意应用中被高亮选中的文本进行语音问答([#118](../../issues/118))。 - **主窗口**:概览 / 历史 / 词典 / 风格 / 市场 / 设置。常驻托盘图标,以及一个浮于屏幕、并跟随你正在输入的显示器的迷你状态胶囊(多显示器)。 diff --git a/openless-all/app/crates/openless-core/src/cloud_providers.rs b/openless-all/app/crates/openless-core/src/cloud_providers.rs index e95d49ae..54113e64 100644 --- a/openless-all/app/crates/openless-core/src/cloud_providers.rs +++ b/openless-all/app/crates/openless-core/src/cloud_providers.rs @@ -76,6 +76,7 @@ pub const SHARED_CLOUD_LLM_PROVIDER_TYPES: &[&str] = &[ "codingPlanX", "minimax", "stepfun", + "opencode", "custom", "custom_responses", "custom_messages", diff --git a/openless-all/app/crates/openless-core/src/polish.rs b/openless-all/app/crates/openless-core/src/polish.rs index 694fefb7..23857658 100644 --- a/openless-all/app/crates/openless-core/src/polish.rs +++ b/openless-all/app/crates/openless-core/src/polish.rs @@ -187,6 +187,7 @@ fn is_builtin_llm_provider(provider_id: &str) -> bool { | "codingPlanX" | "minimax" | "stepfun" + | "opencode" ) } @@ -1649,8 +1650,21 @@ pub(crate) fn apply_openai_compatible_thinking_control( ) { // 优先按 provider_id 预设分派;custom / 未声明 provider 时回退到 base_url 兜底, // 让用户用"自定义"preset 接入 MiniMax 也能正确下发 thinking 控制参数。 - let control = openai_compatible_thinking_control(provider_id) - .or_else(|| openai_compatible_thinking_control_for_base_url(base_url)); + // Zen 是多模型网关,仅 DeepSeek 模型使用 DeepSeek 的思考参数。 + let is_opencode = provider_id.trim() == "opencode" + || (matches!(provider_id.trim(), "custom" | "custom_responses" | "custom_messages") + && url::Url::parse(base_url.trim()) + .ok() + .is_some_and(|url| url.host_str() == Some("opencode.ai"))); + let control = if is_opencode { + model + .trim() + .starts_with("deepseek-") + .then_some(ThinkingControl::DeepSeekThinking) + } else { + openai_compatible_thinking_control(provider_id) + .or_else(|| openai_compatible_thinking_control_for_base_url(base_url)) + }; match control { Some(ThinkingControl::ReasoningEffort) => { // OpenAI 官方 Chat Completions 只在推理模型族接受 reasoning_effort; @@ -2125,7 +2139,10 @@ mod tests { #[tokio::test] async fn all_text_entrypoints_use_the_selected_protocol_over_http() { - for format in LlmRequestFormat::ALL { + for (format, (preset, prefix)) in LlmRequestFormat::ALL.into_iter().flat_map(|format| { + [("custom", "/gateway/v1"), ("opencode", "/zen/v1"), ("opencode", "/zen/go/v1")] + .map(|entry| (format, entry)) + }) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let server = thread::spawn(move || { @@ -2140,7 +2157,7 @@ mod tests { LlmRequestFormat::Responses => "responses", LlmRequestFormat::Messages => "messages", }; - assert!(headers.starts_with(&format!("post /gateway/v1/{path}?tenant=1 "))); + assert!(headers.starts_with(&format!("post {prefix}/{path}?tenant=1 "))); if format == LlmRequestFormat::Messages { assert!(headers.contains("x-api-key: fixture-key")); assert!(headers.contains("anthropic-version: 2023-06-01")); @@ -2189,9 +2206,9 @@ mod tests { } }); let config = OpenAICompatibleConfig::new( - "custom", + preset, "test", - format!("http://{address}/gateway/v1/chat/completions?tenant=1"), + format!("http://{address}{prefix}/chat/completions?tenant=1"), "fixture-key", "test", ) @@ -2283,6 +2300,52 @@ mod tests { } } + #[test] + fn opencode_thinking_is_scoped_to_model_host_and_protocol() { + for (preset, endpoint, zen) in [ + ("opencode", "https://opencode.ai/zen/v1", true), + ("opencode", "https://gateway.example/v1", true), + ("custom", "https://opencode.ai/zen/v1", true), + ("custom_responses", "https://opencode.ai/zen/v1", true), + ("custom_messages", "https://opencode.ai/zen/v1", true), + ("custom", "https://OPENCODE.AI:443/zen/go/v1/chat/completions", true), + ("custom", "https://opencode.ai.example/zen/v1", false), + ("custom", "https://fakeopencode.ai/zen/v1", false), + ("custom", "https://opencode.ai@example.com/zen/v1", false), + ("custom", "https://example.com/opencode.ai", false), + ] { + for model in ["deepseek-v4-flash", "minimax-m3", "gateway-model"] { + for enabled in [false, true] { + for format in LlmRequestFormat::ALL { + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new(preset, "test", endpoint, "key", model) + .with_thinking_enabled(enabled) + .with_protocol(LlmProtocolConfig { format, ..Default::default() }), + ); + let body = provider.chat_body(false, vec![json!({"role":"user","content":"hi"})]); + match format { + LlmRequestFormat::ChatCompletions if zen && model.starts_with("deepseek-") => { + assert_eq!(body["thinking"]["type"], if enabled { "enabled" } else { "disabled" }); + } + LlmRequestFormat::Messages if enabled => { + assert_eq!(body["thinking"]["type"], "adaptive"); + } + _ => assert!(body.get("thinking").is_none(), "{preset} {endpoint} {model} {format:?}"), + } + assert!(body.get("reasoning_effort").is_none()); + assert!(body.get("enable_thinking").is_none()); + if format == LlmRequestFormat::Responses { + assert_eq!(body["reasoning"]["effort"], if enabled { "medium" } else { "low" }); + assert!(body.get("messages").is_none()); + } else { + assert!(body.get("reasoning").is_none()); + } + } + } + } + } + } + fn write_chunked_sse_response(stream: &mut std::net::TcpStream, chunks: &[&[u8]]) { stream .write_all( diff --git a/openless-all/app/crates/openless-core/src/provider_rules.rs b/openless-all/app/crates/openless-core/src/provider_rules.rs index 6ab43ab8..e79f9e20 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -62,6 +62,7 @@ const LLM_PROVIDER_TYPES: &[(&str, &str)] = &[ ("codingPlanX", "codingPlanX"), ("minimax", "minimax"), ("stepfun", "stepfun"), + ("opencode", "opencode"), ("custom", "customChatCompletions"), ("custom_responses", "customResponses"), ("custom_messages", "customMessages"), @@ -481,6 +482,7 @@ pub fn default_asr_model(provider_type: &str) -> Option<&'static str> { pub fn default_llm_endpoint(provider_type: &str) -> Option<&'static str> { match provider_type { + "opencode" => Some("https://opencode.ai/zen/v1"), "ark" => Some("https://ark.cn-beijing.volces.com/api/v3"), "deepseek" => Some("https://api.deepseek.com/v1"), "siliconflow" => Some("https://api.siliconflow.cn/v1"), @@ -501,7 +503,7 @@ pub fn default_llm_endpoint(provider_type: &str) -> Option<&'static str> { pub fn default_llm_model(provider_type: &str) -> Option<&'static str> { match provider_type { "ark" => Some("deepseek-v3-2"), - "deepseek" => Some("deepseek-v4-flash"), + "deepseek" | "opencode" => Some("deepseek-v4-flash"), "siliconflow" => Some("Qwen/Qwen2.5-7B-Instruct"), "atlascloud" => Some("qwen/qwen3.5-flash"), "openai" | "cometapi" => Some("gpt-4o"), @@ -989,6 +991,29 @@ mod tests { assert!(parse_extra_headers(r#"{"authorization":"secret"}"#).is_err()); } + #[test] + fn opencode_descriptor_supplies_defaults_formats_and_credentials() { + use crate::llm_protocol::LlmRequestFormat; + assert!(crate::cloud_providers::SHARED_CLOUD_LLM_PROVIDER_TYPES.contains(&"opencode")); + let descriptor = provider_descriptor(ProviderKind::Llm, "opencode").unwrap(); + assert_eq!(descriptor.label_key, "opencode"); + assert_eq!(descriptor.default_endpoint.as_deref(), Some("https://opencode.ai/zen/v1")); + assert_eq!(descriptor.default_model.as_deref(), Some("deepseek-v4-flash")); + assert_eq!(descriptor.default_request_format, Some(LlmRequestFormat::ChatCompletions)); + assert_eq!(descriptor.supported_request_formats, LlmRequestFormat::ALL); + assert_eq!(descriptor.validation_probe, ValidationProbe::LlmText); + assert!(api_key_required(ProviderKind::Llm, "opencode", Some("https://opencode.ai/zen/v1/chat/completions/"))); + let mut configuration = CredentialConfiguration { + llm_endpoint: true, + llm_endpoint_matches_default: true, + llm_model: true, + ..CredentialConfiguration::default() + }; + assert!(!llm_configured("opencode", &configuration)); + configuration.llm_api_key = true; + assert!(llm_configured("opencode", &configuration)); + } + #[test] fn descriptors_are_the_single_source_for_defaults_auth_and_probes() { let compatible = provider_descriptor(ProviderKind::Asr, "openai-compatible").unwrap(); diff --git a/openless-all/app/crates/openless-core/src/provider_service.rs b/openless-all/app/crates/openless-core/src/provider_service.rs index 1256553f..a4d1c2d4 100644 --- a/openless-all/app/crates/openless-core/src/provider_service.rs +++ b/openless-all/app/crates/openless-core/src/provider_service.rs @@ -892,6 +892,9 @@ mod tests { async fn validation_and_model_lists_use_channel_protocol_and_thinking() { use crate::llm_protocol::*; for (format, preset, sse, path) in [ + ("chat_completions", "opencode", "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n", "/v1/chat/completions"), + ("responses", "opencode", "data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\ndata: {\"type\":\"response.completed\"}\n\n", "/v1/responses"), + ("messages", "opencode", "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"), ("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"), ] { @@ -909,13 +912,13 @@ mod tests { 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 if format == "messages" && 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"), + let channel = create_channel_with_values(&credentials, ChannelKind::Llm, preset, &[ + (LLM_ENDPOINT_ACCOUNT, &format!("{endpoint}/{}", if format == "chat_completions" { "chat/completions" } else { 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)); diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 15b0101c..700a94ad 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1010,6 +1010,7 @@ export const en: typeof zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax (M3)', stepfun: 'StepFun', + opencode: 'OpenCode Zen', customChatCompletions: "Custom · Chat Completions", customResponses: "Custom · Responses", customMessages: "Custom · Messages", diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index b7ef1266..606c5b40 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1012,6 +1012,7 @@ export const ja: typeof zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax(M3)', stepfun: 'StepFun(階躍星辰)', + opencode: 'OpenCode Zen', customChatCompletions: "カスタム · Chat Completions", customResponses: "カスタム · Responses", customMessages: "カスタム · Messages", diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index fef89e75..0ffc6a58 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1012,6 +1012,7 @@ export const ko: typeof zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax (M3)', stepfun: 'StepFun', + opencode: 'OpenCode Zen', customChatCompletions: "사용자 지정 · Chat Completions", customResponses: "사용자 지정 · Responses", customMessages: "사용자 지정 · Messages", diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 31d7d23d..b6d39c9c 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1008,6 +1008,7 @@ export const zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax(M3)', stepfun: 'StepFun(阶跃星辰)', + opencode: 'OpenCode Zen', customChatCompletions: "自定义 · Chat Completions", customResponses: "自定义 · Responses", customMessages: "自定义 · Messages", diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 61e21257..1c7d5c84 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1010,6 +1010,7 @@ export const zhTW: typeof zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax(M3)', stepfun: 'StepFun(階躍星辰)', + opencode: 'OpenCode Zen', customChatCompletions: "自訂 · Chat Completions", customResponses: "自訂 · Responses", customMessages: "自訂 · Messages", diff --git a/openless-all/app/src/lib/ipc/providers.ts b/openless-all/app/src/lib/ipc/providers.ts index 2873b3b5..3db537eb 100644 --- a/openless-all/app/src/lib/ipc/providers.ts +++ b/openless-all/app/src/lib/ipc/providers.ts @@ -28,11 +28,14 @@ export interface ProviderDescriptor { /** Core owns protocol, defaults, and credential requirements. */ export function listProviderDescriptors(kind: ProviderKind): Promise { return invokeOrMock('list_provider_descriptors', { kind }, () => kind === 'llm' ? [ + ['opencode', 'opencode', 'chat_completions'], ['custom', 'customChatCompletions', 'chat_completions'], ['custom_responses', 'customResponses', 'responses'], ['custom_messages', 'customMessages', 'messages'], ].map(([providerType, labelKey, format]) => ({ - kind, providerType, labelKey, defaultEndpoint: null, defaultModel: null, + kind, providerType, labelKey, + defaultEndpoint: providerType === 'opencode' ? 'https://opencode.ai/zen/v1' : null, + defaultModel: providerType === 'opencode' ? 'deepseek-v4-flash' : null, authRequirement: 'api_key_unless_custom_endpoint', validationProbe: 'llm_text', staticModels: [], defaultRequestFormat: format as LlmRequestFormat, supportedRequestFormats: ['chat_completions', 'responses', 'messages'], diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 95ba9168..6faee63a 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -52,6 +52,7 @@ const LLM_NAME_KEY_BY_ID: Record = { openrouterFree: 'openrouterFree', alibabaCoding: 'alibabaCoding', codingPlanX: 'codingPlanX', + opencode: 'opencode', custom: 'custom', }; diff --git a/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts b/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts index 9216eafd..f6192f9e 100644 --- a/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts +++ b/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts @@ -18,14 +18,27 @@ assert(protocolValidationError({ ...values, 'ark.messages_thinking': 'budget', ' 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.filter(p => p.id.startsWith('custom')).length === 3, 'Browser catalog should expose three compatibility presets'); +const opencode = presets.find(p => p.id === 'opencode'); +assert(opencode?.defaultRequestFormat === 'chat_completions' + && opencode.defaultEndpoint === 'https://opencode.ai/zen/v1' + && opencode.defaultModel === 'deepseek-v4-flash', 'OpenCode browser preset must retain Core defaults'); 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 first = await createChannel('llm', 'opencode', 'first'); const second = await createChannel('llm', 'custom', 'second'); await setCredential('ark.request_format', 'messages', first); await setCredential('ark.api_key', 'fixture-key', first); +await setCredential('ark.endpoint', 'https://opencode.ai/zen/go/v1', first); +await setCredential('ark.model_id', 'deepseek-v4-flash', first); +for (const format of ['chat_completions', 'responses', 'messages']) { + await setCredential('ark.request_format', format, first); + assert(await readCredential('ark.request_format', first) === format, 'OpenCode format must survive reload'); + assert(await readCredential('ark.api_key', first) === 'fixture-key', 'Format changes preserve the key'); + assert(await readCredential('ark.model_id', first) === 'deepseek-v4-flash', 'Format changes preserve the model'); + assert(await readCredential('ark.endpoint', first) === 'https://opencode.ai/zen/go/v1', 'Format changes preserve the endpoint'); +} 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'); diff --git a/openless-all/app/src/pages/settings/ProvidersSection.test.ts b/openless-all/app/src/pages/settings/ProvidersSection.test.ts index 2668d8d4..7e027d7d 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.test.ts +++ b/openless-all/app/src/pages/settings/ProvidersSection.test.ts @@ -3,6 +3,9 @@ import { ASR_LABELS } from './shared'; import { presetsFor } from './ChannelList'; const atlascloudPreset = LLM_LABELS.find(p => p.id === 'atlascloud'); +if (LLM_LABELS.find(p => p.id === 'opencode')?.nameKey !== 'opencode') { + throw new Error('OpenCode LLM label is missing'); +} if (!atlascloudPreset) { throw new Error('Atlas Cloud LLM preset is missing'); diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index bbc08e4c..f50e2e73 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -91,6 +91,7 @@ export const LLM_LABELS = [ ['codex_oauth', 'codexOAuth'], ['mimo', 'mimo'], ['cometapi', 'cometapi'], ['openrouterFree', 'openrouterFree'], ['alibabaCoding', 'alibabaCoding'], ['codingPlanX', 'codingPlanX'], ['minimax', 'minimax'], ['stepfun', 'stepfun'], + ['opencode', 'opencode'], ['custom', 'customChatCompletions'], ['custom_responses', 'customResponses'], ['custom_messages', 'customMessages'], ].map(([id, nameKey]) => ({ id, nameKey })) as readonly { id: string; nameKey: string }[];