Merge branch 'main' into feat/proxy-full-url

This commit is contained in:
YoVinchen
2026-03-22 22:30:07 +08:00
27 changed files with 1377 additions and 150 deletions
+2 -2
View File
@@ -22,9 +22,9 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANG
[![MiniMax](assets/partners/banners/minimax-en.jpeg)](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)
MiniMax-M2.5 is a SOTA large language model designed for real-world productivity. Trained in a diverse range of complex real-world digital working environments, M2.5 builds upon the coding expertise of M2.1 to extend into general office work, reaching fluency in generating and operating Word, Excel, and Powerpoint files, context switching between diverse software environments, and working across different agent and human teams. Scoring 80.2% on SWE-Bench Verified, 51.3% on Multi-SWE-Bench, and 76.3% on BrowseComp, M2.5 is also more token efficient than previous generations, having been trained to optimize its actions and output through planning.
MiniMax-M2.7 is a next-generation large language model designed for autonomous evolution and real-world productivity. Unlike traditional models, M2.7 actively participates in its own improvement through agent teams, dynamic tool use, and reinforcement learning loops. It delivers strong performance in software engineering (56.22% on SWE-Pro, 55.6% on VIBE-Pro, 57.0% on Terminal Bench 2) and excels in complex office workflows, achieving a leading 1495 ELO on GDPval-AA. With high-fidelity editing across Word, Excel, and PowerPoint, and a 97% adherence rate across 40+ complex skills, M2.7 sets a new standard for building AI-native workflows and organizations.
[Click](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link) to get an exclusive 12% off the MiniMax Coding Plan!
[Click](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link) to get an exclusive 12% off the MiniMax Token Plan!
---
+2 -2
View File
@@ -22,9 +22,9 @@
[![MiniMax](assets/partners/banners/minimax-en.jpeg)](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)
MiniMax-M2.5 は、実際の生産性向上のために設計された最先端の大規模言語モデルです。多様で複雑な実環境のデジタルワークスペースでトレーニングされた M2.5 は、M2.1 のコーディング能力をベースに一般的なオフィス業務へと拡張し、Word・Excel・PowerPoint ファイルの生成と操作、多様なソフトウェア環境間のコンテキスト切り替え、異なるエージェントや人間チーム間での協働を流暢にこなします。SWE-Bench Verified で 80.2%、Multi-SWE-Bench で 51.3%、BrowseComp で 76.3% を達成し、計画的な行動と出力の最適化トレーニングにより、前世代よりもトークン効率に優れています。
MiniMax-M2.7 は、自律的進化と実世界の生産性向上のために設計された次世代大規模言語モデルです。従来のモデルとは異なり、M2.7 はエージェントチーム、動的ツール使用、強化学習ループを通じて自身の改善に積極的に参加します。ソフトウェアエンジニアリングにおいて優れた性能を発揮し(SWE-Pro で 56.22%、VIBE-Pro で 55.6%、Terminal Bench 2 で 57.0%)、複雑なオフィスワークフローにも秀でており、GDPval-AA で 1495 ELO のリーディングスコアを達成しています。Word・Excel・PowerPoint の高忠実度編集と、40 以上の複雑なスキルにわたる 97% の遵守率により、M2.7 は AI ネイティブなワークフローと組織構築の新基準を打ち立てます。
[こちら](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)から MiniMax Coding Plan の限定 12% オフを入手!
[こちら](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)から MiniMax Token Plan の限定 12% オフを入手!
---
+2 -2
View File
@@ -22,9 +22,9 @@
[![MiniMax](assets/partners/banners/minimax-zh.jpeg)](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)
MiniMax M2.5 在编程、工具调用与搜索、办公等核心生产力场景均达到或刷新行业 SOTA,拥有架构师级代码能力与高效任务拆解能力,推理速度较上一代提升 37%、token 消耗更优;100 token/s 连续工作一小时仅需 1 美金,让复杂 Agent 规模化部署经济可行,已在企业多职能场景深度落地,加速全民 Agent 时代到来
MiniMax M2.7 是 MiniMax 首个深度参与自我迭代的模型,可自主构建复杂 Agent Harness,并基于 Agent Teams、复杂 Skills、Tool Search Tool 等能力完成高复杂度生产力任务;其在软件工程、端到端项目交付及办公场景中表现优异,多项评测接近行业领先水平,同时具备稳定的复杂任务执行、环境交互能力以及良好的情商与身份保持能力
[点击](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)即可领取 MiniMax Coding Plan 专属 88 折优惠!
[点击此处](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link) MiniMax Token Plan 专属 88 折优惠!
---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 902 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 895 KiB

+16
View File
@@ -186,6 +186,22 @@ Set prices for each model (per million tokens):
| Cache Read Price | Price per million cache hit tokens |
| Cache Creation Price | Price per million cache creation tokens |
### Model ID Normalization Rules
Before matching pricing, CC Switch normalizes the requested model ID:
- Remove everything before the last `/`
- Remove everything after `:`
- Replace `@` with `-`
When adding pricing entries, enter the normalized Model ID rather than the full raw model name from the request.
| Raw model name | Model ID to enter | Note |
|-------|-------------------|------|
| `stepfun-ai/step-3.5-flash` | `step-3.5-flash` | Removes the provider prefix |
| `moonshotai/kimi-k2-0905:exa` | `kimi-k2-0905` | Removes the prefix and the `:` suffix |
| `gpt-5.2-codex@low` | `gpt-5.2-codex-low` | Replaces `@` with `-` |
### Operations
- **Add**: Click the "Add" button to add model pricing
+16
View File
@@ -186,6 +186,22 @@ Token 使用量の変化を表示:
| キャッシュ読取価格 | 100 万キャッシュヒット Token あたりの価格 |
| キャッシュ作成価格 | 100 万キャッシュ作成 Token あたりの価格 |
### モデル ID の正規化ルール
料金を照合する前に、CC Switch はリクエスト内のモデル ID を正規化します:
- 最後の `/` より前の接頭辞を削除
- `:` 以降の接尾辞を削除
- `@``-` に置換
料金設定では、リクエスト内の完全な元のモデル名ではなく、正規化後のモデル ID を入力してください。
| 元のモデル名 | 入力するモデル ID | 説明 |
|------|------|------|
| `stepfun-ai/step-3.5-flash` | `step-3.5-flash` | プロバイダー接頭辞を削除 |
| `moonshotai/kimi-k2-0905:exa` | `kimi-k2-0905` | 接頭辞と `:` 以降を削除 |
| `gpt-5.2-codex@low` | `gpt-5.2-codex-low` | `@``-` に置換 |
### 操作
- **追加**:「追加」ボタンで新しいモデル価格を追加
+16
View File
@@ -186,6 +186,22 @@
| 缓存读取价格 | 每百万缓存命中 Token 的价格 |
| 缓存创建价格 | 每百万缓存创建 Token 的价格 |
### 模型 ID 匹配规则
在匹配定价前,CC Switch 会先对请求中的模型 ID 做标准化处理:
- 去掉最后一个 `/` 之前的前缀
- 去掉 `:` 之后的后缀
-`@` 替换为 `-`
因此,在定价配置中请填写清洗后的模型 ID,而不是请求里的完整原始模型名。
| 原始模型名 | 应填写的模型 ID | 说明 |
|------|------|------|
| `stepfun-ai/step-3.5-flash` | `step-3.5-flash` | 去掉供应商前缀 |
| `moonshotai/kimi-k2-0905:exa` | `kimi-k2-0905` | 去掉前缀和 `:` 后缀 |
| `gpt-5.2-codex@low` | `gpt-5.2-codex-low` | 将 `@` 替换为 `-` |
### 操作
- **添加**:点击「添加」按钮新增模型定价
+8
View File
@@ -1059,6 +1059,14 @@ impl Database {
"0.50",
"6.25",
),
(
"claude-sonnet-4-6-20260217",
"Claude Sonnet 4.6",
"3",
"15",
"0.30",
"3.75",
),
// Claude 4.5 系列
(
"claude-opus-4-5-20251101",
+1 -1
View File
@@ -294,7 +294,7 @@ impl OpenClawConfigDocument {
if let Some(existing) = key_value_pairs
.iter_mut()
.find(|pair| json5_key_name(&pair.key).as_deref() == Some(key))
.find(|pair| json5_key_name(&pair.key) == Some(key))
{
existing.value = new_value;
return Ok(());
+14 -1
View File
@@ -860,9 +860,22 @@ impl RequestForwarder {
// 过滤黑名单 Headers,保护隐私并避免冲突
for (key, value) in headers {
let key_str = key.as_str();
if HEADER_BLACKLIST
.iter()
.any(|h| key.as_str().eq_ignore_ascii_case(h))
.any(|h| key_str.eq_ignore_ascii_case(h))
{
continue;
}
// Copilot 请求:过滤会由 add_auth_headers 注入的固定指纹头,
// 防止客户端原始头与注入头重复(reqwest header() 是追加语义)
if is_copilot
&& (key_str.eq_ignore_ascii_case("user-agent")
|| key_str.eq_ignore_ascii_case("editor-version")
|| key_str.eq_ignore_ascii_case("editor-plugin-version")
|| key_str.eq_ignore_ascii_case("copilot-integration-id")
|| key_str.eq_ignore_ascii_case("x-github-api-version")
|| key_str.eq_ignore_ascii_case("openai-intent"))
{
continue;
}
+7 -4
View File
@@ -329,12 +329,15 @@ impl ProviderAdapter for ClaudeAdapter {
AuthStrategy::Bearer => {
request.header("Authorization", format!("Bearer {}", auth.api_key))
}
// GitHub Copilot: Bearer + 特定的 Editor headers
// GitHub Copilot: Bearer + 统一指纹头
AuthStrategy::GitHubCopilot => request
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("Editor-Version", "vscode/1.85.0")
.header("Editor-Plugin-Version", "copilot/1.150.0")
.header("Copilot-Integration-Id", "vscode-chat"),
.header("editor-version", super::copilot_auth::COPILOT_EDITOR_VERSION)
.header("editor-plugin-version", super::copilot_auth::COPILOT_PLUGIN_VERSION)
.header("copilot-integration-id", super::copilot_auth::COPILOT_INTEGRATION_ID)
.header("user-agent", super::copilot_auth::COPILOT_USER_AGENT)
.header("x-github-api-version", super::copilot_auth::COPILOT_API_VERSION)
.header("openai-intent", "conversation-panel"),
_ => request,
}
}
+13 -8
View File
@@ -46,10 +46,11 @@ const TOKEN_REFRESH_BUFFER_SECONDS: i64 = 60;
const COPILOT_MODELS_URL: &str = "https://api.githubcopilot.com/models";
/// Copilot API Header 常量
const COPILOT_EDITOR_VERSION: &str = "vscode/1.96.0";
const COPILOT_PLUGIN_VERSION: &str = "copilot-chat/0.26.7";
const COPILOT_USER_AGENT: &str = "GitHubCopilotChat/0.26.7";
const COPILOT_API_VERSION: &str = "2025-04-01";
pub const COPILOT_EDITOR_VERSION: &str = "vscode/1.96.0";
pub const COPILOT_PLUGIN_VERSION: &str = "copilot-chat/0.26.7";
pub const COPILOT_USER_AGENT: &str = "GitHubCopilotChat/0.26.7";
pub const COPILOT_API_VERSION: &str = "2025-04-01";
pub const COPILOT_INTEGRATION_ID: &str = "vscode-chat";
/// Copilot 使用量 API URL
const COPILOT_USAGE_URL: &str = "https://api.github.com/copilot_internal/user";
@@ -465,6 +466,7 @@ impl CopilotAuthManager {
.http_client
.post(GITHUB_DEVICE_CODE_URL)
.header("Accept", "application/json")
.header("User-Agent", COPILOT_USER_AGENT)
.form(&[("client_id", GITHUB_CLIENT_ID), ("scope", "read:user")])
.send()
.await?;
@@ -502,6 +504,7 @@ impl CopilotAuthManager {
.http_client
.post(GITHUB_OAUTH_TOKEN_URL)
.header("Accept", "application/json")
.header("User-Agent", COPILOT_USER_AGENT)
.form(&[
("client_id", GITHUB_CLIENT_ID),
("device_code", device_code),
@@ -948,7 +951,9 @@ impl CopilotAuthManager {
.http_client
.get(GITHUB_USER_URL)
.header("Authorization", format!("token {}", github_token))
.header("User-Agent", "CC-Switch")
.header("User-Agent", COPILOT_USER_AGENT)
.header("Editor-Version", COPILOT_EDITOR_VERSION)
.header("Editor-Plugin-Version", COPILOT_PLUGIN_VERSION)
.send()
.await?;
@@ -978,9 +983,9 @@ impl CopilotAuthManager {
.http_client
.get(COPILOT_TOKEN_URL)
.header("Authorization", format!("token {}", github_token))
.header("User-Agent", "CC-Switch")
.header("Editor-Version", "vscode/1.85.0")
.header("Editor-Plugin-Version", "copilot/1.150.0")
.header("User-Agent", COPILOT_USER_AGENT)
.header("Editor-Version", COPILOT_EDITOR_VERSION)
.header("Editor-Plugin-Version", COPILOT_PLUGIN_VERSION)
.send()
.await?;
+238
View File
@@ -14,6 +14,63 @@ pub fn is_openai_o_series(model: &str) -> bool {
&& model.as_bytes().get(1).is_some_and(|b| b.is_ascii_digit())
}
/// Detect OpenAI models that support reasoning_effort.
///
/// Supported families:
/// - o-series: o1, o3, o4-mini, etc.
/// - GPT-5+: gpt-5, gpt-5.1, gpt-5.4, gpt-5-codex, etc.
pub fn supports_reasoning_effort(model: &str) -> bool {
is_openai_o_series(model)
|| model
.to_lowercase()
.strip_prefix("gpt-")
.and_then(|rest| rest.chars().next())
.is_some_and(|c| c.is_ascii_digit() && c >= '5')
}
/// Resolve the appropriate OpenAI `reasoning_effort` from an Anthropic request body.
///
/// Priority:
/// 1. Explicit `output_config.effort` — preserves the user's intent directly.
/// `low`/`medium`/`high` map 1:1; `max` maps to `xhigh`
/// (supported by mainstream GPT models). Unknown values are ignored.
/// 2. Fallback: `thinking.type` + `budget_tokens`:
/// - `adaptive` → `high` (mirrors optimizer semantics where adaptive ≈ max effort)
/// - `enabled` with budget → `low` (<4 000) / `medium` (4 00015 999) / `high` (≥16 000)
/// - `enabled` without budget → `high` (conservative default)
/// - `disabled` / absent → `None`
pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> {
// --- Priority 1: explicit output_config.effort ---
if let Some(effort) = body
.pointer("/output_config/effort")
.and_then(|v| v.as_str())
{
return match effort {
"low" => Some("low"),
"medium" => Some("medium"),
"high" => Some("high"),
"max" => Some("xhigh"), // OpenAI xhigh = maximum reasoning effort
_ => None, // unknown value — do not inject
};
}
// --- Priority 2: thinking.type + budget_tokens fallback ---
let thinking = body.get("thinking")?;
match thinking.get("type").and_then(|t| t.as_str()) {
Some("adaptive") => Some("high"),
Some("enabled") => {
let budget = thinking.get("budget_tokens").and_then(|b| b.as_u64());
match budget {
Some(b) if b < 4_000 => Some("low"),
Some(b) if b < 16_000 => Some("medium"),
Some(_) => Some("high"),
None => Some("high"), // enabled but no budget — assume strong reasoning
}
}
_ => None, // disabled or missing
}
}
/// Anthropic 请求 → OpenAI 请求
///
/// `cache_key`: optional prompt_cache_key to inject for improved cache routing
@@ -80,6 +137,13 @@ pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value
result["stream"] = v.clone();
}
// Map Anthropic thinking → OpenAI reasoning_effort
if supports_reasoning_effort(model) {
if let Some(effort) = resolve_reasoning_effort(&body) {
result["reasoning_effort"] = json!(effort);
}
}
// 转换 tools (过滤 BatchTool)
if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) {
let openai_tools: Vec<Value> = tools
@@ -800,6 +864,180 @@ mod tests {
assert!(!is_openai_o_series(""));
}
#[test]
fn test_supports_reasoning_effort() {
assert!(supports_reasoning_effort("o1"));
assert!(supports_reasoning_effort("o3-mini"));
assert!(supports_reasoning_effort("gpt-5"));
assert!(supports_reasoning_effort("gpt-5.4"));
assert!(supports_reasoning_effort("gpt-5-codex"));
assert!(!supports_reasoning_effort("gpt-4o"));
assert!(!supports_reasoning_effort("claude-sonnet-4-6"));
}
// ── resolve_reasoning_effort unit tests ──
#[test]
fn test_output_config_low_maps_to_reasoning_effort_low() {
let body = json!({"output_config": {"effort": "low"}});
assert_eq!(resolve_reasoning_effort(&body), Some("low"));
}
#[test]
fn test_output_config_medium_maps_to_reasoning_effort_medium() {
let body = json!({"output_config": {"effort": "medium"}});
assert_eq!(resolve_reasoning_effort(&body), Some("medium"));
}
#[test]
fn test_output_config_high_maps_to_reasoning_effort_high() {
let body = json!({"output_config": {"effort": "high"}});
assert_eq!(resolve_reasoning_effort(&body), Some("high"));
}
#[test]
fn test_output_config_max_maps_to_reasoning_effort_xhigh() {
let body = json!({"output_config": {"effort": "max"}});
assert_eq!(resolve_reasoning_effort(&body), Some("xhigh"));
}
#[test]
fn test_output_config_takes_priority_over_thinking() {
// Even with thinking.adaptive present, explicit effort wins
let body = json!({
"output_config": {"effort": "low"},
"thinking": {"type": "adaptive"}
});
assert_eq!(resolve_reasoning_effort(&body), Some("low"));
}
#[test]
fn test_output_config_unknown_value_no_reasoning_effort() {
let body = json!({"output_config": {"effort": "turbo"}});
assert_eq!(resolve_reasoning_effort(&body), None);
}
#[test]
fn test_thinking_enabled_small_budget_maps_low() {
let body = json!({"thinking": {"type": "enabled", "budget_tokens": 1024}});
assert_eq!(resolve_reasoning_effort(&body), Some("low"));
}
#[test]
fn test_thinking_enabled_medium_budget_maps_medium() {
let body = json!({"thinking": {"type": "enabled", "budget_tokens": 8000}});
assert_eq!(resolve_reasoning_effort(&body), Some("medium"));
}
#[test]
fn test_thinking_enabled_large_budget_maps_high() {
let body = json!({"thinking": {"type": "enabled", "budget_tokens": 32000}});
assert_eq!(resolve_reasoning_effort(&body), Some("high"));
}
#[test]
fn test_thinking_enabled_without_budget_maps_high() {
let body = json!({"thinking": {"type": "enabled"}});
assert_eq!(resolve_reasoning_effort(&body), Some("high"));
}
#[test]
fn test_thinking_adaptive_maps_high() {
let body = json!({"thinking": {"type": "adaptive"}});
assert_eq!(resolve_reasoning_effort(&body), Some("high"));
}
#[test]
fn test_thinking_disabled_no_reasoning_effort() {
let body = json!({"thinking": {"type": "disabled"}});
assert_eq!(resolve_reasoning_effort(&body), None);
}
#[test]
fn test_no_thinking_field_no_reasoning_effort() {
let body = json!({"messages": [{"role": "user", "content": "Hello"}]});
assert_eq!(resolve_reasoning_effort(&body), None);
}
// ── Integration: anthropic_to_openai with resolve_reasoning_effort ──
#[test]
fn test_non_reasoning_model_no_reasoning_effort() {
let input = json!({
"model": "gpt-4o",
"max_tokens": 1024,
"thinking": {"type": "enabled", "budget_tokens": 2048},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert!(result.get("reasoning_effort").is_none());
}
#[test]
fn test_reasoning_model_with_output_config_effort() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"output_config": {"effort": "medium"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert_eq!(result["reasoning_effort"], "medium");
}
#[test]
fn test_reasoning_model_with_output_config_max() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"output_config": {"effort": "max"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert_eq!(result["reasoning_effort"], "xhigh");
}
#[test]
fn test_reasoning_model_thinking_enabled_small_budget() {
let input = json!({
"model": "o3",
"max_tokens": 1024,
"thinking": {"type": "enabled", "budget_tokens": 2048},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert_eq!(result["reasoning_effort"], "low");
}
#[test]
fn test_reasoning_model_thinking_adaptive() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"thinking": {"type": "adaptive"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert_eq!(result["reasoning_effort"], "high");
}
#[test]
fn test_reasoning_model_no_thinking_no_effort() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert!(result.get("reasoning_effort").is_none());
}
#[test]
fn test_anthropic_to_openai_o_series_max_completion_tokens() {
for model in &["o1", "o3-mini", "o4-mini"] {
@@ -61,6 +61,15 @@ pub fn anthropic_to_responses(body: Value, cache_key: Option<&str>) -> Result<Va
result["stream"] = v.clone();
}
// Map Anthropic thinking → OpenAI Responses reasoning.effort
if let Some(model_name) = body.get("model").and_then(|m| m.as_str()) {
if super::transform::supports_reasoning_effort(model_name) {
if let Some(effort) = super::transform::resolve_reasoning_effort(&body) {
result["reasoning"] = json!({ "effort": effort });
}
}
}
// stop_sequences → 丢弃 (Responses API 不支持)
// 转换 tools (过滤 BatchTool)
@@ -910,4 +919,96 @@ mod tests {
assert_eq!(result["max_output_tokens"], 4096);
assert!(result.get("max_completion_tokens").is_none());
}
#[test]
fn test_responses_output_config_max_sets_reasoning_xhigh() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"output_config": {"effort": "max"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
assert_eq!(result["reasoning"]["effort"], "xhigh");
}
#[test]
fn test_responses_output_config_takes_priority_over_thinking() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"output_config": {"effort": "low"},
"thinking": {"type": "adaptive"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
assert_eq!(result["reasoning"]["effort"], "low");
}
#[test]
fn test_responses_thinking_enabled_small_budget_sets_reasoning_low() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"thinking": {"type": "enabled", "budget_tokens": 2048},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
assert_eq!(result["reasoning"]["effort"], "low");
}
#[test]
fn test_responses_thinking_enabled_medium_budget_sets_reasoning_medium() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"thinking": {"type": "enabled", "budget_tokens": 8000},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
assert_eq!(result["reasoning"]["effort"], "medium");
}
#[test]
fn test_responses_thinking_enabled_large_budget_sets_reasoning_high() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"thinking": {"type": "enabled", "budget_tokens": 32000},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
assert_eq!(result["reasoning"]["effort"], "high");
}
#[test]
fn test_responses_thinking_adaptive_sets_reasoning_high() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"thinking": {"type": "adaptive"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
assert_eq!(result["reasoning"]["effort"], "high");
}
#[test]
fn test_responses_non_reasoning_model_no_reasoning() {
let input = json!({
"model": "gpt-4o",
"max_tokens": 1024,
"thinking": {"type": "enabled", "budget_tokens": 2048},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
assert!(result.get("reasoning").is_none());
}
}
+7 -3
View File
@@ -13,6 +13,7 @@ use crate::app_config::AppType;
use crate::error::AppError;
use crate::provider::Provider;
use crate::proxy::providers::transform::anthropic_to_openai;
use crate::proxy::providers::copilot_auth;
use crate::proxy::providers::transform_responses::anthropic_to_responses;
use crate::proxy::providers::{get_adapter, AuthInfo, AuthStrategy};
@@ -353,9 +354,12 @@ impl StreamCheckService {
.header("content-type", "application/json")
.header("accept", "text/event-stream")
.header("accept-encoding", "identity")
.header("editor-version", "vscode/1.85.0")
.header("editor-plugin-version", "copilot/1.150.0")
.header("copilot-integration-id", "vscode-chat");
.header("user-agent", copilot_auth::COPILOT_USER_AGENT)
.header("editor-version", copilot_auth::COPILOT_EDITOR_VERSION)
.header("editor-plugin-version", copilot_auth::COPILOT_PLUGIN_VERSION)
.header("copilot-integration-id", copilot_auth::COPILOT_INTEGRATION_ID)
.header("x-github-api-version", copilot_auth::COPILOT_API_VERSION)
.header("openai-intent", "conversation-panel");
} else if is_openai_chat || is_openai_responses {
// OpenAI-compatible targets: Bearer auth + SSE headers only
request_builder = request_builder
+10
View File
@@ -69,6 +69,11 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
}
pub fn load_messages(provider_id: &str, source_path: &str) -> Result<Vec<SessionMessage>, String> {
// OpenCode SQLite sessions use a "sqlite:" prefixed source_path
if provider_id == "opencode" && source_path.starts_with("sqlite:") {
return opencode::load_messages_sqlite(source_path);
}
let path = Path::new(source_path);
match provider_id {
"codex" => codex::load_messages(path),
@@ -85,6 +90,11 @@ pub fn delete_session(
session_id: &str,
source_path: &str,
) -> Result<bool, String> {
// OpenCode SQLite sessions bypass the file-based deletion path
if provider_id == "opencode" && source_path.starts_with("sqlite:") {
return opencode::delete_session_sqlite(session_id, source_path);
}
let root = provider_root(provider_id)?;
delete_session_with_root(provider_id, session_id, Path::new(source_path), &root)
}
@@ -52,11 +52,25 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
None => continue,
};
let role = message
let mut role = message
.get("role")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
// Claude wraps tool_result inside user messages; reclassify as "tool" role
if role == "user" {
if let Some(Value::Array(items)) = message.get("content") {
let all_tool_results = !items.is_empty()
&& items.iter().all(|item| {
item.get("type").and_then(Value::as_str) == Some("tool_result")
});
if all_tool_results {
role = "tool".to_string();
}
}
}
let content = message.get("content").map(extract_text).unwrap_or_default();
if content.trim().is_empty() {
continue;
@@ -268,4 +282,58 @@ mod tests {
assert!(!path.exists());
assert!(!sidecar.exists());
}
#[test]
fn load_messages_tool_use_shows_as_assistant() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session.jsonl");
std::fs::write(
&path,
concat!(
"{\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"Write\",\"input\":{\"file_path\":\"a.txt\"}}]},\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
"{\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_1\",\"content\":\"File written\"}]},\"timestamp\":\"2026-03-06T10:00:01Z\"}\n",
),
)
.expect("write");
let msgs = load_messages(&path).expect("load");
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].role, "assistant");
assert!(msgs[0].content.contains("[Tool: Write]"));
assert_eq!(msgs[1].role, "tool");
assert_eq!(msgs[1].content, "File written");
}
#[test]
fn load_messages_mixed_text_and_tool_use() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session.jsonl");
std::fs::write(
&path,
"{\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Let me help.\"},{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"Read\",\"input\":{}}]},\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
)
.expect("write");
let msgs = load_messages(&path).expect("load");
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].role, "assistant");
assert!(msgs[0].content.contains("Let me help."));
assert!(msgs[0].content.contains("[Tool: Read]"));
}
#[test]
fn load_messages_mixed_user_tool_result_and_text_stays_user() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session.jsonl");
std::fs::write(
&path,
"{\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_1\",\"content\":\"result\"},{\"type\":\"text\",\"text\":\"Please continue\"}]},\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
)
.expect("write");
let msgs = load_messages(&path).expect("load");
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].role, "user");
assert!(msgs[0].content.contains("Please continue"));
}
}
@@ -59,16 +59,37 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
None => continue,
};
if payload.get("type").and_then(Value::as_str) != Some("message") {
continue;
}
let payload_type = payload.get("type").and_then(Value::as_str).unwrap_or("");
// Codex uses separate payload types for tool interactions
let (role, content) = match payload_type {
"message" => {
let role = payload
.get("role")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
let content = payload.get("content").map(extract_text).unwrap_or_default();
(role, content)
}
"function_call" => {
let name = payload
.get("name")
.and_then(Value::as_str)
.unwrap_or("unknown");
("assistant".to_string(), format!("[Tool: {name}]"))
}
"function_call_output" => {
let output = payload
.get("output")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
("tool".to_string(), output)
}
_ => continue,
};
let role = payload
.get("role")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
let content = payload.get("content").map(extract_text).unwrap_or_default();
if content.trim().is_empty() {
continue;
}
@@ -239,4 +260,36 @@ mod tests {
assert!(!path.exists());
}
#[test]
fn load_messages_includes_function_call_and_output() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session.jsonl");
std::fs::write(
&path,
concat!(
"{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp\"}}\n",
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"list files\"}}\n",
"{\"timestamp\":\"2026-03-06T21:50:14Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"function_call\",\"name\":\"shell\",\"arguments\":\"{\\\"cmd\\\":[\\\"ls\\\"]}\",\"call_id\":\"call_1\"}}\n",
"{\"timestamp\":\"2026-03-06T21:50:15Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"function_call_output\",\"call_id\":\"call_1\",\"output\":\"file1.txt\\nfile2.txt\"}}\n",
"{\"timestamp\":\"2026-03-06T21:50:16Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Done.\"}]}}\n",
),
)
.expect("write");
let msgs = load_messages(&path).expect("load");
assert_eq!(msgs.len(), 4);
assert_eq!(msgs[0].role, "user");
assert_eq!(msgs[0].content, "list files");
assert_eq!(msgs[1].role, "assistant");
assert!(msgs[1].content.contains("[Tool: shell]"));
assert_eq!(msgs[2].role, "tool");
assert!(msgs[2].content.contains("file1.txt"));
assert_eq!(msgs[3].role, "assistant");
assert_eq!(msgs[3].content, "Done.");
}
}
@@ -60,21 +60,47 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
let mut result = Vec::new();
for msg in messages {
let content = match msg.get("content").and_then(Value::as_str) {
Some(c) if !c.trim().is_empty() => c.to_string(),
_ => continue,
let role = match msg.get("type").and_then(Value::as_str) {
Some("gemini") => "assistant",
Some("user") => "user",
Some("info") | Some("error") => continue,
Some(_) | None => continue,
};
let role = match msg.get("type").and_then(Value::as_str) {
Some("gemini") => "assistant".to_string(),
Some("user") => "user".to_string(),
Some(other) => other.to_string(),
None => continue,
// Gemini content may be a plain string or an array of {text: ...} objects
let mut content = match msg.get("content") {
Some(Value::String(s)) => s.to_string(),
Some(Value::Array(items)) => items
.iter()
.filter_map(|item| item.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n"),
_ => String::new(),
};
// Append tool call names from the optional toolCalls array
if let Some(Value::Array(calls)) = msg.get("toolCalls") {
for call in calls {
if let Some(name) = call.get("name").and_then(Value::as_str) {
if !content.is_empty() {
content.push('\n');
}
content.push_str(&format!("[Tool: {name}]"));
}
}
}
if content.trim().is_empty() {
continue;
}
let ts = msg.get("timestamp").and_then(parse_timestamp_to_ms);
result.push(SessionMessage { role, content, ts });
result.push(SessionMessage {
role: role.to_string(),
content,
ts,
});
}
Ok(result)
@@ -172,4 +198,55 @@ mod tests {
assert!(!path.exists());
}
#[test]
fn load_messages_handles_array_content() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session.json");
std::fs::write(
&path,
r#"{
"sessionId": "test",
"messages": [
{"id":"1","timestamp":"2026-03-06T10:00:00Z","type":"user","content":[{"text":"hello"}]},
{"id":"2","timestamp":"2026-03-06T10:00:01Z","type":"gemini","content":"world"},
{"id":"3","timestamp":"2026-03-06T10:00:02Z","type":"info","content":"system info"},
{"id":"4","timestamp":"2026-03-06T10:00:03Z","type":"error","content":"MCP ERROR"}
]
}"#,
)
.expect("write");
let msgs = load_messages(&path).expect("load");
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].role, "user");
assert_eq!(msgs[0].content, "hello");
assert_eq!(msgs[1].role, "assistant");
assert_eq!(msgs[1].content, "world");
}
#[test]
fn load_messages_includes_tool_calls() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session.json");
std::fs::write(
&path,
r#"{
"sessionId": "test",
"messages": [
{"id":"1","timestamp":"2026-03-10T08:24:50Z","type":"gemini","content":"","toolCalls":[{"id":"call_1","name":"web_search","args":{"query":"test"}}]},
{"id":"2","timestamp":"2026-03-10T08:25:00Z","type":"gemini","content":"Here are the results.","toolCalls":[{"id":"call_2","name":"web_fetch","args":{"url":"http://example.com"}}]}
]
}"#,
)
.expect("write");
let msgs = load_messages(&path).expect("load");
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].role, "assistant");
assert!(msgs[0].content.contains("[Tool: web_search]"));
assert_eq!(msgs[1].role, "assistant");
assert!(msgs[1].content.contains("Here are the results."));
assert!(msgs[1].content.contains("[Tool: web_fetch]"));
}
}
@@ -1,5 +1,6 @@
use std::path::{Path, PathBuf};
use rusqlite::Connection;
use serde_json::Value;
use crate::session_manager::{SessionMessage, SessionMeta};
@@ -8,22 +9,59 @@ use super::utils::{parse_timestamp_to_ms, path_basename, truncate_summary};
const PROVIDER_ID: &str = "opencode";
/// Return the OpenCode data directory.
/// Return the OpenCode base directory (`$XDG_DATA_HOME/opencode`).
///
/// Respects `XDG_DATA_HOME` on all platforms; falls back to
/// `~/.local/share/opencode/storage/`.
pub(crate) fn get_opencode_data_dir() -> PathBuf {
/// `~/.local/share/opencode/`.
pub(crate) fn get_opencode_base_dir() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
if !xdg.is_empty() {
return PathBuf::from(xdg).join("opencode").join("storage");
return PathBuf::from(xdg).join("opencode");
}
}
dirs::home_dir()
.map(|h| h.join(".local/share/opencode/storage"))
.unwrap_or_else(|| PathBuf::from(".local/share/opencode/storage"))
.map(|h| h.join(".local/share/opencode"))
.unwrap_or_else(|| PathBuf::from(".local/share/opencode"))
}
/// Return the OpenCode JSON storage directory (legacy flat-file layout).
pub(crate) fn get_opencode_data_dir() -> PathBuf {
get_opencode_base_dir().join("storage")
}
fn get_opencode_db_path() -> PathBuf {
get_opencode_base_dir().join("opencode.db")
}
/// Scan sessions from both the legacy JSON files and the newer SQLite database,
/// merging results with SQLite taking precedence on ID conflicts.
pub fn scan_sessions() -> Vec<SessionMeta> {
let json_sessions = scan_sessions_json();
let sqlite_sessions = scan_sessions_sqlite();
if sqlite_sessions.is_empty() {
return json_sessions;
}
if json_sessions.is_empty() {
return sqlite_sessions;
}
// Deduplicate: keep SQLite version when the same session_id exists in both
let sqlite_ids: std::collections::HashSet<String> = sqlite_sessions
.iter()
.map(|s| s.session_id.clone())
.collect();
let mut merged = sqlite_sessions;
for s in json_sessions {
if !sqlite_ids.contains(&s.session_id) {
merged.push(s);
}
}
merged
}
fn scan_sessions_json() -> Vec<SessionMeta> {
let storage = get_opencode_data_dir();
let session_dir = storage.join("session");
if !session_dir.exists() {
@@ -42,6 +80,81 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
sessions
}
/// Parse a SQLite source reference in the format `sqlite:<db_path>:<session_id>`.
///
/// Uses `rfind(":ses_")` to split the path from the session ID because the
/// db path itself may contain colons (e.g. `C:\Users\...` on Windows).
/// This relies on the OpenCode convention that session IDs start with `ses_`.
fn parse_sqlite_source(source: &str) -> Option<(PathBuf, String)> {
let rest = source.strip_prefix("sqlite:")?;
let sep = rest.rfind(":ses_")?;
let db_path = PathBuf::from(&rest[..sep]);
let session_id = rest[sep + 1..].to_string();
Some((db_path, session_id))
}
fn scan_sessions_sqlite() -> Vec<SessionMeta> {
let db_path = get_opencode_db_path();
if !db_path.exists() {
return Vec::new();
}
let conn = match Connection::open_with_flags(
&db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let mut stmt = match conn.prepare(
"SELECT id, title, directory, time_created, time_updated FROM session ORDER BY time_updated DESC",
) {
Ok(s) => s,
Err(_) => return Vec::new(),
};
let db_display = db_path.display().to_string();
let iter = match stmt.query_map([], |row| {
let session_id: String = row.get(0)?;
let title: String = row.get(1)?;
let directory: String = row.get(2)?;
let created: i64 = row.get(3)?;
let updated: i64 = row.get(4)?;
Ok((session_id, title, directory, created, updated))
}) {
Ok(rows) => rows,
Err(_) => return Vec::new(),
};
let mut sessions = Vec::new();
for row in iter.flatten() {
let (session_id, title, directory, created, updated) = row;
let display_title = if title.is_empty() {
path_basename(&directory)
} else {
Some(title)
};
sessions.push(SessionMeta {
provider_id: PROVIDER_ID.to_string(),
session_id: session_id.clone(),
title: display_title.clone(),
summary: display_title,
project_dir: if directory.is_empty() {
None
} else {
Some(directory)
},
created_at: Some(created),
last_active_at: Some(updated),
source_path: Some(format!("sqlite:{}:{}", db_display, session_id)),
resume_command: Some(format!("opencode session resume {session_id}")),
});
}
sessions
}
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
// `path` is the message directory: storage/message/{sessionID}/
if !path.is_dir() {
@@ -111,6 +224,95 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
Ok(messages)
}
/// Load messages from the OpenCode SQLite database for a given source reference.
/// Joins the `message` and `part` tables in memory to reconstruct full messages.
pub fn load_messages_sqlite(source: &str) -> Result<Vec<SessionMessage>, String> {
let (db_path, session_id) = parse_sqlite_source(source)
.ok_or_else(|| format!("Invalid SQLite source reference: {source}"))?;
let conn = Connection::open_with_flags(
&db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|e| format!("Failed to open OpenCode database: {e}"))?;
let mut msg_stmt = conn
.prepare(
"SELECT id, time_created, data FROM message WHERE session_id = ?1 ORDER BY time_created ASC",
)
.map_err(|e| format!("Failed to prepare message query: {e}"))?;
let msg_rows = msg_stmt
.query_map([session_id.as_str()], |row| {
let id: String = row.get(0)?;
let ts: i64 = row.get(1)?;
let data: String = row.get(2)?;
Ok((id, ts, data))
})
.map_err(|e| format!("Failed to query messages: {e}"))?;
let mut part_stmt = conn
.prepare(
"SELECT message_id, data FROM part WHERE session_id = ?1 ORDER BY time_created ASC",
)
.map_err(|e| format!("Failed to prepare part query: {e}"))?;
let part_rows = part_stmt
.query_map([session_id.as_str()], |row| {
let message_id: String = row.get(0)?;
let data: String = row.get(1)?;
Ok((message_id, data))
})
.map_err(|e| format!("Failed to query parts: {e}"))?;
let mut parts_map: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for part in part_rows.flatten() {
let (message_id, data) = part;
parts_map.entry(message_id).or_default().push(data);
}
let mut messages = Vec::new();
for row in msg_rows.flatten() {
let (msg_id, ts, data) = row;
let msg_value: Value = match serde_json::from_str(&data) {
Ok(v) => v,
Err(_) => continue,
};
let role = msg_value
.get("role")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
let mut texts = Vec::new();
if let Some(parts) = parts_map.get(&msg_id) {
for part_data in parts {
let part_value: Value = match serde_json::from_str(part_data) {
Ok(v) => v,
Err(_) => continue,
};
if let Some(text) = extract_part_text(&part_value) {
texts.push(text);
}
}
}
let content = texts.join("\n");
if content.trim().is_empty() {
continue;
}
messages.push(SessionMessage {
role,
content,
ts: Some(ts),
});
}
Ok(messages)
}
pub fn delete_session(storage: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
if path.file_name().and_then(|name| name.to_str()) != Some(session_id) {
return Err(format!(
@@ -176,6 +378,48 @@ pub fn delete_session(storage: &Path, path: &Path, session_id: &str) -> Result<b
Ok(true)
}
/// Delete a session from the OpenCode SQLite database.
pub fn delete_session_sqlite(session_id: &str, source: &str) -> Result<bool, String> {
let (db_path, ref_session_id) = parse_sqlite_source(source)
.ok_or_else(|| format!("Invalid SQLite source reference: {source}"))?;
let db_path = db_path
.canonicalize()
.map_err(|e| format!("Failed to canonicalize SQLite database path: {e}"))?;
let expected_db_path = get_opencode_db_path()
.canonicalize()
.map_err(|e| format!("Failed to canonicalize expected OpenCode database path: {e}"))?;
if ref_session_id != session_id {
return Err(format!(
"OpenCode SQLite session ID mismatch: expected {session_id}, found {ref_session_id}"
));
}
if db_path != expected_db_path {
return Err("SQLite path does not match expected OpenCode database".to_string());
}
let conn =
Connection::open(&db_path).map_err(|e| format!("Failed to open OpenCode database: {e}"))?;
let tx = conn
.unchecked_transaction()
.map_err(|e| format!("Failed to begin transaction: {e}"))?;
tx.execute("DELETE FROM part WHERE session_id = ?1", [session_id])
.map_err(|e| format!("Failed to delete OpenCode parts: {e}"))?;
tx.execute("DELETE FROM message WHERE session_id = ?1", [session_id])
.map_err(|e| format!("Failed to delete OpenCode messages: {e}"))?;
let deleted = tx
.execute("DELETE FROM session WHERE id = ?1", [session_id])
.map_err(|e| format!("Failed to delete OpenCode session: {e}"))?;
tx.commit()
.map_err(|e| format!("Failed to commit session deletion: {e}"))?;
Ok(deleted > 0)
}
fn parse_session(storage: &Path, path: &Path) -> Option<SessionMeta> {
let data = std::fs::read_to_string(path).ok()?;
let value: Value = serde_json::from_str(&data).ok()?;
@@ -286,6 +530,24 @@ fn get_first_user_summary(storage: &Path, session_id: &str) -> Option<String> {
}
/// Collect text content from all parts in a part directory.
fn extract_part_text(part_value: &Value) -> Option<String> {
match part_value.get("type").and_then(Value::as_str) {
Some("text") => part_value
.get("text")
.and_then(Value::as_str)
.filter(|t| !t.trim().is_empty())
.map(|t| t.to_string()),
Some("tool") => {
let tool = part_value
.get("tool")
.and_then(Value::as_str)
.unwrap_or("unknown");
Some(format!("[Tool: {tool}]"))
}
_ => None,
}
}
fn collect_parts_text(part_dir: &Path) -> String {
if !part_dir.is_dir() {
return String::new();
@@ -305,15 +567,8 @@ fn collect_parts_text(part_dir: &Path) -> String {
Err(_) => continue,
};
// Only include text-type parts
if value.get("type").and_then(Value::as_str) != Some("text") {
continue;
}
if let Some(text) = value.get("text").and_then(Value::as_str) {
if !text.trim().is_empty() {
texts.push(text.to_string());
}
if let Some(text) = extract_part_text(&value) {
texts.push(text);
}
}
@@ -370,8 +625,47 @@ fn remove_dir_all_if_exists(path: &Path) -> std::io::Result<()> {
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
use std::sync::{Mutex, OnceLock};
use tempfile::tempdir;
fn opencode_env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn create_sqlite_schema(conn: &Connection) {
conn.execute_batch(
"
PRAGMA foreign_keys = ON;
CREATE TABLE session (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
directory TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL
);
CREATE TABLE message (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
data TEXT NOT NULL,
FOREIGN KEY(session_id) REFERENCES session(id) ON DELETE CASCADE
);
CREATE TABLE part (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
message_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
data TEXT NOT NULL,
FOREIGN KEY(session_id) REFERENCES session(id) ON DELETE CASCADE,
FOREIGN KEY(message_id) REFERENCES message(id) ON DELETE CASCADE
);
",
)
.expect("create sqlite schema");
}
#[test]
fn delete_session_removes_session_diff_messages_and_parts() {
let temp = tempdir().expect("tempdir");
@@ -432,4 +726,272 @@ mod tests {
.join(format!("{project_id}.json"))
.exists());
}
#[test]
fn load_messages_includes_tool_parts() {
let temp = tempdir().expect("tempdir");
let storage = temp.path();
let session_id = "ses_test";
let msg_id = "msg_1";
let msg_dir = storage.join("message").join(session_id);
let part_dir = storage.join("part").join(msg_id);
std::fs::create_dir_all(&msg_dir).expect("create msg dir");
std::fs::create_dir_all(&part_dir).expect("create part dir");
std::fs::write(
msg_dir.join(format!("{msg_id}.json")),
r#"{"id":"msg_1","role":"assistant","time":{"created":"2026-03-06T10:00:00Z"}}"#,
)
.expect("write msg");
std::fs::write(
part_dir.join("prt_1.json"),
r#"{"id":"prt_1","type":"tool","tool":"bash","state":{"status":"completed","input":{"command":"ls"},"output":"file.txt"}}"#,
)
.expect("write tool part");
std::fs::write(
part_dir.join("prt_2.json"),
r#"{"id":"prt_2","type":"text","text":"Here are the files."}"#,
)
.expect("write text part");
let msgs = load_messages(&msg_dir).expect("load");
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].role, "assistant");
assert!(msgs[0].content.contains("[Tool: bash]"));
assert!(msgs[0].content.contains("Here are the files."));
}
#[test]
fn parse_sqlite_source_accepts_valid_references() {
let parsed = parse_sqlite_source("sqlite:/tmp/opencode.db:ses_123").expect("valid source");
assert_eq!(parsed.0, PathBuf::from("/tmp/opencode.db"));
assert_eq!(parsed.1, "ses_123");
}
#[test]
fn parse_sqlite_source_rejects_invalid_references() {
assert!(parse_sqlite_source("/tmp/opencode.db:ses_123").is_none());
assert!(parse_sqlite_source("sqlite:/tmp/opencode.db:msg_123").is_none());
assert!(parse_sqlite_source("sqlite:/tmp/opencode.db").is_none());
}
#[test]
#[allow(deprecated)] // set_var/remove_var deprecated since Rust 1.81; safe here under mutex
fn scan_sessions_sqlite_reads_temp_database() {
let _guard = opencode_env_lock().lock().expect("lock");
let temp = tempdir().expect("tempdir");
let original_xdg = std::env::var_os("XDG_DATA_HOME");
std::env::set_var("XDG_DATA_HOME", temp.path());
let base_dir = temp.path().join("opencode");
std::fs::create_dir_all(&base_dir).expect("create base dir");
let db_path = base_dir.join("opencode.db");
let conn = Connection::open(&db_path).expect("open sqlite db");
create_sqlite_schema(&conn);
conn.execute(
"INSERT INTO session (id, title, directory, time_created, time_updated) VALUES (?1, ?2, ?3, ?4, ?5)",
("ses_1", "", "/tmp/project-a", 1_771_061_953_033_i64, 1_771_061_954_033_i64),
)
.expect("insert session 1");
conn.execute(
"INSERT INTO session (id, title, directory, time_created, time_updated) VALUES (?1, ?2, ?3, ?4, ?5)",
("ses_2", "Named Session", "/tmp/project-b", 1_771_061_950_000_i64, 1_771_061_955_000_i64),
)
.expect("insert session 2");
drop(conn);
let sessions = scan_sessions_sqlite();
#[allow(deprecated)]
if let Some(value) = original_xdg {
std::env::set_var("XDG_DATA_HOME", value);
} else {
std::env::remove_var("XDG_DATA_HOME");
}
assert_eq!(sessions.len(), 2);
assert_eq!(sessions[0].session_id, "ses_2");
assert_eq!(sessions[0].title.as_deref(), Some("Named Session"));
assert_eq!(sessions[1].session_id, "ses_1");
assert_eq!(sessions[1].title.as_deref(), Some("project-a"));
assert_eq!(sessions[1].project_dir.as_deref(), Some("/tmp/project-a"));
let expected_source = format!("sqlite:{}:ses_1", db_path.display());
assert_eq!(
sessions[1].source_path.as_deref(),
Some(expected_source.as_str())
);
}
#[test]
fn load_messages_sqlite_reads_messages_and_parts() {
let temp = tempdir().expect("tempdir");
let db_path = temp.path().join("opencode.db");
let conn = Connection::open(&db_path).expect("open sqlite db");
create_sqlite_schema(&conn);
conn.execute(
"INSERT INTO session (id, title, directory, time_created, time_updated) VALUES (?1, ?2, ?3, ?4, ?5)",
("ses_1", "Session", "/tmp/project-a", 1000_i64, 3000_i64),
)
.expect("insert session");
conn.execute(
"INSERT INTO message (id, session_id, time_created, data) VALUES (?1, ?2, ?3, ?4)",
("msg_1", "ses_1", 1000_i64, r#"{"role":"user"}"#),
)
.expect("insert message 1");
conn.execute(
"INSERT INTO message (id, session_id, time_created, data) VALUES (?1, ?2, ?3, ?4)",
("msg_2", "ses_1", 2000_i64, r#"{"role":"assistant"}"#),
)
.expect("insert message 2");
conn.execute(
"INSERT INTO part (id, session_id, message_id, time_created, data) VALUES (?1, ?2, ?3, ?4, ?5)",
("prt_1", "ses_1", "msg_1", 1000_i64, r#"{"type":"text","text":"Hello"}"#),
)
.expect("insert part 1");
conn.execute(
"INSERT INTO part (id, session_id, message_id, time_created, data) VALUES (?1, ?2, ?3, ?4, ?5)",
(
"prt_2",
"ses_1",
"msg_2",
2000_i64,
r#"{"type":"tool","tool":"bash"}"#,
),
)
.expect("insert part 2");
conn.execute(
"INSERT INTO part (id, session_id, message_id, time_created, data) VALUES (?1, ?2, ?3, ?4, ?5)",
(
"prt_3",
"ses_1",
"msg_2",
2001_i64,
r#"{"type":"text","text":"Done"}"#,
),
)
.expect("insert part 3");
drop(conn);
let source = format!("sqlite:{}:ses_1", db_path.display());
let messages = load_messages_sqlite(&source).expect("load sqlite messages");
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].role, "user");
assert_eq!(messages[0].content, "Hello");
assert_eq!(messages[0].ts, Some(1000));
assert_eq!(messages[1].role, "assistant");
assert_eq!(messages[1].content, "[Tool: bash]\nDone");
assert_eq!(messages[1].ts, Some(2000));
}
#[test]
fn delete_session_sqlite_removes_session() {
let _guard = opencode_env_lock().lock().expect("lock");
let temp = tempdir().expect("tempdir");
let original_xdg = std::env::var_os("XDG_DATA_HOME");
#[allow(deprecated)]
std::env::set_var("XDG_DATA_HOME", temp.path());
let base_dir = temp.path().join("opencode");
std::fs::create_dir_all(&base_dir).expect("create base dir");
let db_path = base_dir.join("opencode.db");
let conn = Connection::open(&db_path).expect("open sqlite db");
create_sqlite_schema(&conn);
conn.execute(
"INSERT INTO session (id, title, directory, time_created, time_updated) VALUES (?1, ?2, ?3, ?4, ?5)",
("ses_1", "Session", "/tmp/project-a", 1000_i64, 3000_i64),
)
.expect("insert session");
conn.execute(
"INSERT INTO message (id, session_id, time_created, data) VALUES (?1, ?2, ?3, ?4)",
("msg_1", "ses_1", 1000_i64, r#"{"role":"user"}"#),
)
.expect("insert message");
conn.execute(
"INSERT INTO part (id, session_id, message_id, time_created, data) VALUES (?1, ?2, ?3, ?4, ?5)",
("prt_1", "ses_1", "msg_1", 1000_i64, r#"{"type":"text","text":"Hello"}"#),
)
.expect("insert part");
drop(conn);
let source = format!("sqlite:{}:ses_1", db_path.display());
let deleted = delete_session_sqlite("ses_1", &source).expect("delete sqlite session");
assert!(deleted);
let conn = Connection::open(&db_path).expect("re-open sqlite db");
let remaining_sessions: i64 = conn
.query_row(
"SELECT COUNT(*) FROM session WHERE id = 'ses_1'",
[],
|row| row.get(0),
)
.expect("count sessions");
let remaining_messages: i64 = conn
.query_row(
"SELECT COUNT(*) FROM message WHERE session_id = 'ses_1'",
[],
|row| row.get(0),
)
.expect("count messages");
let remaining_parts: i64 = conn
.query_row(
"SELECT COUNT(*) FROM part WHERE session_id = 'ses_1'",
[],
|row| row.get(0),
)
.expect("count parts");
assert_eq!(remaining_sessions, 0);
assert_eq!(remaining_messages, 0);
assert_eq!(remaining_parts, 0);
#[allow(deprecated)]
if let Some(value) = original_xdg {
std::env::set_var("XDG_DATA_HOME", value);
} else {
std::env::remove_var("XDG_DATA_HOME");
}
}
#[test]
fn delete_session_sqlite_rejects_foreign_db_path() {
let _guard = opencode_env_lock().lock().expect("lock");
let temp = tempdir().expect("tempdir");
let original_xdg = std::env::var_os("XDG_DATA_HOME");
#[allow(deprecated)]
std::env::set_var("XDG_DATA_HOME", temp.path());
let expected_base_dir = temp.path().join("opencode");
std::fs::create_dir_all(&expected_base_dir).expect("create expected base dir");
let expected_db_path = expected_base_dir.join("opencode.db");
Connection::open(&expected_db_path).expect("create expected sqlite db");
let db_path = temp.path().join("foreign.db");
let conn = Connection::open(&db_path).expect("open sqlite db");
create_sqlite_schema(&conn);
conn.execute(
"INSERT INTO session (id, title, directory, time_created, time_updated) VALUES (?1, ?2, ?3, ?4, ?5)",
("ses_1", "Session", "/tmp/project", 1000_i64, 3000_i64),
)
.expect("insert session");
drop(conn);
let source = format!("sqlite:{}:ses_1", db_path.display());
let err = delete_session_sqlite("ses_1", &source).expect_err("should reject foreign db");
assert!(err.contains("expected OpenCode database"));
#[allow(deprecated)]
if let Some(value) = original_xdg {
std::env::set_var("XDG_DATA_HOME", value);
} else {
std::env::remove_var("XDG_DATA_HOME");
}
}
}
@@ -46,6 +46,15 @@ pub fn read_head_tail_lines(
}
pub fn parse_timestamp_to_ms(value: &Value) -> Option<i64> {
// Integer: milliseconds (>1e12) or seconds
if let Some(n) = value.as_i64() {
return Some(if n > 1_000_000_000_000 { n } else { n * 1000 });
}
if let Some(n) = value.as_f64() {
let n = n as i64;
return Some(if n > 1_000_000_000_000 { n } else { n * 1000 });
}
// RFC3339 string
let raw = value.as_str()?;
DateTime::parse_from_rfc3339(raw)
.ok()
@@ -71,6 +80,28 @@ pub fn extract_text(content: &Value) -> String {
}
fn extract_text_from_item(item: &Value) -> Option<String> {
let item_type = item.get("type").and_then(Value::as_str).unwrap_or("");
// tool_use: show tool name
if item_type == "tool_use" {
let name = item
.get("name")
.and_then(Value::as_str)
.unwrap_or("unknown");
return Some(format!("[Tool: {name}]"));
}
// tool_result: extract nested content
if item_type == "tool_result" {
if let Some(content) = item.get("content") {
let text = extract_text(content);
if !text.is_empty() {
return Some(text);
}
}
return None;
}
if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
return Some(text.to_string());
}
@@ -119,3 +150,25 @@ pub fn path_basename(value: &str) -> Option<String> {
.filter(|segment| !segment.is_empty())?;
Some(last.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parse_timestamp_to_ms_supports_integers_and_rfc3339() {
assert_eq!(
parse_timestamp_to_ms(&json!(1_771_061_953_033_i64)),
Some(1_771_061_953_033)
);
assert_eq!(
parse_timestamp_to_ms(&json!(1_771_061_953_i64)),
Some(1_771_061_953_000)
);
assert_eq!(
parse_timestamp_to_ms(&json!("1970-01-01T00:00:01Z")),
Some(1_000)
);
}
}
+3 -19
View File
@@ -14,7 +14,6 @@ import {
} from "@/components/providers/forms/ProviderForm";
import { UniversalProviderFormModal } from "@/components/universal/UniversalProviderFormModal";
import { UniversalProviderPanel } from "@/components/universal";
import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel";
import { providerPresets } from "@/config/claudeProviderPresets";
import { codexProviderPresets } from "@/config/codexProviderPresets";
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
@@ -44,7 +43,7 @@ export function AddProviderDialog({
// OpenCode and OpenClaw don't support universal providers
const showUniversalTab = appId !== "opencode" && appId !== "openclaw";
const [activeTab, setActiveTab] = useState<
"app-specific" | "universal" | "oauth"
"app-specific" | "universal"
>("app-specific");
const [universalFormOpen, setUniversalFormOpen] = useState(false);
const [selectedUniversalPreset, setSelectedUniversalPreset] =
@@ -256,14 +255,6 @@ export function AddProviderDialog({
{t("common.add")}
</Button>
</>
) : activeTab === "oauth" ? (
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
>
{t("common.close", { defaultValue: "关闭" })}
</Button>
) : (
<>
<Button
@@ -294,19 +285,16 @@ export function AddProviderDialog({
<Tabs
value={activeTab}
onValueChange={(v) =>
setActiveTab(v as "app-specific" | "universal" | "oauth")
setActiveTab(v as "app-specific" | "universal")
}
>
<TabsList className="grid w-full grid-cols-3 mb-6">
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="app-specific">
{t(`apps.${appId}`)} {t("provider.tabProvider")}
</TabsTrigger>
<TabsTrigger value="universal">
{t("provider.tabUniversal")}
</TabsTrigger>
<TabsTrigger value="oauth">
{t("provider.tabOAuth", { defaultValue: "OAuth 认证源" })}
</TabsTrigger>
</TabsList>
<TabsContent value="app-specific" className="mt-0">
@@ -323,10 +311,6 @@ export function AddProviderDialog({
<TabsContent value="universal" className="mt-0">
<UniversalProviderPanel />
</TabsContent>
<TabsContent value="oauth" className="mt-0">
<AuthCenterPanel />
</TabsContent>
</Tabs>
) : (
// OpenCode/OpenClaw: directly show form without tabs
+20 -20
View File
@@ -279,10 +279,10 @@ export const providerPresets: ProviderPreset[] = [
ANTHROPIC_AUTH_TOKEN: "",
API_TIMEOUT_MS: "3000000",
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1,
ANTHROPIC_MODEL: "MiniMax-M2.5",
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.5",
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2.5",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2.5",
ANTHROPIC_MODEL: "MiniMax-M2.7",
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.7",
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2.7",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2.7",
},
},
category: "cn_official",
@@ -305,10 +305,10 @@ export const providerPresets: ProviderPreset[] = [
ANTHROPIC_AUTH_TOKEN: "",
API_TIMEOUT_MS: "3000000",
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1,
ANTHROPIC_MODEL: "MiniMax-M2.5",
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.5",
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2.5",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2.5",
ANTHROPIC_MODEL: "MiniMax-M2.7",
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.7",
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2.7",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2.7",
},
},
category: "cn_official",
@@ -381,10 +381,10 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://api.siliconflow.cn",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "Pro/MiniMaxAI/MiniMax-M2.5",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "Pro/MiniMaxAI/MiniMax-M2.5",
ANTHROPIC_DEFAULT_SONNET_MODEL: "Pro/MiniMaxAI/MiniMax-M2.5",
ANTHROPIC_DEFAULT_OPUS_MODEL: "Pro/MiniMaxAI/MiniMax-M2.5",
ANTHROPIC_MODEL: "Pro/MiniMaxAI/MiniMax-M2.7",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "Pro/MiniMaxAI/MiniMax-M2.7",
ANTHROPIC_DEFAULT_SONNET_MODEL: "Pro/MiniMaxAI/MiniMax-M2.7",
ANTHROPIC_DEFAULT_OPUS_MODEL: "Pro/MiniMaxAI/MiniMax-M2.7",
},
},
category: "aggregator",
@@ -401,10 +401,10 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://api.siliconflow.com",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "MiniMaxAI/MiniMax-M2.5",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMaxAI/MiniMax-M2.5",
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMaxAI/MiniMax-M2.5",
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMaxAI/MiniMax-M2.5",
ANTHROPIC_MODEL: "MiniMaxAI/MiniMax-M2.7",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMaxAI/MiniMax-M2.7",
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMaxAI/MiniMax-M2.7",
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMaxAI/MiniMax-M2.7",
},
},
category: "aggregator",
@@ -732,10 +732,10 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://api.xiaomimimo.com/anthropic",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "mimo-v2-flash",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "mimo-v2-flash",
ANTHROPIC_DEFAULT_SONNET_MODEL: "mimo-v2-flash",
ANTHROPIC_DEFAULT_OPUS_MODEL: "mimo-v2-flash",
ANTHROPIC_MODEL: "mimo-v2-pro",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "mimo-v2-pro",
ANTHROPIC_DEFAULT_SONNET_MODEL: "mimo-v2-pro",
ANTHROPIC_DEFAULT_OPUS_MODEL: "mimo-v2-pro",
},
},
category: "cn_official",
+47 -47
View File
@@ -340,8 +340,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "MiniMax-M2.5",
name: "MiniMax M2.5",
id: "MiniMax-M2.7",
name: "MiniMax M2.7",
contextWindow: 200000,
cost: { input: 0.001, output: 0.004 },
},
@@ -364,8 +364,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
suggestedDefaults: {
model: { primary: "minimax/MiniMax-M2.5" },
modelCatalog: { "minimax/MiniMax-M2.5": { alias: "MiniMax" } },
model: { primary: "minimax/MiniMax-M2.7" },
modelCatalog: { "minimax/MiniMax-M2.7": { alias: "MiniMax" } },
},
},
{
@@ -378,8 +378,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "MiniMax-M2.5",
name: "MiniMax M2.5",
id: "MiniMax-M2.7",
name: "MiniMax M2.7",
contextWindow: 200000,
cost: { input: 0.001, output: 0.004 },
},
@@ -402,8 +402,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
suggestedDefaults: {
model: { primary: "minimax-en/MiniMax-M2.5" },
modelCatalog: { "minimax-en/MiniMax-M2.5": { alias: "MiniMax" } },
model: { primary: "minimax-en/MiniMax-M2.7" },
modelCatalog: { "minimax-en/MiniMax-M2.7": { alias: "MiniMax" } },
},
},
{
@@ -563,8 +563,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "mimo-v2-flash",
name: "MiMo V2 Flash",
id: "mimo-v2-pro",
name: "MiMo V2 Pro",
contextWindow: 128000,
cost: { input: 0.001, output: 0.004 },
},
@@ -581,8 +581,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
suggestedDefaults: {
model: { primary: "xiaomimimo/mimo-v2-flash" },
modelCatalog: { "xiaomimimo/mimo-v2-flash": { alias: "MiMo" } },
model: { primary: "xiaomimimo/mimo-v2-pro" },
modelCatalog: { "xiaomimimo/mimo-v2-pro": { alias: "MiMo" } },
},
},
@@ -599,13 +599,13 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 5, output: 25 },
},
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 3, output: 15 },
},
],
@@ -643,13 +643,13 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 5, output: 25 },
},
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 3, output: 15 },
},
],
@@ -687,13 +687,13 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
{
id: "anthropic/claude-opus-4.6",
name: "Claude Opus 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 5, output: 25 },
},
{
id: "anthropic/claude-sonnet-4.6",
name: "Claude Sonnet 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 3, output: 15 },
},
],
@@ -767,8 +767,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "Pro/MiniMaxAI/MiniMax-M2.5",
name: "MiniMax M2.5",
id: "Pro/MiniMaxAI/MiniMax-M2.7",
name: "MiniMax M2.7",
contextWindow: 200000,
cost: { input: 0.001, output: 0.004 },
},
@@ -787,9 +787,9 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
suggestedDefaults: {
model: { primary: "siliconflow/Pro/MiniMaxAI/MiniMax-M2.5" },
model: { primary: "siliconflow/Pro/MiniMaxAI/MiniMax-M2.7" },
modelCatalog: {
"siliconflow/Pro/MiniMaxAI/MiniMax-M2.5": { alias: "MiniMax" },
"siliconflow/Pro/MiniMaxAI/MiniMax-M2.7": { alias: "MiniMax" },
},
},
},
@@ -803,8 +803,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "MiniMaxAI/MiniMax-M2.5",
name: "MiniMax M2.5",
id: "MiniMaxAI/MiniMax-M2.7",
name: "MiniMax M2.7",
contextWindow: 200000,
cost: { input: 0.001, output: 0.004 },
},
@@ -823,9 +823,9 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
suggestedDefaults: {
model: { primary: "siliconflow-en/MiniMaxAI/MiniMax-M2.5" },
model: { primary: "siliconflow-en/MiniMaxAI/MiniMax-M2.7" },
modelCatalog: {
"siliconflow-en/MiniMaxAI/MiniMax-M2.5": { alias: "MiniMax" },
"siliconflow-en/MiniMaxAI/MiniMax-M2.7": { alias: "MiniMax" },
},
},
},
@@ -909,13 +909,13 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 5, output: 25 },
},
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 3, output: 15 },
},
],
@@ -954,13 +954,13 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 5, output: 25 },
},
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 3, output: 15 },
},
],
@@ -1000,13 +1000,13 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 5, output: 25 },
},
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 3, output: 15 },
},
],
@@ -1046,13 +1046,13 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 5, output: 25 },
},
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 3, output: 15 },
},
],
@@ -1092,13 +1092,13 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 5, output: 25 },
},
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 3, output: 15 },
},
],
@@ -1138,13 +1138,13 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 5, output: 25 },
},
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 3, output: 15 },
},
],
@@ -1184,13 +1184,13 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 5, output: 25 },
},
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 3, output: 15 },
},
],
@@ -1230,13 +1230,13 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 5, output: 25 },
},
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 3, output: 15 },
},
],
@@ -1278,7 +1278,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 5, output: 25 },
},
],
@@ -1316,7 +1316,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 5, output: 25 },
},
],
@@ -1354,7 +1354,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 5, output: 25 },
},
],
@@ -1393,13 +1393,13 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
{
id: "anthropic.claude-opus-4-6-20250514-v1:0",
name: "Claude Opus 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
},
{
id: "anthropic.claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextWindow: 200000,
contextWindow: 1000000,
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
},
{
+6 -6
View File
@@ -42,8 +42,8 @@ export const OPENCODE_PRESET_MODEL_VARIANTS: Record<
> = {
"@ai-sdk/openai-compatible": [
{
id: "MiniMax-M2.5",
name: "MiniMax M2.5",
id: "MiniMax-M2.7",
name: "MiniMax M2.7",
contextLimit: 204800,
outputLimit: 131072,
modalities: { input: ["text"], output: ["text"] },
@@ -170,7 +170,7 @@ export const OPENCODE_PRESET_MODEL_VARIANTS: Record<
{
id: "global.anthropic.claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextLimit: 200000,
contextLimit: 1000000,
outputLimit: 64000,
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
@@ -634,7 +634,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
setCacheKey: true,
},
models: {
"MiniMax-M2.5": { name: "MiniMax M2.5" },
"MiniMax-M2.7": { name: "MiniMax M2.7" },
},
},
category: "cn_official",
@@ -667,7 +667,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
setCacheKey: true,
},
models: {
"MiniMax-M2.5": { name: "MiniMax M2.5" },
"MiniMax-M2.7": { name: "MiniMax M2.7" },
},
},
category: "cn_official",
@@ -753,7 +753,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
setCacheKey: true,
},
models: {
"mimo-v2-flash": { name: "MiMo V2 Flash" },
"mimo-v2-pro": { name: "MiMo V2 Pro" },
},
},
category: "cn_official",
+1 -1
View File
@@ -1,7 +1,7 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/index.html", "./src/**/*.{js,ts,jsx,tsx}"],
darkMode: ["selector", "class"],
darkMode: ["selector", ".dark"],
theme: {
extend: {
colors: {