feat(admin): harden provider draft model conflicts

This commit is contained in:
phamnazage-jpg
2026-05-28 12:18:10 +08:00
parent de33ff3492
commit 6b03eb8fb9
5 changed files with 702 additions and 28 deletions

View File

@@ -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>

View File

@@ -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 或模型名大小写问题

View File

@@ -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")

View File

@@ -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 {

View File

@@ -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" "管理员登录"