Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

- 合并冲突的双份 schema v10 迁移:投影表不再被 `010_kb_max_documents` 抢先抬版本而跳过;启动时幂等补建 `thread_messages` / `thread_history_projection`
- Windows CI:`test_create_keeps_user_workspace_dir` 等真实 Harness 单测关闭 memory,避免 GC 线程与 `close()` 争用 SQLite 触发 access violation
- OpenCode Go 合并为稳定的 `opencode-go` Provider,并按模型显式路由 OpenAI Responses、Chat Completions 与 Anthropic Messages;补齐推理、上下文、输出上限和多模态元数据,草稿测试与初始化/设置保存不再丢失模型级协议字段
- 兼容已持久化的旧 OpenCode Go 模型:`grok-4.5`/`grok-4.6` 自动选择 Responses,MiniMax/Qwen 自动选择 Anthropic Messages,无需自动改写现有 Provider、凭证或任务配置

## [0.9.29] - 2026-08-27

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,36 @@ interface LocalServiceCardProps {
}

function presetModelsToRows(preset: ProviderPreset): ProviderModel[] {
return preset.models.map((m) => ({
id: m.id,
name: m.name,
enabled: false,
embedding: preset.id === "onnx" ? true : undefined,
task: preset.id === "onnx" ? "embedding" : undefined,
input: m.input?.length ? m.input : ["text"],
thinking: null,
}));
return preset.models.map((m) => {
const row: ProviderModel = {
id: m.id,
name: m.name,
enabled: false,
embedding: preset.id === "onnx" ? true : undefined,
task: preset.id === "onnx" ? "embedding" : undefined,
input: m.input?.length ? m.input : ["text"],
thinking: null,
};
if (m.context_window != null) row.context_window = m.context_window;
if (m.max_input_tokens != null) {
row.max_input_tokens = m.max_input_tokens;
}
const maxOutput = m.max_output_tokens ?? m.max_tokens;
if (maxOutput != null) row.max_tokens = maxOutput;
if (m.reasoning != null) row.reasoning = m.reasoning;
if (m.reasoning_config !== undefined) {
row.reasoning_config = m.reasoning_config;
}
if (m.wire_api != null) row.wire_api = m.wire_api;
if (m.endpoint_base_url != null) {
row.endpoint_base_url = m.endpoint_base_url;
}
if (m.native_tool_search != null) {
row.native_tool_search = m.native_tool_search;
}
if (m.options !== undefined) row.options = m.options;
return row;
});
}

export function LocalServiceCard({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import { useTranslation } from "react-i18next";
import { request } from "../../../../../api/request";
import type { ProviderModel, ProviderRow } from "../../useProviders";
import { isEmbeddingModel } from "../../useProviders";
import { fetchProviderModels, testProviderDraft } from "../../providerApi";
import {
fetchProviderModels,
testProviderDraft,
toProviderProbeModel,
} from "../../providerApi";
import { ModelListEditor } from "./ModelListEditor";
import styles from "../../index.module.less";

Expand Down Expand Up @@ -83,13 +87,18 @@ export function CustomProviderModal({
if (!key) {
return { ok: false, error: t("models.pleaseEnterApiKey") };
}
const model = models.find((item) => item.id === modelId);
if (!model) {
return { ok: false, error: t("models.testDraftNeedModel") };
}
return testProviderDraft({
name: (values.name || "draft").trim(),
kind: values.kind,
api_key: key,
base_url: values.base_url?.trim() || null,
model_id: modelId,
embedding: isEmbeddingModel(models.find((m) => m.id === modelId)),
model: toProviderProbeModel(model),
embedding: isEmbeddingModel(model),
});
};

Expand Down Expand Up @@ -156,16 +165,14 @@ export function CustomProviderModal({
setSaving(true);

const modelEntries = models.map((m) => {
const { max_output_tokens, ...persisted } = m;
const entry: Record<string, unknown> = {
id: m.id,
name: m.name,
enabled: m.enabled,
...persisted,
input: m.input?.length ? m.input : ["text"],
thinking: null,
thinking: m.thinking ?? null,
};
if (m.max_tokens != null) entry.max_tokens = m.max_tokens;
if (m.context_window != null) entry.context_window = m.context_window;
if (m.reasoning) entry.reasoning = true;
const maxOutput = m.max_tokens ?? max_output_tokens;
if (maxOutput != null) entry.max_tokens = maxOutput;
if (isEmbeddingModel(m)) {
entry.embedding = true;
entry.task = "embedding";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type { ProviderRow, ProviderModel } from "../../useProviders";
import { isEmbeddingModel } from "../../useProviders";
import { isOnnxProviderRow } from "../../presetUtils";
import { ModelMetaTags } from "../../modelMeta";
import { buildProviderModelEntry } from "../../modelEntry";
import styles from "../../index.module.less";

export interface LocalModelDownloadControl {
Expand Down Expand Up @@ -230,63 +231,12 @@ export function ModelListEditor({
}
};

const buildModelEntry = (values: Record<string, unknown>): ProviderModel => {
const id = (values.id as string).trim();
const name = (values.name as string | undefined)?.trim() || id;
const isOnnx = isOnnxProviderRow(provider);
const embedding = isOnnx || values.embedding === true;
const entry: ProviderModel = {
id,
name,
enabled: true,
input: ["text"],
thinking: null,
};
if (embedding) {
entry.embedding = true;
entry.task = "embedding";
return entry;
}
if (values.input != null) {
entry.input = (values.input as string[] | undefined) || ["text"];
}
if (values.context_window != null)
entry.context_window = values.context_window as number;
if (values.max_tokens != null)
entry.max_tokens = values.max_tokens as number;
if (values.reasoning != null) entry.reasoning = values.reasoning as boolean;
if (values.reasoning === true) {
const efforts = (values.reasoning_efforts as string[] | undefined) || [];
entry.reasoning_config = {
supported: true,
toggle: values.reasoning_toggle !== false,
default_mode:
(values.reasoning_default_mode as "auto" | "enabled" | "disabled") ||
"auto",
efforts,
default_effort:
(values.reasoning_default_effort as string | undefined) || null,
effort_type:
(values.reasoning_effort_type as "enum" | "token_budget") || "enum",
adapter:
(values.reasoning_adapter as
| "status_only"
| "thinking"
| "thinking_nested_effort"
| "openai_reasoning_effort"
| "anthropic_adaptive"
| "anthropic_budget"
| "dashscope"
| "openrouter") || "thinking",
};
}
return entry;
};

const handleAddModel = async () => {
try {
const values = await form.validateFields();
const entry = buildModelEntry(values as Record<string, unknown>);
const entry = buildProviderModelEntry(values as Record<string, unknown>, {
isOnnx,
});
if (models.some((m) => m.id === entry.id)) {
message.error(t("models.initialModelDuplicate", { name: entry.id }));
return;
Expand All @@ -306,8 +256,11 @@ export function ModelListEditor({
if (!editingModelId) return;
try {
const values = await form.validateFields();
const entry = buildModelEntry(values as Record<string, unknown>);
const existing = models.find((m) => m.id === editingModelId);
const entry = buildProviderModelEntry(values as Record<string, unknown>, {
existing,
isOnnx,
});
if (existing) {
entry.enabled = existing.enabled;
}
Expand All @@ -327,7 +280,7 @@ export function ModelListEditor({
name: model.name,
context_window:
(model as Record<string, unknown>).context_window ?? undefined,
max_tokens: (model as Record<string, unknown>).max_tokens ?? undefined,
max_tokens: model.max_tokens ?? model.max_output_tokens ?? undefined,
reasoning: (model as Record<string, unknown>).reasoning ?? undefined,
reasoning_toggle: model.reasoning_config?.toggle ?? true,
reasoning_efforts: model.reasoning_config?.efforts ?? [],
Expand All @@ -342,6 +295,7 @@ export function ModelListEditor({
const hasAdvanced =
(model as Record<string, unknown>).context_window != null ||
(model as Record<string, unknown>).max_tokens != null ||
(model as Record<string, unknown>).max_output_tokens != null ||
(model as Record<string, unknown>).reasoning != null;
setShowAdvanced(hasAdvanced);
};
Expand Down Expand Up @@ -421,7 +375,7 @@ export function ModelListEditor({
includeText
input={m.input}
context_window={m.context_window}
max_tokens={m.max_tokens}
max_tokens={m.max_tokens ?? m.max_output_tokens}
reasoning={m.reasoning}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import type {
} from "../../useProviders";
import { isEmbeddingModel } from "../../useProviders";
import { CodexOAuthConnect } from "../CodexOAuthConnect";
import { fetchProviderModels, testProviderDraft } from "../../providerApi";
import {
fetchProviderModels,
testProviderDraft,
toProviderProbeModel,
} from "../../providerApi";
import { ModelListEditor } from "./ModelListEditor";
import styles from "../../index.module.less";

Expand Down Expand Up @@ -81,8 +85,23 @@ export function PresetProviderModal({
thinking: null,
};
if (meta.reasoning) entry.reasoning = true;
if (ctx) entry.context_window = ctx;
if (meta.max_tokens) entry.max_tokens = meta.max_tokens;
if (m.reasoning_config !== undefined) {
entry.reasoning_config = m.reasoning_config;
}
if (ctx != null) entry.context_window = ctx;
if (m.max_input_tokens != null) {
entry.max_input_tokens = m.max_input_tokens;
}
const maxOutput = m.max_output_tokens ?? m.max_tokens;
if (maxOutput != null) entry.max_tokens = maxOutput;
if (m.wire_api != null) entry.wire_api = m.wire_api;
if (m.endpoint_base_url != null) {
entry.endpoint_base_url = m.endpoint_base_url;
}
if (m.native_tool_search != null) {
entry.native_tool_search = m.native_tool_search;
}
if (m.options !== undefined) entry.options = m.options;
return entry;
});
setDraftModels(baseModels);
Expand Down Expand Up @@ -157,13 +176,18 @@ export function PresetProviderModal({
if (!key) {
return { ok: false, error: t("models.pleaseEnterApiKey") };
}
const model = draftModels.find((item) => item.id === modelId);
if (!model) {
return { ok: false, error: t("models.testDraftNeedModel") };
}
return testProviderDraft({
name: values.name.trim(),
kind: preset.protocol,
api_key: key,
base_url: values.base_url?.trim() || preset.base_url,
model_id: modelId,
embedding: isEmbeddingModel(draftModels.find((m) => m.id === modelId)),
model: toProviderProbeModel(model),
embedding: isEmbeddingModel(model),
});
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,27 @@
* with local model list, download, and delete UI
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { App, Button, Divider, Form, Input, Modal, Progress, Select } from "antd";
import {
App,
Button,
Divider,
Form,
Input,
Modal,
Progress,
Select,
} from "antd";

import { Download, Key, Loader2, Trash2, X, Zap } from "lucide-react";
import { useTranslation } from "react-i18next";
import { request } from "../../../../../api/request";
import type { ProviderRow, ProviderModel } from "../../useProviders";
import { isEmbeddingModel } from "../../useProviders";
import { fetchProviderModels, testProviderDraft } from "../../providerApi";
import {
fetchProviderModels,
testProviderDraft,
toProviderProbeModel,
} from "../../providerApi";
import { getProviderDocs } from "../../../../../assets/providers";
import { ollamaModelApi } from "../../../../../api/modules/ollamaModel";
import { onnxModelApi } from "../../../../../api/modules/onnxModel";
Expand Down Expand Up @@ -767,9 +780,12 @@ export function ProviderConfigModal({
return;
}

const embedding = isEmbeddingModel(
draftModels.find((m) => m.id === modelId),
);
const selectedModel = draftModels.find((m) => m.id === modelId);
if (!selectedModel) {
message.warning(t("models.testDraftNeedModel"));
return;
}
const embedding = isEmbeddingModel(selectedModel);
const result =
useDraft || !hasApiKey
? await testProviderDraft({
Expand All @@ -778,6 +794,7 @@ export function ProviderConfigModal({
api_key: draftApiKey || provider.api_key || undefined,
base_url: draftBaseUrl || provider.base_url,
model_id: modelId,
model: toProviderProbeModel(selectedModel),
embedding,
})
: await request<{
Expand Down
55 changes: 55 additions & 0 deletions dashboard/src/pages/Settings/Models/modelEntry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { buildProviderModelEntry } from "./modelEntry";
import type { ProviderModel } from "./useProviders";

describe("buildProviderModelEntry", () => {
it("preserves opaque provider metadata while updating visible fields", () => {
const existing: ProviderModel & {
provider_extension: Record<string, unknown>;
} = {
id: "grok-4.6",
name: "Grok 4.6",
enabled: false,
input: ["text"],
thinking: true,
context_window: 1_000_000,
max_tokens: 65_536,
max_input_tokens: 1_000_000,
wire_api: "openai_responses",
endpoint_base_url: "https://opencode.ai/zen/go/v1",
native_tool_search: true,
options: { thinking: { type: "enabled", budget_tokens: 8192 } },
provider_extension: { cache_control: "ephemeral" },
};

const updated = buildProviderModelEntry(
{
id: "grok-4.6",
name: "Grok 4.6 (reviewed)",
input: ["text", "image"],
context_window: 2_000_000,
max_tokens: 131_072,
reasoning: false,
embedding: false,
},
{ existing, isOnnx: false },
);

expect(updated).toEqual(
expect.objectContaining({
name: "Grok 4.6 (reviewed)",
enabled: false,
input: ["text", "image"],
thinking: true,
context_window: 2_000_000,
max_tokens: 131_072,
max_input_tokens: 1_000_000,
wire_api: "openai_responses",
endpoint_base_url: "https://opencode.ai/zen/go/v1",
native_tool_search: true,
options: { thinking: { type: "enabled", budget_tokens: 8192 } },
provider_extension: { cache_control: "ephemeral" },
}),
);
});
});
Loading
Loading