feat(admin): harden provider draft model conflicts
This commit is contained in:
@@ -519,15 +519,21 @@
|
||||
页面本身不直接拼 Git 命令,所有写仓库动作都经由 CRM 服务端完成。
|
||||
</p>
|
||||
|
||||
<div class="statusbar" id="recent-template-meta">最近成功模板:暂无。首次可以先按一份已有 provider 作为参考修改。</div>
|
||||
|
||||
<div class="field-grid two">
|
||||
<label>Provider ID
|
||||
<label>Provider ID(自动生成,可手改)
|
||||
<input id="draft-provider-id" type="text" placeholder="openai-zhongzhuan">
|
||||
<span class="hint">根据 display name / base url / models 自动生成,并尽量避免与现有 provider_id 冲突。</span>
|
||||
</label>
|
||||
<label>Display Name
|
||||
<input id="draft-display-name" type="text" placeholder="OpenAI 中转">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="statusbar" id="provider-id-preview">Provider ID 预览:等待填写模型信息。</div>
|
||||
<div class="statusbar" id="model-conflicts">同模型已存在:当前未发现冲突。</div>
|
||||
|
||||
<div class="field-grid two">
|
||||
<label>Platform
|
||||
<input id="draft-platform" type="text" placeholder="openai">
|
||||
@@ -597,6 +603,15 @@
|
||||
|
||||
<script>
|
||||
const storageKey = "sub2api-crm-provider-admin-v1";
|
||||
const lastPublishedTemplateKey = "sub2api-crm-provider-admin:last-published-template";
|
||||
const sampleDraftTemplate = {
|
||||
provider_id: "openai-zhongzhuan",
|
||||
display_name: "OpenAI 中转",
|
||||
platform: "openai",
|
||||
base_url: "https://api.example.com/v1",
|
||||
smoke_test_model: "gpt-5.4",
|
||||
supported_models: ["gpt-5.4", "gpt-5.4-mini"],
|
||||
};
|
||||
const state = {
|
||||
packs: [],
|
||||
hosts: [],
|
||||
@@ -604,6 +619,8 @@
|
||||
selectedProvider: null,
|
||||
drafts: [],
|
||||
currentDraftID: "",
|
||||
draftTemplateHydrated: false,
|
||||
draftProviderIDAuto: true,
|
||||
};
|
||||
|
||||
const apiBaseInput = document.getElementById("api-base");
|
||||
@@ -643,6 +660,9 @@
|
||||
const draftBaseURLInput = document.getElementById("draft-base-url");
|
||||
const draftModelsInput = document.getElementById("draft-models");
|
||||
const draftCommitMessageInput = document.getElementById("draft-commit-message");
|
||||
const recentTemplateMeta = document.getElementById("recent-template-meta");
|
||||
const providerIdPreview = document.getElementById("provider-id-preview");
|
||||
const modelConflicts = document.getElementById("model-conflicts");
|
||||
|
||||
function defaultApiBase() {
|
||||
return `${window.location.origin}/portal-admin-api`;
|
||||
@@ -705,6 +725,213 @@
|
||||
metricProviderID.textContent = providerIDInput.value || "-";
|
||||
}
|
||||
|
||||
function parseDraftModels() {
|
||||
return draftModelsInput.value
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function draftFieldsAreEmpty() {
|
||||
return ![
|
||||
draftProviderIDInput.value,
|
||||
draftDisplayNameInput.value,
|
||||
draftPlatformInput.value,
|
||||
draftSmokeModelInput.value,
|
||||
draftBaseURLInput.value,
|
||||
draftModelsInput.value,
|
||||
].some((value) => String(value || "").trim());
|
||||
}
|
||||
|
||||
function slugifyProviderPart(value) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/https?:\/\//g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function inferRouteSuffix(displayName, baseURL) {
|
||||
const display = String(displayName || "").toLowerCase();
|
||||
const url = String(baseURL || "").toLowerCase();
|
||||
if (display.includes("官方") || display.includes("official") || url.includes("deepseek.com")) {
|
||||
return "official";
|
||||
}
|
||||
if (display.includes("中转") || display.includes("relay")) {
|
||||
const host = slugifyProviderPart(url.split("/")[0] || "");
|
||||
if (host && !["api", "com", "www", "example"].includes(host)) {
|
||||
return host;
|
||||
}
|
||||
return "relay";
|
||||
}
|
||||
const host = slugifyProviderPart(url.split("/")[0] || "");
|
||||
return host || "";
|
||||
}
|
||||
|
||||
function draftPrimaryModel() {
|
||||
return parseDraftModels()[0] || draftSmokeModelInput.value.trim() || "";
|
||||
}
|
||||
|
||||
function existingProviderIDs() {
|
||||
const ids = new Set();
|
||||
state.providers.forEach((provider) => ids.add((provider.provider_id || "").trim()));
|
||||
state.drafts.forEach((draft) => ids.add((draft.provider_id || "").trim()));
|
||||
return ids;
|
||||
}
|
||||
|
||||
function detectModelConflicts(models, ignoreProviderID = "") {
|
||||
const normalizedModels = models.map((value) => value.trim()).filter(Boolean);
|
||||
const ignored = ignoreProviderID.trim();
|
||||
const conflicts = [];
|
||||
const seen = new Set();
|
||||
const scan = (entries, source) => {
|
||||
entries.forEach((entry) => {
|
||||
const providerID = (entry.provider_id || entry.providerID || "").trim();
|
||||
if (!providerID || providerID === ignored) {
|
||||
return;
|
||||
}
|
||||
const supportedModels = Array.isArray(entry.supported_models || entry.supportedModels)
|
||||
? (entry.supported_models || entry.supportedModels)
|
||||
: [];
|
||||
const matched = supportedModels.filter((model) => normalizedModels.includes(String(model || "").trim()));
|
||||
if (!matched.length) {
|
||||
return;
|
||||
}
|
||||
const key = `${providerID}:${matched.join(",")}`;
|
||||
if (seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
conflicts.push({
|
||||
providerID,
|
||||
displayName: entry.display_name || entry.displayName || providerID,
|
||||
matchedModels: matched,
|
||||
source,
|
||||
});
|
||||
});
|
||||
};
|
||||
scan(state.providers, "provider");
|
||||
scan(state.drafts, "draft");
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
function updateConflictSummary() {
|
||||
const conflicts = detectModelConflicts(parseDraftModels(), draftProviderIDInput.value);
|
||||
if (!conflicts.length) {
|
||||
setStatus(modelConflicts, "同模型已存在:当前未发现冲突。", "success");
|
||||
return conflicts;
|
||||
}
|
||||
const labels = conflicts
|
||||
.map((item) => `${item.matchedModels.join("/")} -> ${item.providerID}`)
|
||||
.join(";");
|
||||
setStatus(modelConflicts, `同模型已存在:${labels}。通常不需要因为“官方 / 中转”再重复新增 provider,优先复用或修改已有 provider。`, "warning");
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
function buildSuggestedProviderID() {
|
||||
const primaryModel = draftPrimaryModel();
|
||||
const displayName = draftDisplayNameInput.value.trim();
|
||||
const baseURL = draftBaseURLInput.value.trim();
|
||||
const conflicts = detectModelConflicts(parseDraftModels(), draftProviderIDInput.value);
|
||||
if (primaryModel && conflicts.length === 1) {
|
||||
return conflicts[0].providerID;
|
||||
}
|
||||
const baseCandidate = slugifyProviderPart(primaryModel) || slugifyProviderPart(displayName) || "provider";
|
||||
const suffix = inferRouteSuffix(displayName, baseURL);
|
||||
let candidate = [baseCandidate, suffix].filter(Boolean).join("-");
|
||||
if (!candidate) {
|
||||
candidate = "provider";
|
||||
}
|
||||
const ids = existingProviderIDs();
|
||||
const currentValue = draftProviderIDInput.value.trim();
|
||||
if (currentValue) {
|
||||
ids.delete(currentValue);
|
||||
}
|
||||
if (!ids.has(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
let index = 2;
|
||||
while (ids.has(`${candidate}-${index}`)) {
|
||||
index += 1;
|
||||
}
|
||||
return `${candidate}-${index}`;
|
||||
}
|
||||
|
||||
function syncDraftHelperState(forceProviderID = false) {
|
||||
const suggested = buildSuggestedProviderID();
|
||||
setStatus(providerIdPreview, `providerIdPreview: ${suggested}`, "note");
|
||||
updateConflictSummary();
|
||||
if (forceProviderID || state.draftProviderIDAuto || !draftProviderIDInput.value.trim()) {
|
||||
draftProviderIDInput.value = suggested;
|
||||
}
|
||||
}
|
||||
|
||||
function rememberLastPublishedTemplate() {
|
||||
const payload = {
|
||||
provider_id: draftProviderIDInput.value.trim(),
|
||||
display_name: draftDisplayNameInput.value.trim(),
|
||||
platform: draftPlatformInput.value.trim(),
|
||||
base_url: draftBaseURLInput.value.trim(),
|
||||
smoke_test_model: draftSmokeModelInput.value.trim(),
|
||||
supported_models: parseDraftModels(),
|
||||
saved_at: new Date().toISOString(),
|
||||
};
|
||||
localStorage.setItem(lastPublishedTemplateKey, JSON.stringify(payload));
|
||||
renderRecentTemplateMeta(payload);
|
||||
}
|
||||
|
||||
function readLastPublishedTemplate() {
|
||||
try {
|
||||
const raw = localStorage.getItem(lastPublishedTemplateKey);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderRecentTemplateMeta(template) {
|
||||
if (!template) {
|
||||
setStatus(recentTemplateMeta, "最近成功模板:暂无。首次可以先按一份已有 provider 作为参考修改。", "note");
|
||||
return;
|
||||
}
|
||||
const models = Array.isArray(template.supported_models) ? template.supported_models.join(", ") : "";
|
||||
setStatus(recentTemplateMeta, `最近成功模板:${template.provider_id || "-"} · ${template.display_name || "-"} · ${models || "-"}`, "success");
|
||||
}
|
||||
|
||||
function fillDraftForm(draft, options = {}) {
|
||||
const { preserveCommitMessage = false, lockProviderID = false } = options;
|
||||
state.currentDraftID = draft.draft_id || "";
|
||||
state.draftProviderIDAuto = !lockProviderID;
|
||||
draftProviderIDInput.value = draft.provider_id || "";
|
||||
draftDisplayNameInput.value = draft.display_name || "";
|
||||
draftPlatformInput.value = draft.platform || "";
|
||||
draftSmokeModelInput.value = draft.smoke_test_model || "";
|
||||
draftBaseURLInput.value = draft.base_url || "";
|
||||
draftModelsInput.value = Array.isArray(draft.supported_models) ? draft.supported_models.join(",") : "";
|
||||
if (!preserveCommitMessage && !draftCommitMessageInput.value.trim()) {
|
||||
draftCommitMessageInput.value = `feat(pack): publish provider draft ${draft.provider_id || ""}`.trim();
|
||||
}
|
||||
syncDraftHelperState();
|
||||
}
|
||||
|
||||
function hydrateDraftTemplateIfNeeded() {
|
||||
if (state.draftTemplateHydrated || !draftFieldsAreEmpty()) {
|
||||
return;
|
||||
}
|
||||
const template = readLastPublishedTemplate()
|
||||
|| state.drafts[0]
|
||||
|| state.providers[0]
|
||||
|| sampleDraftTemplate;
|
||||
fillDraftForm(template, { preserveCommitMessage: true });
|
||||
state.currentDraftID = "";
|
||||
state.draftTemplateHydrated = true;
|
||||
renderRecentTemplateMeta(template === sampleDraftTemplate ? null : template);
|
||||
}
|
||||
|
||||
function saveConfig() {
|
||||
localStorage.setItem(storageKey, JSON.stringify({
|
||||
apiBase: apiBaseInput.value.trim(),
|
||||
@@ -893,28 +1120,16 @@
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
fillDraftForm(draft);
|
||||
fillDraftForm(draft, { lockProviderID: true });
|
||||
importResult.textContent = JSON.stringify(draft.manifest || {}, null, 2);
|
||||
setStatus(draftStatus, `已回填服务端草稿:${draft.draft_id}`, "success");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function fillDraftForm(draft) {
|
||||
state.currentDraftID = draft.draft_id || "";
|
||||
draftProviderIDInput.value = draft.provider_id || "";
|
||||
draftDisplayNameInput.value = draft.display_name || "";
|
||||
draftPlatformInput.value = draft.platform || "";
|
||||
draftSmokeModelInput.value = draft.smoke_test_model || "";
|
||||
draftBaseURLInput.value = draft.base_url || "";
|
||||
draftModelsInput.value = Array.isArray(draft.supported_models) ? draft.supported_models.join(",") : "";
|
||||
if (!draftCommitMessageInput.value.trim()) {
|
||||
draftCommitMessageInput.value = `feat(pack): publish provider draft ${draft.provider_id || ""}`.trim();
|
||||
}
|
||||
}
|
||||
|
||||
function clearDraftFormSelection() {
|
||||
state.currentDraftID = "";
|
||||
state.draftProviderIDAuto = true;
|
||||
}
|
||||
|
||||
async function loadCatalog() {
|
||||
@@ -964,6 +1179,7 @@
|
||||
syncMetrics();
|
||||
saveCurrentIDsOnly();
|
||||
await loadServerDrafts();
|
||||
hydrateDraftTemplateIfNeeded();
|
||||
setStatus(catalogStatus, `目录已加载:${state.packs.length} 个 pack,${state.providers.length} 个 provider。`, "success");
|
||||
} catch (error) {
|
||||
setStatus(catalogStatus, `加载目录失败:${error.message}`, "danger");
|
||||
@@ -1162,7 +1378,6 @@
|
||||
const payload = buildServerDraftPayload();
|
||||
const result = await requestJSON("/api/provider-drafts", {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
importResult.textContent = JSON.stringify(result.draft || result, null, 2);
|
||||
@@ -1186,7 +1401,6 @@
|
||||
const payload = buildServerDraftPayload();
|
||||
const result = await requestJSON(`/api/provider-drafts/${encodeURIComponent(state.currentDraftID)}`, {
|
||||
method: "PUT",
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
importResult.textContent = JSON.stringify(result.draft || result, null, 2);
|
||||
@@ -1208,7 +1422,6 @@
|
||||
}
|
||||
await requestJSON(`/api/provider-drafts/${encodeURIComponent(state.currentDraftID)}`, {
|
||||
method: "DELETE",
|
||||
headers: authHeaders(),
|
||||
});
|
||||
const deletedDraftID = state.currentDraftID;
|
||||
clearDraftFormSelection();
|
||||
@@ -1230,10 +1443,10 @@
|
||||
}
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
const payload = await requestJSON(`/api/provider-drafts${suffix}`, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
state.drafts = Array.isArray(payload.provider_drafts) ? payload.provider_drafts : [];
|
||||
renderServerDrafts();
|
||||
hydrateDraftTemplateIfNeeded();
|
||||
} catch (error) {
|
||||
serverDraftList.innerHTML = `<div class="empty">加载草稿失败:${escapeHTML(error.message)}</div>`;
|
||||
}
|
||||
@@ -1248,12 +1461,12 @@
|
||||
}
|
||||
const result = await requestJSON(`/api/provider-drafts/${encodeURIComponent(state.currentDraftID)}/publish`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({
|
||||
commit_message: draftCommitMessageInput.value.trim(),
|
||||
}),
|
||||
});
|
||||
importResult.textContent = JSON.stringify(result.publish || result, null, 2);
|
||||
rememberLastPublishedTemplate();
|
||||
setStatus(
|
||||
draftStatus,
|
||||
`已发布到仓库:${result.publish?.provider_path || "-"} · ${result.publish?.pack_version_before || "-"} -> ${result.publish?.pack_version_after || "-"} · ${result.publish?.commit_sha || "-"}`,
|
||||
@@ -1306,10 +1519,21 @@
|
||||
packIDInput.addEventListener("change", loadCatalog);
|
||||
providerIDInput.addEventListener("input", syncMetrics);
|
||||
apiBaseInput.addEventListener("input", syncMetrics);
|
||||
draftDisplayNameInput.addEventListener("input", () => syncDraftHelperState(false));
|
||||
draftBaseURLInput.addEventListener("input", () => syncDraftHelperState(false));
|
||||
draftSmokeModelInput.addEventListener("input", () => syncDraftHelperState(false));
|
||||
draftModelsInput.addEventListener("input", () => syncDraftHelperState(false));
|
||||
draftProviderIDInput.addEventListener("input", () => {
|
||||
const currentValue = draftProviderIDInput.value.trim();
|
||||
state.draftProviderIDAuto = !currentValue || currentValue === buildSuggestedProviderID();
|
||||
syncDraftHelperState(false);
|
||||
});
|
||||
|
||||
restoreConfig();
|
||||
updateAccessModeFields();
|
||||
syncMetrics();
|
||||
renderRecentTemplateMeta(readLastPublishedTemplate());
|
||||
syncDraftHelperState();
|
||||
refreshAdminSession().catch(() => {});
|
||||
renderServerDrafts();
|
||||
</script>
|
||||
|
||||
@@ -79,6 +79,12 @@
|
||||
- `SUB2API_CRM_ADMIN_USERNAME`
|
||||
- `SUB2API_CRM_ADMIN_PASSWORD`
|
||||
- `SUB2API_CRM_ADMIN_SESSION_TTL`
|
||||
- 2026-05-28 已继续把 `providers.html` 的 manifest 草稿表单收口成“按最近成功模板起步”的录入流:
|
||||
- 草稿区首次打开且字段为空时,会优先回填最近一次成功发布的模板;没有历史时,回退到当前 pack/provider 目录里的现有 provider,最后才使用静态样例
|
||||
- `Provider ID` 现已按 `display_name / base_url / supported_models` 自动生成,并在与现有 provider / draft 冲突时自动补后缀避重
|
||||
- 当新填的 `supported_models` 已在现有 provider 或草稿里出现时,页面会直接提示“同模型已存在”,并优先建议复用已有 `provider_id`,避免因为“官方 / 中转”重复新增同一模型定义
|
||||
- `GET /api/packs/{pack_id}/providers` 现已补充返回 `base_url / smoke_test_model / supported_models`,用于前端做模板参考和模型冲突提示
|
||||
- 2026-05-28 继续把这条规则下沉到服务端:`POST /api/provider-drafts`、`PUT /api/provider-drafts/{draft_id}`、`POST /api/provider-drafts/{draft_id}/publish` 现在都会做 pack 级模型冲突校验;同模型若已被其他 provider / draft 占用,会直接返回 `409 provider_model_conflict`
|
||||
- 2026-05-26 已把“最终用户 -> 公网域名 -> OpenClaw”这一跳补进正式验证口径:
|
||||
- 公网根地址当前统一为 `https://sub.tksea.top`
|
||||
- OpenClaw 本地 `MiniMax` 运行时故障已定位为 `pi-ai/openai-node` 未继承系统 `HTTP(S)_PROXY`,不是 allowlist 或模型名大小写问题
|
||||
|
||||
@@ -545,6 +545,51 @@ func TestAPIProviderAccessStatusReturnsSummary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIListPackProvidersReturnsProviderMetadata(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
ListPackProviders: func(_ context.Context, packID string) ([]PackProviderInfo, error) {
|
||||
if packID != "openai-cn-pack" {
|
||||
t.Fatalf("packID = %q, want openai-cn-pack", packID)
|
||||
}
|
||||
return []PackProviderInfo{{
|
||||
ProviderID: "deepseek-chat-official",
|
||||
DisplayName: "DeepSeek Official",
|
||||
Platform: "openai",
|
||||
HostOverlays: 0,
|
||||
BaseURL: "https://api.deepseek.com",
|
||||
SmokeTestModel: "deepseek-chat",
|
||||
SupportedModels: []string{"deepseek-chat", "deepseek-reasoner"},
|
||||
}}, nil
|
||||
},
|
||||
})
|
||||
request := httptestRequest(t, http.MethodGet, "/api/packs/openai-cn-pack/providers", nil, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, http.StatusOK)
|
||||
|
||||
var payload struct {
|
||||
Providers []PackProviderInfo `json:"providers"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body().Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(payload.Providers) != 1 {
|
||||
t.Fatalf("providers len = %d, want 1", len(payload.Providers))
|
||||
}
|
||||
got := payload.Providers[0]
|
||||
if got.ProviderID != "deepseek-chat-official" {
|
||||
t.Fatalf("provider_id = %q, want deepseek-chat-official", got.ProviderID)
|
||||
}
|
||||
if got.BaseURL != "https://api.deepseek.com" {
|
||||
t.Fatalf("base_url = %q, want https://api.deepseek.com", got.BaseURL)
|
||||
}
|
||||
if got.SmokeTestModel != "deepseek-chat" {
|
||||
t.Fatalf("smoke_test_model = %q, want deepseek-chat", got.SmokeTestModel)
|
||||
}
|
||||
if len(got.SupportedModels) != 2 || got.SupportedModels[0] != "deepseek-chat" || got.SupportedModels[1] != "deepseek-reasoner" {
|
||||
t.Fatalf("supported_models = %#v, want [deepseek-chat deepseek-reasoner]", got.SupportedModels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIProviderResourcesReturnsSummary(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
GetProviderResources: func(_ context.Context, req ProviderQueryRequest) (provision.ProviderSnapshot, error) {
|
||||
@@ -1571,6 +1616,194 @@ func TestNewActionSetPackErrorPaths(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewActionSetCreateProviderDraftRejectsModelConflictWithStoredProvider(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, err := sqlite.Open(ctx, "file:"+filepath.Join(t.TempDir(), "create-draft-conflict.db")+"?_foreign_keys=on&_busy_timeout=5000")
|
||||
if err != nil {
|
||||
t.Fatalf("sqlite.Open() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
packID, err := store.Packs().Create(ctx, sqlite.Pack{
|
||||
PackID: "openai-cn-pack",
|
||||
Version: "1.1.4",
|
||||
Checksum: "chk-openai-cn-pack",
|
||||
Vendor: "YourTeam",
|
||||
TargetHost: "sub2api",
|
||||
ManifestJSON: `{"pack_id":"openai-cn-pack","version":"1.1.4","target_host":"sub2api"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Packs().Create() error = %v", err)
|
||||
}
|
||||
if _, err := store.Providers().Create(ctx, sqlite.Provider{
|
||||
PackID: packID,
|
||||
ProviderID: "deepseek-chat-official",
|
||||
DisplayName: "DeepSeek Official",
|
||||
BaseURL: "https://api.deepseek.com",
|
||||
Platform: "openai",
|
||||
AccountType: "apikey",
|
||||
DefaultModelsJSON: `["deepseek-chat"]`,
|
||||
SmokeTestModel: "deepseek-chat",
|
||||
ManifestJSON: `{"provider_id":"deepseek-chat-official","display_name":"DeepSeek Official","base_url":"https://api.deepseek.com","platform":"openai","default_models":["deepseek-chat"],"smoke_test_model":"deepseek-chat"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("Providers().Create() error = %v", err)
|
||||
}
|
||||
|
||||
actions := NewActionSet(appTestDSN(t, store))
|
||||
_, err = actions.CreateProviderDraft(ctx, CreateProviderDraftRequest{
|
||||
PackID: "openai-cn-pack",
|
||||
ProviderID: "deepseek-chat-relay",
|
||||
DisplayName: "DeepSeek Relay",
|
||||
Platform: "openai",
|
||||
BaseURL: "https://relay.example.com/v1",
|
||||
SmokeTestModel: "deepseek-chat",
|
||||
SupportedModels: []string{"deepseek-chat"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("CreateProviderDraft() error = nil, want provider model conflict")
|
||||
}
|
||||
var httpErr *httpError
|
||||
if !errors.As(err, &httpErr) {
|
||||
t.Fatalf("CreateProviderDraft() error = %T, want *httpError", err)
|
||||
}
|
||||
if httpErr.StatusCode != http.StatusConflict || httpErr.Code != "provider_model_conflict" {
|
||||
t.Fatalf("CreateProviderDraft() = %#v, want 409/provider_model_conflict", httpErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewActionSetUpdateProviderDraftRejectsModelConflictWithOtherDraft(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, err := sqlite.Open(ctx, "file:"+filepath.Join(t.TempDir(), "update-draft-conflict.db")+"?_foreign_keys=on&_busy_timeout=5000")
|
||||
if err != nil {
|
||||
t.Fatalf("sqlite.Open() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if _, err := store.ProviderDrafts().Create(ctx, sqlite.ProviderDraft{
|
||||
DraftID: "draft_existing",
|
||||
PackID: "openai-cn-pack",
|
||||
ProviderID: "deepseek-chat-official",
|
||||
DisplayName: "DeepSeek Official",
|
||||
Platform: "openai",
|
||||
BaseURL: "https://api.deepseek.com",
|
||||
SmokeTestModel: "deepseek-chat",
|
||||
SupportedModelsJSON: `["deepseek-chat"]`,
|
||||
ManifestJSON: `{"provider_id":"deepseek-chat-official","display_name":"DeepSeek Official","platform":"openai","base_url":"https://api.deepseek.com","smoke_test_model":"deepseek-chat"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("ProviderDrafts().Create(existing) error = %v", err)
|
||||
}
|
||||
if _, err := store.ProviderDrafts().Create(ctx, sqlite.ProviderDraft{
|
||||
DraftID: "draft_target",
|
||||
PackID: "openai-cn-pack",
|
||||
ProviderID: "deepseek-chat-relay",
|
||||
DisplayName: "DeepSeek Relay",
|
||||
Platform: "openai",
|
||||
BaseURL: "https://relay.example.com/v1",
|
||||
SmokeTestModel: "deepseek-reasoner",
|
||||
SupportedModelsJSON: `["deepseek-reasoner"]`,
|
||||
ManifestJSON: `{"provider_id":"deepseek-chat-relay","display_name":"DeepSeek Relay","platform":"openai","base_url":"https://relay.example.com/v1","smoke_test_model":"deepseek-reasoner"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("ProviderDrafts().Create(target) error = %v", err)
|
||||
}
|
||||
|
||||
actions := NewActionSet(appTestDSN(t, store))
|
||||
_, err = actions.UpdateProviderDraft(ctx, UpdateProviderDraftRequest{
|
||||
DraftID: "draft_target",
|
||||
CreateProviderDraftRequest: CreateProviderDraftRequest{
|
||||
PackID: "openai-cn-pack",
|
||||
ProviderID: "deepseek-chat-relay",
|
||||
DisplayName: "DeepSeek Relay",
|
||||
Platform: "openai",
|
||||
BaseURL: "https://relay.example.com/v1",
|
||||
SmokeTestModel: "deepseek-chat",
|
||||
SupportedModels: []string{"deepseek-chat"},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("UpdateProviderDraft() error = nil, want provider model conflict")
|
||||
}
|
||||
var httpErr *httpError
|
||||
if !errors.As(err, &httpErr) {
|
||||
t.Fatalf("UpdateProviderDraft() error = %T, want *httpError", err)
|
||||
}
|
||||
if httpErr.StatusCode != http.StatusConflict || httpErr.Code != "provider_model_conflict" {
|
||||
t.Fatalf("UpdateProviderDraft() = %#v, want 409/provider_model_conflict", httpErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewActionSetPublishProviderDraftRejectsModelConflictWithRepoProvider(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git is required for publish action test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
store, err := sqlite.Open(ctx, "file:"+filepath.Join(t.TempDir(), "publish-draft-conflict.db")+"?_foreign_keys=on&_busy_timeout=5000")
|
||||
if err != nil {
|
||||
t.Fatalf("sqlite.Open() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
repoRoot := t.TempDir()
|
||||
packDir := filepath.Join(repoRoot, "packs", "openai-cn-pack")
|
||||
createPackFixtureAt(t, packDir, map[string]string{
|
||||
"pack.json": `{
|
||||
"pack_id": "openai-cn-pack",
|
||||
"version": "1.1.4",
|
||||
"vendor": "YourTeam",
|
||||
"target_host": "sub2api",
|
||||
"min_host_version": "0.1.126",
|
||||
"max_host_version": "0.2.x",
|
||||
"providers_dir": "providers",
|
||||
"checksum_file": "checksums.txt"
|
||||
}`,
|
||||
"providers/deepseek-chat-official.json": `{
|
||||
"provider_id": "deepseek-chat-official",
|
||||
"display_name": "DeepSeek Official",
|
||||
"base_url": "https://api.deepseek.com",
|
||||
"platform": "openai",
|
||||
"account_type": "apikey",
|
||||
"default_models": ["deepseek-chat"],
|
||||
"smoke_test_model": "deepseek-chat",
|
||||
"group_template": {"name": "g", "rate_multiplier": 1.0},
|
||||
"channel_template": {"name": "c", "model_mapping": {"deepseek-chat": "deepseek-chat"}},
|
||||
"plan_template": {"name": "p", "price": 1, "validity_days": 30, "validity_unit": "day"},
|
||||
"import": {"supports_multi_key": true, "supports_strict": true, "supports_partial": true}
|
||||
}`,
|
||||
})
|
||||
initGitRepo(t, repoRoot)
|
||||
|
||||
if _, err := store.ProviderDrafts().Create(ctx, sqlite.ProviderDraft{
|
||||
DraftID: "draft_publish_conflict",
|
||||
PackID: "openai-cn-pack",
|
||||
ProviderID: "deepseek-chat-relay",
|
||||
DisplayName: "DeepSeek Relay",
|
||||
Platform: "openai",
|
||||
BaseURL: "https://relay.example.com/v1",
|
||||
SmokeTestModel: "deepseek-chat",
|
||||
SupportedModelsJSON: `["deepseek-chat"]`,
|
||||
ManifestJSON: `{"provider_id":"deepseek-chat-relay","display_name":"DeepSeek Relay","platform":"openai","base_url":"https://relay.example.com/v1","smoke_test_model":"deepseek-chat"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("ProviderDrafts().Create() error = %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("SUB2API_CRM_REPO_ROOT", repoRoot)
|
||||
actions := NewActionSet(appTestDSN(t, store))
|
||||
_, err = actions.PublishProviderDraft(ctx, PublishProviderDraftRequest{
|
||||
DraftID: "draft_publish_conflict",
|
||||
CommitMessage: "feat(pack): publish provider draft deepseek-chat-relay",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("PublishProviderDraft() error = nil, want provider model conflict")
|
||||
}
|
||||
var httpErr *httpError
|
||||
if !errors.As(err, &httpErr) {
|
||||
t.Fatalf("PublishProviderDraft() error = %T, want *httpError", err)
|
||||
}
|
||||
if httpErr.StatusCode != http.StatusConflict || httpErr.Code != "provider_model_conflict" {
|
||||
t.Fatalf("PublishProviderDraft() = %#v, want 409/provider_model_conflict", httpErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewActionSetPublishProviderDraftCreatesPackCommit(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git is required for publish action test")
|
||||
|
||||
@@ -2,11 +2,14 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -100,10 +103,13 @@ type PackInfo struct {
|
||||
}
|
||||
|
||||
type PackProviderInfo struct {
|
||||
ProviderID string `json:"provider_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
HostOverlays int `json:"host_overlays,omitempty"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
HostOverlays int `json:"host_overlays,omitempty"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
SmokeTestModel string `json:"smoke_test_model,omitempty"`
|
||||
SupportedModels []string `json:"supported_models,omitempty"`
|
||||
}
|
||||
|
||||
type CreateProviderDraftRequest struct {
|
||||
@@ -1131,6 +1137,9 @@ func NewActionSet(sqliteDSN string) ActionSet {
|
||||
if err != nil {
|
||||
return ProviderDraftInfo{}, err
|
||||
}
|
||||
if err := validateProviderDraftModelConflicts(ctx, store, strings.TrimSpace(req.PackID), strings.TrimSpace(req.ProviderID), "", supportedModels, strings.TrimSpace(req.SmokeTestModel)); err != nil {
|
||||
return ProviderDraftInfo{}, err
|
||||
}
|
||||
|
||||
draftRow := sqlite.ProviderDraft{
|
||||
DraftID: draftID,
|
||||
@@ -1203,6 +1212,9 @@ func NewActionSet(sqliteDSN string) ActionSet {
|
||||
if err != nil {
|
||||
return ProviderDraftInfo{}, err
|
||||
}
|
||||
if err := validateProviderDraftModelConflicts(ctx, store, strings.TrimSpace(req.PackID), strings.TrimSpace(req.ProviderID), strings.TrimSpace(req.DraftID), supportedModels, strings.TrimSpace(req.SmokeTestModel)); err != nil {
|
||||
return ProviderDraftInfo{}, err
|
||||
}
|
||||
|
||||
if err := store.ProviderDrafts().UpdateByDraftID(ctx, sqlite.ProviderDraft{
|
||||
DraftID: strings.TrimSpace(req.DraftID),
|
||||
@@ -1256,6 +1268,9 @@ func NewActionSet(sqliteDSN string) ActionSet {
|
||||
if err != nil {
|
||||
return PublishProviderDraftResult{}, err
|
||||
}
|
||||
if err := validateProviderDraftModelConflicts(ctx, store, strings.TrimSpace(row.PackID), strings.TrimSpace(manifest.ProviderID), strings.TrimSpace(row.DraftID), manifest.DefaultModels, strings.TrimSpace(manifest.SmokeTestModel)); err != nil {
|
||||
return PublishProviderDraftResult{}, err
|
||||
}
|
||||
publishResult, err := pack.PublishProviderManifest(ctx, pack.PublishProviderManifestRequest{
|
||||
RepoRoot: startupCfg.Repository.RepoRoot,
|
||||
PackID: row.PackID,
|
||||
@@ -1698,18 +1713,27 @@ func NewActionSet(sqliteDSN string) ActionSet {
|
||||
result := make([]PackProviderInfo, 0, len(providers))
|
||||
for _, p := range providers {
|
||||
hostOverlays := 0
|
||||
baseURL := ""
|
||||
smokeTestModel := ""
|
||||
supportedModels := []string{}
|
||||
if strings.TrimSpace(p.ManifestJSON) != "" {
|
||||
var providerManifest pack.ProviderManifest
|
||||
if err := json.Unmarshal([]byte(p.ManifestJSON), &providerManifest); err != nil {
|
||||
return nil, fmt.Errorf("decode stored provider manifest: %w", err)
|
||||
}
|
||||
hostOverlays = len(providerManifest.HostOverlays)
|
||||
baseURL = strings.TrimSpace(providerManifest.BaseURL)
|
||||
smokeTestModel = strings.TrimSpace(providerManifest.SmokeTestModel)
|
||||
supportedModels = append([]string(nil), providerManifest.DefaultModels...)
|
||||
}
|
||||
result = append(result, PackProviderInfo{
|
||||
ProviderID: p.ProviderID,
|
||||
DisplayName: p.DisplayName,
|
||||
Platform: p.Platform,
|
||||
HostOverlays: hostOverlays,
|
||||
ProviderID: p.ProviderID,
|
||||
DisplayName: p.DisplayName,
|
||||
Platform: p.Platform,
|
||||
HostOverlays: hostOverlays,
|
||||
BaseURL: baseURL,
|
||||
SmokeTestModel: smokeTestModel,
|
||||
SupportedModels: supportedModels,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
@@ -2016,6 +2040,149 @@ func packRecordToInfo(pack sqlite.Pack) PackInfo {
|
||||
}
|
||||
}
|
||||
|
||||
type providerModelOwner struct {
|
||||
ProviderID string
|
||||
DisplayName string
|
||||
Models []string
|
||||
Source string
|
||||
DraftID string
|
||||
}
|
||||
|
||||
func validateProviderDraftModelConflicts(ctx context.Context, store *sqlite.DB, packID, providerID, draftID string, supportedModels []string, smokeTestModel string) error {
|
||||
packID = strings.TrimSpace(packID)
|
||||
providerID = strings.TrimSpace(providerID)
|
||||
draftID = strings.TrimSpace(draftID)
|
||||
targetModels := normalizeProviderModels(supportedModels, smokeTestModel)
|
||||
if packID == "" || providerID == "" || len(targetModels) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
owners, err := collectProviderModelOwners(ctx, store, packID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conflicts := make([]string, 0)
|
||||
seen := make(map[string]struct{})
|
||||
for _, owner := range owners {
|
||||
if owner.ProviderID == "" || owner.ProviderID == providerID {
|
||||
continue
|
||||
}
|
||||
if owner.DraftID != "" && owner.DraftID == draftID {
|
||||
continue
|
||||
}
|
||||
matched := intersectNormalizedModels(targetModels, owner.Models)
|
||||
if len(matched) == 0 {
|
||||
continue
|
||||
}
|
||||
label := fmt.Sprintf("%s -> %s[%s]", strings.Join(matched, "/"), owner.ProviderID, owner.Source)
|
||||
if _, ok := seen[label]; ok {
|
||||
continue
|
||||
}
|
||||
seen[label] = struct{}{}
|
||||
conflicts = append(conflicts, label)
|
||||
}
|
||||
if len(conflicts) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &httpError{
|
||||
StatusCode: http.StatusConflict,
|
||||
Code: "provider_model_conflict",
|
||||
Message: fmt.Sprintf("provider model conflict in pack %q: %s", packID, strings.Join(conflicts, "; ")),
|
||||
}
|
||||
}
|
||||
|
||||
func collectProviderModelOwners(ctx context.Context, store *sqlite.DB, packID string) ([]providerModelOwner, error) {
|
||||
owners := make([]providerModelOwner, 0)
|
||||
|
||||
repoOwners, err := loadRepoPackModelOwners(packID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(repoOwners) > 0 {
|
||||
owners = append(owners, repoOwners...)
|
||||
} else {
|
||||
storedOwners, err := loadStoredPackModelOwners(ctx, store, packID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owners = append(owners, storedOwners...)
|
||||
}
|
||||
|
||||
draftRows, err := store.ProviderDrafts().List(ctx, sqlite.ListProviderDraftsFilter{PackID: packID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range draftRows {
|
||||
owners = append(owners, providerModelOwner{
|
||||
ProviderID: strings.TrimSpace(row.ProviderID),
|
||||
DisplayName: strings.TrimSpace(row.DisplayName),
|
||||
Models: normalizeProviderModels(decodeStringList(row.SupportedModelsJSON), row.SmokeTestModel),
|
||||
Source: "draft",
|
||||
DraftID: strings.TrimSpace(row.DraftID),
|
||||
})
|
||||
}
|
||||
|
||||
return owners, nil
|
||||
}
|
||||
|
||||
func loadRepoPackModelOwners(packID string) ([]providerModelOwner, error) {
|
||||
startupCfg, err := config.LoadStartupFromEnv()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repoRoot := strings.TrimSpace(startupCfg.Repository.RepoRoot)
|
||||
if repoRoot == "" {
|
||||
return nil, nil
|
||||
}
|
||||
packDir := filepath.Join(repoRoot, "packs", strings.TrimSpace(packID))
|
||||
if _, err := os.Stat(packDir); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("stat repo pack dir %q: %w", packDir, err)
|
||||
}
|
||||
loadedPack, err := pack.LoadPath(packDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load repo pack %q for conflict check: %w", packID, err)
|
||||
}
|
||||
|
||||
owners := make([]providerModelOwner, 0, len(loadedPack.Providers))
|
||||
for _, provider := range loadedPack.Providers {
|
||||
owners = append(owners, providerModelOwner{
|
||||
ProviderID: strings.TrimSpace(provider.ProviderID),
|
||||
DisplayName: strings.TrimSpace(provider.DisplayName),
|
||||
Models: normalizeProviderModels(provider.DefaultModels, provider.SmokeTestModel),
|
||||
Source: "repo",
|
||||
})
|
||||
}
|
||||
return owners, nil
|
||||
}
|
||||
|
||||
func loadStoredPackModelOwners(ctx context.Context, store *sqlite.DB, packID string) ([]providerModelOwner, error) {
|
||||
packRow, err := store.Packs().GetByPackID(ctx, packID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
providerRows, err := store.Providers().ListByPackID(ctx, packRow.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owners := make([]providerModelOwner, 0, len(providerRows))
|
||||
for _, row := range providerRows {
|
||||
owners = append(owners, providerModelOwner{
|
||||
ProviderID: strings.TrimSpace(row.ProviderID),
|
||||
DisplayName: strings.TrimSpace(row.DisplayName),
|
||||
Models: normalizeProviderModels(decodeStringList(row.DefaultModelsJSON), row.SmokeTestModel),
|
||||
Source: "store",
|
||||
})
|
||||
}
|
||||
return owners, nil
|
||||
}
|
||||
|
||||
func normalizeProviderDraftPayload(req CreateProviderDraftRequest) (string, any, []string, error) {
|
||||
supportedModels := normalizeStringList(req.SupportedModels)
|
||||
if len(req.Manifest) > 0 {
|
||||
@@ -2171,6 +2338,45 @@ func encodeStringList(values []string) string {
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func normalizeProviderModels(values []string, smokeTestModel string) []string {
|
||||
normalized := normalizeStringList(values)
|
||||
if len(normalized) == 0 {
|
||||
if smoke := strings.TrimSpace(smokeTestModel); smoke != "" {
|
||||
normalized = []string{smoke}
|
||||
}
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(normalized))
|
||||
result := make([]string, 0, len(normalized))
|
||||
for _, value := range normalized {
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func intersectNormalizedModels(left, right []string) []string {
|
||||
if len(left) == 0 || len(right) == 0 {
|
||||
return nil
|
||||
}
|
||||
rightSet := make(map[string]struct{}, len(right))
|
||||
for _, value := range right {
|
||||
rightSet[value] = struct{}{}
|
||||
}
|
||||
matched := make([]string, 0)
|
||||
for _, value := range left {
|
||||
if _, ok := rightSet[value]; ok {
|
||||
matched = append(matched, value)
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
func normalizeStringList(values []string) []string {
|
||||
normalized := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
|
||||
@@ -91,6 +91,11 @@ assert_contains_file "$ADMIN_PROVIDERS_FILE" "/portal-admin-api"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/publish"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "发布 Commit Message"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "credentials: \"include\""
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "最近成功模板"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "根据 display name / base url / models 自动生成"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "同模型已存在"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "providerIdPreview"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "modelConflicts"
|
||||
|
||||
assert_contains_file "$ADMIN_BATCH_FILE" "/portal/admin-batch-import.html"
|
||||
assert_contains_file "$ADMIN_HTML_FILE" "管理员登录"
|
||||
|
||||
Reference in New Issue
Block a user