mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5af7e7438a | ||
|
|
0116c5a1e1 | ||
|
|
67d3a896bd | ||
|
|
501cc19d0e | ||
|
|
8efe2fd8cf | ||
|
|
0aac71c6ee | ||
|
|
f4dd88b001 | ||
|
|
0be30886a5 | ||
|
|
324a47e2b7 | ||
|
|
2493b0dc31 | ||
|
|
264cbbec26 | ||
|
|
87e01596b7 | ||
|
|
43f9383d40 | ||
|
|
685e97a3a6 | ||
|
|
e71d291b39 | ||
|
|
4adcef4ff4 | ||
|
|
799e9938fb | ||
|
|
1c0932e173 | ||
|
|
a4edda36d5 | ||
|
|
4e5632c3e3 | ||
|
|
21323c31f8 | ||
|
|
c76db386c0 | ||
|
|
58e6e6b325 | ||
|
|
567ea21dc8 | ||
|
|
b9d0728687 | ||
|
|
de76baaa0d | ||
|
|
26798773cb | ||
|
|
98d86915be | ||
|
|
a0babc93dd | ||
|
|
f180fbb884 | ||
|
|
1d4e0436e4 | ||
|
|
266ac9c11b | ||
|
|
fd880839f6 | ||
|
|
8ed48e27a6 |
@@ -1 +1 @@
|
||||
0.2.3
|
||||
0.2.4
|
||||
|
||||
@@ -9,13 +9,14 @@ import (
|
||||
|
||||
// CustomMenuItem represents a user-configured custom menu entry.
|
||||
type CustomMenuItem struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
IconSVG string `json:"icon_svg"`
|
||||
URL string `json:"url"`
|
||||
PageSlug string `json:"page_slug,omitempty"`
|
||||
Visibility string `json:"visibility"` // "user" or "admin"
|
||||
SortOrder int `json:"sort_order"`
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
IconSVG string `json:"icon_svg"`
|
||||
URL string `json:"url"`
|
||||
PageSlug string `json:"page_slug,omitempty"`
|
||||
Visibility string `json:"visibility"` // "user" or "admin"
|
||||
SortOrder int `json:"sort_order"`
|
||||
HideOpenButton bool `json:"hide_open_button,omitempty"`
|
||||
}
|
||||
|
||||
// CustomEndpoint represents an admin-configured API endpoint for quick copy.
|
||||
|
||||
@@ -1116,8 +1116,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Models handles listing available models
|
||||
// GET /v1/models
|
||||
// Models lists visible models, or retrieves the exact list entry for a model path parameter.
|
||||
// GET /v1/models and /v1/models/:model (also exposed through root aliases)
|
||||
// Returns models based on account configurations (model_mapping whitelist)
|
||||
// Falls back to default models if no whitelist is configured
|
||||
func (h *GatewayHandler) Models(c *gin.Context) {
|
||||
@@ -1173,18 +1173,12 @@ func (h *GatewayHandler) Models(c *gin.Context) {
|
||||
|
||||
// Fallback to default models
|
||||
if platform == service.PlatformOpenAI {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"object": "list",
|
||||
"data": openai.DefaultModels,
|
||||
})
|
||||
writeModelsListResponse(c, openai.DefaultModels)
|
||||
return
|
||||
}
|
||||
|
||||
if platform == service.PlatformGemini {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"object": "list",
|
||||
"data": geminicli.DefaultModels,
|
||||
})
|
||||
writeModelsListResponse(c, geminicli.DefaultModels)
|
||||
return
|
||||
}
|
||||
if platform == service.PlatformGrok {
|
||||
@@ -1192,10 +1186,7 @@ func (h *GatewayHandler) Models(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"object": "list",
|
||||
"data": claude.DefaultModels,
|
||||
})
|
||||
writeModelsListResponse(c, claude.DefaultModels)
|
||||
}
|
||||
|
||||
// CodexModels returns the effective group model list using the manifest shape
|
||||
@@ -1320,10 +1311,7 @@ func writeModelsList(c *gin.Context, platform string, modelIDs []string) {
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"object": "list",
|
||||
"data": models,
|
||||
})
|
||||
writeModelsListResponse(c, models)
|
||||
}
|
||||
|
||||
func writeAllowlistedModelsList(c *gin.Context, platform string, modelIDs []string) {
|
||||
@@ -1382,10 +1370,7 @@ func writeGrokModelsList(c *gin.Context, modelIDs []string) {
|
||||
models = append(models, item)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"object": "list",
|
||||
"data": models,
|
||||
})
|
||||
writeModelsListResponse(c, models)
|
||||
}
|
||||
|
||||
func grokModelSupportsConfigurableReasoning(modelID string) bool {
|
||||
@@ -1418,10 +1403,7 @@ func writeOpenAIModelsList(c *gin.Context, modelIDs []string) {
|
||||
DisplayName: modelID,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"object": "list",
|
||||
"data": models,
|
||||
})
|
||||
writeModelsListResponse(c, models)
|
||||
}
|
||||
|
||||
// modelListingSource 汇总模型列表过滤的候选来源:账号映射键(availableModels)
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func requestModelForTest(h *GatewayHandler, group *service.Group, modelID, etag string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||
c.Request.Header.Set("If-None-Match", etag)
|
||||
if modelID != "" {
|
||||
c.Params = gin.Params{{Key: "model", Value: modelID}}
|
||||
}
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{GroupID: &group.ID, Group: group})
|
||||
h.Models(c)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestRetrieveModelMatchesVisibleCatalogue(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, platform := range []string{service.PlatformOpenAI, service.PlatformAnthropic, service.PlatformGemini, service.PlatformGrok, service.PlatformComposite} {
|
||||
for _, mapped := range []bool{false, true} {
|
||||
name := platform + "/fallback"
|
||||
if mapped {
|
||||
name = platform + "/mapped"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
accountPlatform := platform
|
||||
if platform == service.PlatformComposite {
|
||||
accountPlatform = service.PlatformOpenAI
|
||||
}
|
||||
credentials := map[string]any{}
|
||||
if mapped {
|
||||
credentials["model_mapping"] = map[string]any{"custom-model": "upstream-only-model"}
|
||||
}
|
||||
group := &service.Group{ID: 71, Platform: platform}
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{
|
||||
group.ID: {{ID: 1, Platform: accountPlatform, Type: service.AccountTypeAPIKey, Status: service.StatusActive, Schedulable: true, Credentials: credentials}},
|
||||
}})
|
||||
list := requestModelForTest(h, group, "", "")
|
||||
require.Equal(t, http.StatusOK, list.Code, list.Body.String())
|
||||
var catalog struct {
|
||||
Data []json.RawMessage `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(list.Body.Bytes(), &catalog))
|
||||
require.NotEmpty(t, catalog.Data)
|
||||
var model struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(catalog.Data[0], &model))
|
||||
retrieved := requestModelForTest(h, group, model.ID, "")
|
||||
require.Equal(t, http.StatusOK, retrieved.Code, retrieved.Body.String())
|
||||
require.JSONEq(t, string(catalog.Data[0]), retrieved.Body.String())
|
||||
require.Equal(t, http.StatusNotFound, requestModelForTest(h, group, "unknown-model", "").Code)
|
||||
group.ModelAllowlist = service.GroupModelAllowlist{Enabled: true, Models: []string{"absent-from-source"}}
|
||||
hidden := requestModelForTest(h, group, model.ID, "")
|
||||
require.Equal(t, http.StatusNotFound, hidden.Code, hidden.Body.String())
|
||||
require.Contains(t, hidden.Body.String(), `"code":"model_not_found"`)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrievePinnedModelPreservesMetadataFilteringAndErrors(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
status int
|
||||
selected []string
|
||||
wantStatus int
|
||||
}{
|
||||
{name: "metadata", wantStatus: http.StatusOK},
|
||||
{name: "hidden", selected: []string{"other-model"}, wantStatus: http.StatusNotFound},
|
||||
{name: "upstream failure", status: http.StatusServiceUnavailable, wantStatus: http.StatusBadGateway},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{
|
||||
2: `{"data":[{"id":"special-model","ID":"not-a-model-id","owned_by":"source-owner","created":123,"extra":{"context":999}}]}`,
|
||||
}, statuses: map[int64]int{}}
|
||||
if tc.status != 0 {
|
||||
upstream.statuses[2] = tc.status
|
||||
}
|
||||
codex := newPinnedCodexTestHandler([]service.Account{newPinnedCodexAccount(2, service.StatusActive, true, false)}, upstream, 3)
|
||||
h := &GatewayHandler{openAIGatewayService: codex.gatewayService, maxAccountSwitches: 3}
|
||||
group := &service.Group{ID: 72, Platform: service.PlatformOpenAI,
|
||||
ModelAllowlist: service.GroupModelAllowlist{Enabled: len(tc.selected) > 0, Models: tc.selected},
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{2}}}
|
||||
got := requestModelForTest(h, group, "special-model", "collection-etag")
|
||||
require.Equal(t, tc.wantStatus, got.Code, got.Body.String())
|
||||
if tc.wantStatus == http.StatusOK {
|
||||
list := requestModelForTest(h, group, "", "")
|
||||
var catalog struct {
|
||||
Data []json.RawMessage `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(list.Body.Bytes(), &catalog))
|
||||
require.JSONEq(t, string(catalog.Data[0]), got.Body.String())
|
||||
require.Contains(t, got.Body.String(), `"owned_by":"source-owner"`)
|
||||
require.Contains(t, got.Body.String(), `"created":123`)
|
||||
again := requestModelForTest(h, group, "special-model", list.Header().Get("ETag"))
|
||||
require.Equal(t, http.StatusOK, again.Code)
|
||||
require.Empty(t, again.Header().Get("ETag"))
|
||||
require.Equal(t, http.StatusNotFound, requestModelForTest(h, group, "not-a-model-id", "").Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -166,3 +166,44 @@ func TestNormalizeCodexAutomationBootstrapPreservesOrderAndIsIdempotent(t *testi
|
||||
require.False(t, changedAgain)
|
||||
require.Equal(t, got, again)
|
||||
}
|
||||
|
||||
func TestNormalizeCodexAutomationBootstrapFullHeartbeat(t *testing.T) {
|
||||
const output = `<heartbeat>
|
||||
<automation_id>flutter</automation_id>
|
||||
<current_time_iso>2026-09-09T00:33:34.775Z</current_time_iso>
|
||||
<instructions>
|
||||
Read the reference index and report changes. Preserve A & B and <tags>.
|
||||
</instructions>
|
||||
</heartbeat>`
|
||||
body := codexAutomationBootstrapBody(t, output, "")
|
||||
got, changed := normalizeCodexAutomationBootstrap(body)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String())
|
||||
require.Equal(t, output, gjson.GetBytes(got, "input.0.content.0.text").String())
|
||||
again, changedAgain := normalizeCodexAutomationBootstrap(got)
|
||||
require.False(t, changedAgain)
|
||||
require.Equal(t, got, again)
|
||||
|
||||
for _, field := range []string{"automation_id", "current_time_iso", "instructions"} {
|
||||
duplicate := strings.Replace(output, "</heartbeat>", "<"+field+">duplicate</"+field+"></heartbeat>", 1)
|
||||
require.False(t, validCodexAutomationHeartbeat(duplicate), field)
|
||||
}
|
||||
for _, invalid := range []string{
|
||||
strings.Replace(output, "2026-09-09T00:33:34.775Z", "yesterday", 1),
|
||||
strings.Replace(output, "<current_time_iso>2026-09-09T00:33:34.775Z</current_time_iso>", "", 1),
|
||||
strings.Replace(output, "Read the reference index and report changes. Preserve A & B and <tags>.", " ", 1),
|
||||
strings.Replace(output, "<instructions>", `<instructions source="other">`, 1),
|
||||
strings.Replace(output, "<instructions>", "<instructions><nested/>", 1),
|
||||
} {
|
||||
require.False(t, validCodexAutomationHeartbeat(invalid))
|
||||
}
|
||||
for _, guarded := range [][]byte{
|
||||
codexAutomationBootstrapBody(t, output, `,"call_id":"call-1"`),
|
||||
[]byte(strings.Replace(string(body), `"model":"gpt-5"`, `"model":"gpt-5","previous_response_id":"resp-1"`, 1)),
|
||||
[]byte(strings.Replace(string(body), `"namespace":"codex_app"`, `"namespace":"other"`, 1)),
|
||||
} {
|
||||
unchanged, normalized := normalizeCodexAutomationBootstrap(guarded)
|
||||
require.False(t, normalized)
|
||||
require.Equal(t, guarded, unchanged)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1889,12 +1889,24 @@ func validCodexAutomationLastRun(value string) bool {
|
||||
func validCodexAutomationHeartbeat(value string) bool {
|
||||
decoder := xml.NewDecoder(strings.NewReader(value))
|
||||
var rootSeen, automationIDSeen bool
|
||||
var automationID bytes.Buffer
|
||||
var childName string
|
||||
var childText bytes.Buffer
|
||||
fields := make(map[string]string)
|
||||
depth := 0
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
id := automationID.String()
|
||||
id := fields["automation_id"]
|
||||
timestamp, hasTime := fields["current_time_iso"]
|
||||
instructions, hasInstructions := fields["instructions"]
|
||||
if hasTime != hasInstructions {
|
||||
return false
|
||||
}
|
||||
if hasTime {
|
||||
if _, err := time.Parse(time.RFC3339Nano, timestamp); err != nil || strings.TrimSpace(instructions) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return rootSeen && automationIDSeen && depth == 0 &&
|
||||
strings.TrimSpace(id) == id && validCodexAutomationID(id)
|
||||
}
|
||||
@@ -1912,21 +1924,33 @@ func validCodexAutomationHeartbeat(value string) bool {
|
||||
return false
|
||||
}
|
||||
rootSeen = true
|
||||
} else if automationIDSeen || current.Name.Local != "automation_id" {
|
||||
return false
|
||||
} else {
|
||||
childName = current.Name.Local
|
||||
switch childName {
|
||||
case "automation_id", "current_time_iso", "instructions":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
if _, duplicate := fields[childName]; duplicate {
|
||||
return false
|
||||
}
|
||||
childText.Reset()
|
||||
}
|
||||
automationIDSeen = depth == 2
|
||||
case xml.EndElement:
|
||||
if current.Name.Space != "" {
|
||||
return false
|
||||
}
|
||||
if depth == 2 {
|
||||
fields[childName] = childText.String()
|
||||
automationIDSeen = automationIDSeen || childName == "automation_id"
|
||||
}
|
||||
depth--
|
||||
if depth < 0 {
|
||||
return false
|
||||
}
|
||||
case xml.CharData:
|
||||
if depth == 2 {
|
||||
_, _ = automationID.Write(current)
|
||||
_, _ = childText.Write(current)
|
||||
} else if len(bytes.TrimSpace(current)) != 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
@@ -17,8 +19,12 @@ func (h *GatewayHandler) pinnedOpenAIModels(c *gin.Context, group *service.Group
|
||||
writeOpenAIModelsError(c, http.StatusInternalServerError, "api_error", "OpenAI model discovery is not configured")
|
||||
return
|
||||
}
|
||||
etag := c.GetHeader("If-None-Match")
|
||||
if c.Param("model") != "" {
|
||||
etag = "" // A collection ETag cannot validate a single-model representation.
|
||||
}
|
||||
response, account, err := h.openAIGatewayService.FetchPinnedOpenAIModelsList(
|
||||
c.Request.Context(), group, h.maxAccountSwitches, c.GetHeader("If-None-Match"),
|
||||
c.Request.Context(), group, h.maxAccountSwitches, etag,
|
||||
)
|
||||
if c.Request.Context().Err() != nil {
|
||||
return
|
||||
@@ -40,6 +46,10 @@ func writeOpenAIModelsError(c *gin.Context, status int, errorType, message strin
|
||||
}
|
||||
|
||||
func writeOpenAIModelsResponse(c *gin.Context, manifest *service.OpenAIModelsResponse) {
|
||||
if c.Param("model") != "" {
|
||||
writeRetrievedModel(c, manifest.Body)
|
||||
return
|
||||
}
|
||||
if manifest.ETag != "" {
|
||||
c.Header("ETag", manifest.ETag)
|
||||
}
|
||||
@@ -50,3 +60,50 @@ func writeOpenAIModelsResponse(c *gin.Context, manifest *service.OpenAIModelsRes
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", manifest.Body)
|
||||
}
|
||||
|
||||
// Both discovery endpoints consume the same final catalogue, after group/platform
|
||||
// selection and allowlist filtering. Preserve every field on the selected entry.
|
||||
func writeModelsListResponse(c *gin.Context, models any) {
|
||||
response := gin.H{"object": "list", "data": models}
|
||||
if c.Param("model") == "" {
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
writeOpenAIModelsError(c, http.StatusInternalServerError, "api_error", "Failed to encode model catalogue")
|
||||
return
|
||||
}
|
||||
writeRetrievedModel(c, body)
|
||||
}
|
||||
|
||||
func writeRetrievedModel(c *gin.Context, body []byte) {
|
||||
var catalog struct {
|
||||
Data []json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &catalog); err != nil {
|
||||
writeOpenAIModelsError(c, http.StatusBadGateway, "upstream_error", "Invalid model catalogue")
|
||||
return
|
||||
}
|
||||
modelID := c.Param("model")
|
||||
for _, raw := range catalog.Data {
|
||||
var model map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &model); err != nil {
|
||||
writeOpenAIModelsError(c, http.StatusBadGateway, "upstream_error", "Invalid model catalogue entry")
|
||||
return
|
||||
}
|
||||
var id string
|
||||
if err := json.Unmarshal(model["id"], &id); err != nil {
|
||||
writeOpenAIModelsError(c, http.StatusBadGateway, "upstream_error", "Invalid model catalogue ID")
|
||||
return
|
||||
}
|
||||
if id == modelID {
|
||||
c.Data(http.StatusOK, "application/json", raw)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{
|
||||
"type": "invalid_request_error", "code": "model_not_found", "param": "model",
|
||||
"message": fmt.Sprintf("Model %q does not exist or is not available for this group", modelID),
|
||||
}})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package openai
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/http/httpguts"
|
||||
)
|
||||
|
||||
// CodexCLIUserAgentPrefixes matches Codex CLI User-Agent patterns
|
||||
@@ -198,6 +200,10 @@ func matchCodexClientHeaderStrictPrefixes(value string, prefixes []string) bool
|
||||
// UA 首段后配对,保留真实版本/OS/终端指纹;
|
||||
// 3. 均不命中 → ok=false,调用方应整体回退为默认官方身份。
|
||||
func PairCodexClientIdentity(userAgent string) (originator string, pairedUA string, ok bool) {
|
||||
// Validate before trimming so control bytes cannot become a valid identity.
|
||||
if !validCodexUserAgentValue(userAgent) {
|
||||
return "", "", false
|
||||
}
|
||||
ua := strings.TrimSpace(userAgent)
|
||||
slash := strings.IndexByte(ua, '/')
|
||||
if slash <= 0 {
|
||||
@@ -217,6 +223,15 @@ func PairCodexClientIdentity(userAgent string) (originator string, pairedUA stri
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func validCodexUserAgentValue(value string) bool {
|
||||
if !httpguts.ValidHeaderFieldValue(value) {
|
||||
return false
|
||||
}
|
||||
// httpguts follows the legacy field-value grammar and permits obs-fold.
|
||||
// User-Agent is not an obs-folded header; reject CR/LF before forwarding.
|
||||
return !strings.ContainsAny(value, "\r\n")
|
||||
}
|
||||
|
||||
// codexOriginatorMaxLen 官方 clientInfo.name 均为短 ASCII 标识,远低于此上限。
|
||||
const codexOriginatorMaxLen = 64
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPairCodexClientIdentityRejectsInvalidHeaderBytes(t *testing.T) {
|
||||
for name, ua := range map[string]string{
|
||||
"crlf_suffix": "codex-tui/0.146.0 (Linux)\r\nX-Injected: value",
|
||||
"nul_suffix": "codex-tui/0.146.0 (Linux\x00)",
|
||||
"del_suffix": "codex-tui/0.146.0 terminal\x7f",
|
||||
"newline_prefix": "\ncodex-tui/0.146.0",
|
||||
"newline_suffix": "codex-tui/0.146.0\n",
|
||||
"control_in_trailer": "custom/0.146.0 bad\x01 (codex-tui; 0.146.0)",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
originator, paired, ok := PairCodexClientIdentity(ua)
|
||||
require.False(t, ok)
|
||||
require.Empty(t, originator)
|
||||
require.Empty(t, paired)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairCodexClientIdentityPreservesValidSuffix(t *testing.T) {
|
||||
for _, ua := range []string{
|
||||
"codex-tui/0.146.0 (Mac OS X 14.0; arm64) iTerm",
|
||||
"codex-tui/0.146.0 (Windows NT 10.0; x86_64) terminal",
|
||||
"codex-tui/0.146.0 (Linux)\tterminal",
|
||||
} {
|
||||
_, paired, ok := PairCodexClientIdentity(ua)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, ua, paired)
|
||||
}
|
||||
}
|
||||
@@ -1540,6 +1540,41 @@ func (s *AccountRepoSuite) TestUpdateExtra_SchedulerNeutralSkipsOutboxAndSyncsFr
|
||||
s.Require().Equal("2026-03-11T10:00:00Z", cacheRecorder.accounts[account.ID].Extra["codex_usage_updated_at"])
|
||||
}
|
||||
|
||||
// Exercise the complete UpdateExtra -> PostgreSQL -> Redis metadata -> admission
|
||||
// path. A recorder-only cache would miss fields discarded by the slim projection.
|
||||
func (s *AccountRepoSuite) TestUpdateExtra_AnthropicThresholdRefreshesCandidateSnapshot() {
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
end := now.Add(time.Hour)
|
||||
account := mustCreateAccount(s.T(), s.client, &service.Account{
|
||||
Name: "threshold-refresh", Platform: service.PlatformAnthropic, Type: service.AccountTypeOAuth,
|
||||
Credentials: map[string]any{"account_scheduling_threshold": 60},
|
||||
Extra: map[string]any{"passive_usage_7d_utilization": .59, "passive_usage_7d_reset": end.Unix()},
|
||||
})
|
||||
cache := NewSchedulerCache(testRedis(s.T()))
|
||||
s.repo.schedulerCache = cache
|
||||
bucket := service.SchedulerBucket{GroupID: account.ID, Platform: service.PlatformAnthropic, Mode: service.SchedulerModeSingle}
|
||||
token, err := cache.CaptureBucketWriteToken(s.ctx, bucket)
|
||||
s.Require().NoError(err)
|
||||
s.Require().NoError(cache.SetSnapshot(s.ctx, bucket, token, []service.Account{*account}))
|
||||
for _, step := range []struct {
|
||||
used float64
|
||||
reset time.Time
|
||||
paused bool
|
||||
}{
|
||||
{.59, end, false}, {.66, end, true}, {.66, now.Add(-time.Hour), false}, {.10, end, false},
|
||||
} {
|
||||
s.Require().NoError(s.repo.UpdateExtra(s.ctx, account.ID, map[string]any{
|
||||
"passive_usage_7d_utilization": step.used, "passive_usage_7d_reset": step.reset.Unix(),
|
||||
}))
|
||||
candidates, hit, err := cache.GetSnapshot(s.ctx, bucket)
|
||||
s.Require().NoError(err)
|
||||
s.Require().True(hit)
|
||||
s.Require().Len(candidates, 1)
|
||||
decision := service.EvaluateAccountSchedulingThreshold(candidates[0], map[string]int{service.PlatformAnthropic: 100}, now)
|
||||
s.Require().Equal(step.paused, decision.ShouldPause)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AccountRepoSuite) TestUpdateExtra_ExhaustedCodexSnapshotSyncsSchedulerCache() {
|
||||
account := mustCreateAccount(s.T(), s.client, &service.Account{
|
||||
Name: "acc-extra-codex-exhausted",
|
||||
|
||||
@@ -395,11 +395,21 @@ func sameFixedRollupBucket(start, end time.Time, seconds int) bool {
|
||||
return start.Truncate(interval).Equal(end.Add(-time.Nanosecond).Truncate(interval))
|
||||
}
|
||||
|
||||
// PostgreSQL interprets a TIMESTAMPTZ literal without an explicit offset in
|
||||
// the current session timezone. Keep date_bin's origin fixed in UTC so bucket
|
||||
// boundaries do not shift when the database session runs in Asia/Shanghai (or
|
||||
// any other non-UTC timezone).
|
||||
const channelMonitorV2DateBinOrigin = "TIMESTAMPTZ '1970-01-01 00:00:00+00'"
|
||||
|
||||
func channelMonitorV2DateBinExpr(column string) string {
|
||||
return "date_bin($1::interval," + column + "," + channelMonitorV2DateBinOrigin + ")"
|
||||
}
|
||||
|
||||
const channelMonitorV2FixedRollupBoundsSQL = `
|
||||
WITH bounds AS (
|
||||
SELECT
|
||||
date_bin($1::interval, $3::timestamptz, TIMESTAMPTZ '1970-01-01') AS start_at,
|
||||
date_bin($1::interval, $4::timestamptz - INTERVAL '1 microsecond', TIMESTAMPTZ '1970-01-01') + $1::interval AS end_at
|
||||
date_bin($1::interval, $3::timestamptz, ` + channelMonitorV2DateBinOrigin + `) AS start_at,
|
||||
date_bin($1::interval, $4::timestamptz - INTERVAL '1 microsecond', ` + channelMonitorV2DateBinOrigin + `) + $1::interval AS end_at
|
||||
)`
|
||||
|
||||
const channelMonitorV2FixedRollupDeleteSQL = channelMonitorV2FixedRollupBoundsSQL + `
|
||||
@@ -417,7 +427,7 @@ INSERT INTO channel_monitor_v2_metrics_rollup (
|
||||
duration_count, computed_at
|
||||
)
|
||||
` + channelMonitorV2FixedRollupBoundsSQL + `
|
||||
SELECT date_bin($1::interval, m.bucket_start, TIMESTAMPTZ '1970-01-01'), $2::integer,
|
||||
SELECT date_bin($1::interval, m.bucket_start, ` + channelMonitorV2DateBinOrigin + `), $2::integer,
|
||||
platform, group_id, model, SUM(success_requests), SUM(error_requests),
|
||||
SUM(upstream_affected_requests), SUM(upstream_attempt_count), SUM(input_tokens),
|
||||
SUM(output_tokens), SUM(cache_creation_tokens), SUM(cache_read_tokens),
|
||||
@@ -433,7 +443,7 @@ INSERT INTO channel_monitor_v2_user_metrics_rollup (
|
||||
ttft_sum_ms, ttft_count, duration_sum_ms, duration_count, computed_at
|
||||
)
|
||||
` + channelMonitorV2FixedRollupBoundsSQL + `
|
||||
SELECT date_bin($1::interval, m.bucket_start, TIMESTAMPTZ '1970-01-01'), $2::integer,
|
||||
SELECT date_bin($1::interval, m.bucket_start, ` + channelMonitorV2DateBinOrigin + `), $2::integer,
|
||||
platform, group_id, model, user_id, SUM(success_requests), SUM(error_requests),
|
||||
SUM(input_tokens), SUM(output_tokens), SUM(cache_creation_tokens), SUM(cache_read_tokens),
|
||||
SUM(ttft_sum_ms), SUM(ttft_count), SUM(duration_sum_ms), SUM(duration_count), NOW()
|
||||
@@ -446,7 +456,7 @@ INSERT INTO channel_monitor_v2_latency_histograms_rollup (
|
||||
bucket_start, bucket_seconds, platform, group_id, model, user_id, metric, upper_bound_ms, sample_count
|
||||
)
|
||||
` + channelMonitorV2FixedRollupBoundsSQL + `
|
||||
SELECT date_bin($1::interval, h.bucket_start, TIMESTAMPTZ '1970-01-01'), $2::integer,
|
||||
SELECT date_bin($1::interval, h.bucket_start, ` + channelMonitorV2DateBinOrigin + `), $2::integer,
|
||||
platform, group_id, model, user_id, metric, upper_bound_ms, SUM(sample_count)
|
||||
FROM channel_monitor_v2_latency_histograms_1m h, bounds
|
||||
WHERE h.bucket_start >= bounds.start_at AND h.bucket_start < bounds.end_at
|
||||
@@ -457,7 +467,7 @@ INSERT INTO channel_monitor_v2_error_metrics_rollup (
|
||||
bucket_start, bucket_seconds, platform, group_id, model, error_category, taxonomy_version, error_requests
|
||||
)
|
||||
` + channelMonitorV2FixedRollupBoundsSQL + `
|
||||
SELECT date_bin($1::interval, e.bucket_start, TIMESTAMPTZ '1970-01-01'), $2::integer,
|
||||
SELECT date_bin($1::interval, e.bucket_start, ` + channelMonitorV2DateBinOrigin + `), $2::integer,
|
||||
platform, group_id, model, error_category, taxonomy_version, SUM(error_requests)
|
||||
FROM channel_monitor_v2_error_metrics_1m e, bounds
|
||||
WHERE e.bucket_start >= bounds.start_at AND e.bucket_start < bounds.end_at
|
||||
|
||||
@@ -949,7 +949,7 @@ func (r *channelMonitorV2Repository) loadFacts(ctx context.Context, filter servi
|
||||
} else {
|
||||
args = append([]any{fmt.Sprintf("%d seconds", int(filter.Bucket.Seconds()))}, args...)
|
||||
where = shiftSQLPlaceholders(where, 1)
|
||||
bucketExpr = "date_bin($1::interval,m.bucket_start,TIMESTAMPTZ '1970-01-01')"
|
||||
bucketExpr = channelMonitorV2DateBinExpr("m.bucket_start")
|
||||
group = bucketExpr + "," + group
|
||||
}
|
||||
}
|
||||
@@ -991,7 +991,7 @@ func (r *channelMonitorV2Repository) loadHistograms(ctx context.Context, filter
|
||||
args = []any{fmt.Sprintf("%d seconds", int(filter.Bucket.Seconds()))}
|
||||
args = append(args, oldArgs...)
|
||||
where = shiftSQLPlaceholders(where, 1)
|
||||
bucketExpr = "date_bin($1::interval,h.bucket_start,TIMESTAMPTZ '1970-01-01')"
|
||||
bucketExpr = channelMonitorV2DateBinExpr("h.bucket_start")
|
||||
group = bucketExpr + "," + group
|
||||
}
|
||||
}
|
||||
@@ -1444,7 +1444,7 @@ func (r *channelMonitorV2Repository) loadIgnoredErrorCounts(
|
||||
if filter.Bucket > 0 && bucketSeconds == 0 {
|
||||
args = append([]any{fmt.Sprintf("%d seconds", int(filter.Bucket.Seconds()))}, args...)
|
||||
where = shiftSQLPlaceholders(where, 1)
|
||||
bucketExpr = "date_bin($1::interval,e.bucket_start,TIMESTAMPTZ '1970-01-01')"
|
||||
bucketExpr = channelMonitorV2DateBinExpr("e.bucket_start")
|
||||
groupBy = bucketExpr + ", e.platform, e.model"
|
||||
}
|
||||
args = append(args, pq.Array(cfg.IgnoredErrorCategories), service.ChannelMonitorV2TaxonomyVersion)
|
||||
@@ -1540,7 +1540,7 @@ func (r *channelMonitorV2Repository) loadIgnoredErrorCountsByMatrixKey(
|
||||
if filter.Bucket > 0 && bucketSeconds == 0 {
|
||||
args = append([]any{fmt.Sprintf("%d seconds", int(filter.Bucket.Seconds()))}, args...)
|
||||
where = shiftSQLPlaceholders(where, 1)
|
||||
bucketExpr = "date_bin($1::interval,e.bucket_start,TIMESTAMPTZ '1970-01-01')"
|
||||
bucketExpr = channelMonitorV2DateBinExpr("e.bucket_start")
|
||||
groupSQL = bucketExpr + ", e.platform, e.group_id, e.model"
|
||||
}
|
||||
args = append(args, pq.Array(cfg.IgnoredErrorCategories), service.ChannelMonitorV2TaxonomyVersion)
|
||||
|
||||
@@ -12,6 +12,22 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestChannelMonitorV2DateBinOriginIsUTC(t *testing.T) {
|
||||
require.Equal(t, "TIMESTAMPTZ '1970-01-01 00:00:00+00'", channelMonitorV2DateBinOrigin)
|
||||
require.Equal(t, "date_bin($1::interval,m.bucket_start,TIMESTAMPTZ '1970-01-01 00:00:00+00')", channelMonitorV2DateBinExpr("m.bucket_start"))
|
||||
|
||||
for _, query := range []string{
|
||||
channelMonitorV2FixedRollupBoundsSQL,
|
||||
channelMonitorV2MetricsRollupSQL,
|
||||
channelMonitorV2UserMetricsRollupSQL,
|
||||
channelMonitorV2HistogramRollupSQL,
|
||||
channelMonitorV2ErrorRollupSQL,
|
||||
} {
|
||||
require.Contains(t, query, channelMonitorV2DateBinOrigin)
|
||||
require.NotContains(t, query, "TIMESTAMPTZ '1970-01-01'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2DisplayModelIsPlatformScoped(t *testing.T) {
|
||||
cfg := service.ChannelMonitorV2Config{Platforms: []service.ChannelMonitorV2PlatformConfig{
|
||||
{Platform: "openai", Enabled: true, Models: []string{"shared", "gpt-5"}},
|
||||
|
||||
@@ -954,7 +954,9 @@ func filterSchedulerCredentials(credentials map[string]any) map[string]any {
|
||||
if len(credentials) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := []string{"model_mapping", "compact_model_mapping", "api_key", "project_id", "oauth_type", "plan_type"}
|
||||
// Candidate-list admission evaluates the account override before hydrating
|
||||
// the full account. Dropping it silently falls back to the platform threshold.
|
||||
keys := []string{"model_mapping", "compact_model_mapping", "api_key", "project_id", "oauth_type", "plan_type", "account_scheduling_threshold"}
|
||||
filtered := make(map[string]any)
|
||||
for _, key := range keys {
|
||||
if value, ok := credentials[key]; ok && value != nil {
|
||||
@@ -972,6 +974,13 @@ func filterSchedulerExtra(extra map[string]any) map[string]any {
|
||||
return nil
|
||||
}
|
||||
keys := []string{
|
||||
// Anthropic shared-window and Fable-only threshold checks run on this
|
||||
// projection. UpdateExtra refreshes both payloads without a bucket rebuild.
|
||||
"session_window_utilization",
|
||||
"passive_usage_7d_utilization",
|
||||
"passive_usage_7d_reset",
|
||||
"passive_usage_7d_oi_utilization",
|
||||
"passive_usage_7d_oi_reset",
|
||||
"quota_limit",
|
||||
"quota_used",
|
||||
"quota_daily_limit",
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//go:build unit
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Only persistence is stubbed: the candidate is serialized through the real
|
||||
// Redis snapshot projection before the production threshold service sees it.
|
||||
type snapshotThresholdRepo struct {
|
||||
service.AccountRepository
|
||||
accountPauses int
|
||||
modelPauses int
|
||||
until time.Time
|
||||
}
|
||||
|
||||
func (r *snapshotThresholdRepo) SetTempUnschedulable(_ context.Context, _ int64, until time.Time, _ string) error {
|
||||
r.accountPauses++
|
||||
r.until = until
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *snapshotThresholdRepo) SetModelRateLimit(_ context.Context, _ int64, _ string, until time.Time, _ ...string) error {
|
||||
r.modelPauses++
|
||||
r.until = until
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSchedulerCacheAnthropicThresholdAdmission(t *testing.T) {
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
reset := now.Add(time.Hour)
|
||||
cases := []struct {
|
||||
name string
|
||||
fiveHour, sevenDay, fable float64
|
||||
threshold int
|
||||
expired bool
|
||||
accountPaused, fablePaused bool
|
||||
}{
|
||||
{name: "shared 5h", fiveHour: .60, threshold: 60, accountPaused: true},
|
||||
{name: "shared 7d", sevenDay: .66, threshold: 60, accountPaused: true},
|
||||
{name: "Fable only", sevenDay: .40, fable: .75, threshold: 60, fablePaused: true},
|
||||
{name: "below threshold", fiveHour: .59, sevenDay: .59, fable: .59, threshold: 60},
|
||||
{name: "expired windows", fiveHour: .75, sevenDay: .75, fable: .75, threshold: 60, expired: true},
|
||||
{name: "disabled override", fiveHour: .75, sevenDay: .75, fable: .75, threshold: 100},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
end := reset
|
||||
if tc.expired {
|
||||
end = now.Add(-time.Hour)
|
||||
}
|
||||
account := service.Account{
|
||||
ID: 3, Platform: service.PlatformAnthropic, Type: service.AccountTypeOAuth,
|
||||
Status: service.StatusActive, Schedulable: true, SessionWindowEnd: &end,
|
||||
Credentials: map[string]any{"account_scheduling_threshold": tc.threshold, "access_token": "test-only-token"},
|
||||
Extra: map[string]any{
|
||||
"session_window_utilization": tc.fiveHour,
|
||||
"passive_usage_7d_utilization": tc.sevenDay, "passive_usage_7d_reset": end.Unix(),
|
||||
"passive_usage_7d_oi_utilization": tc.fable, "passive_usage_7d_oi_reset": end.Unix(),
|
||||
"unrelated_large_payload": "drop me",
|
||||
},
|
||||
}
|
||||
cache := newSchedulerCacheUnit(t)
|
||||
bucket := service.SchedulerBucket{GroupID: 8, Platform: service.PlatformAnthropic, Mode: service.SchedulerModeSingle}
|
||||
token, err := cache.CaptureBucketWriteToken(ctx, bucket)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, cache.SetSnapshot(ctx, bucket, token, []service.Account{account}))
|
||||
candidates, hit, err := cache.GetSnapshot(ctx, bucket)
|
||||
require.NoError(t, err)
|
||||
require.True(t, hit)
|
||||
require.Len(t, candidates, 1)
|
||||
require.NotContains(t, candidates[0].Credentials, "access_token")
|
||||
require.NotContains(t, candidates[0].Extra, "unrelated_large_payload")
|
||||
full, err := cache.GetAccount(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, full)
|
||||
for _, candidate := range []*service.Account{candidates[0], full} {
|
||||
repo := &snapshotThresholdRepo{}
|
||||
limiter := service.NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
|
||||
// Default platform thresholds are disabled, so the per-account override
|
||||
// must survive both cache read paths to trigger admission control.
|
||||
limiter.SetSettingService(service.NewSettingService(nil, &config.Config{}))
|
||||
require.Equal(t, tc.accountPaused, limiter.ApplyAccountSchedulingThreshold(ctx, candidate))
|
||||
require.Equal(t, tc.accountPaused, repo.accountPauses == 1)
|
||||
require.Equal(t, tc.fablePaused, repo.modelPauses == 1)
|
||||
if tc.accountPaused || tc.fablePaused {
|
||||
require.True(t, end.Equal(repo.until))
|
||||
}
|
||||
if tc.fablePaused {
|
||||
require.False(t, candidate.IsSchedulableForModel("claude-fable-5"))
|
||||
require.True(t, candidate.IsSchedulableForModel("claude-opus-5"))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedulerCacheAnthropicUsageRefresh(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
end := now.Add(time.Hour)
|
||||
cache := newSchedulerCacheUnit(t)
|
||||
bucket := service.SchedulerBucket{GroupID: 8, Platform: service.PlatformAnthropic, Mode: service.SchedulerModeSingle}
|
||||
account := service.Account{
|
||||
ID: 3, Platform: service.PlatformAnthropic, Type: service.AccountTypeOAuth,
|
||||
Status: service.StatusActive, Schedulable: true,
|
||||
Credentials: map[string]any{"account_scheduling_threshold": 60},
|
||||
Extra: map[string]any{"passive_usage_7d_utilization": .59, "passive_usage_7d_reset": end.Unix()},
|
||||
}
|
||||
token, err := cache.CaptureBucketWriteToken(ctx, bucket)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, cache.SetSnapshot(ctx, bucket, token, []service.Account{account}))
|
||||
for _, used := range []float64{.59, .66, .10} {
|
||||
account.Extra["passive_usage_7d_utilization"] = used
|
||||
// UpdateExtra refreshes account payloads without rebuilding bucket membership.
|
||||
require.NoError(t, cache.SetAccount(ctx, &account))
|
||||
candidates, hit, err := cache.GetSnapshot(ctx, bucket)
|
||||
require.NoError(t, err)
|
||||
require.True(t, hit)
|
||||
require.Len(t, candidates, 1)
|
||||
decision := service.EvaluateAccountSchedulingThreshold(candidates[0], map[string]int{service.PlatformAnthropic: 100}, now)
|
||||
require.Equal(t, used >= .60, decision.ShouldPause)
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,8 @@ func RegisterGatewayRoutes(
|
||||
// /models endpoint with a client_version query and expect the ChatGPT
|
||||
// Codex manifest format; other clients keep the OpenAI-style list.
|
||||
gateway.GET("/models", modelsHandler)
|
||||
// Single-model discovery never selects the Codex client_version manifest.
|
||||
gateway.GET("/models/:model", h.Gateway.Models)
|
||||
gateway.GET("/usage", h.Gateway.Usage)
|
||||
gateway.POST("/live", h.OpenAIGateway.Live)
|
||||
gateway.GET("/live/:call_id", h.OpenAIGateway.LiveSideband)
|
||||
@@ -372,6 +374,7 @@ func RegisterGatewayRoutes(
|
||||
h.OpenAIGateway.ResponsesWebSocket(c)
|
||||
})
|
||||
rootRoute(http.MethodGet, "/models", bodyLimit, modelsHandler)
|
||||
rootRoute(http.MethodGet, "/models/:model", bodyLimit, h.Gateway.Models)
|
||||
rootRoute(http.MethodPost, "/messages/count_tokens", bodyLimit, countTokensHandler)
|
||||
codexDirect := r.Group("/backend-api/codex")
|
||||
codexDirect.Use(bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), groupModelAllowlist, compositeTarget, requireGroupAnthropic)
|
||||
|
||||
@@ -92,3 +92,59 @@ func TestGatewayRoutesPinnedModelsDispatchesOrdinaryAndCodexRequests(t *testing.
|
||||
require.EqualValues(t, 1, upstream.ordinaryCalls.Load())
|
||||
require.EqualValues(t, 1, upstream.codexCalls.Load())
|
||||
}
|
||||
|
||||
func TestGatewayRoutesRetrievePinnedModel(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
repo := &pinnedModelsRoutesRepository{account: service.Account{
|
||||
ID: 7, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive, Schedulable: true,
|
||||
Credentials: map[string]any{"api_key": "test-models-key", "base_url": "https://models.example/v1"},
|
||||
}}
|
||||
upstream := &pinnedModelsRoutesUpstream{}
|
||||
cfg := &config.Config{RunMode: config.RunModeSimple}
|
||||
s := service.NewOpenAIGatewayService(repo, nil, nil, nil, nil, nil, nil, cfg,
|
||||
nil, nil, nil, nil, nil, upstream, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
h := &handler.Handlers{
|
||||
Gateway: handler.NewGatewayHandler(nil, s, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, cfg, nil),
|
||||
OpenAIGateway: handler.NewOpenAIGatewayHandler(s, nil, nil, nil, nil, nil, nil, nil, cfg),
|
||||
AsyncImage: handler.NewAsyncImageHandler(nil, nil),
|
||||
}
|
||||
group := &service.Group{ID: 1, Platform: service.PlatformOpenAI,
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{7}}}
|
||||
router := gin.New()
|
||||
RegisterGatewayRoutes(router, h, servermiddleware.APIKeyAuthMiddleware(func(c *gin.Context) {
|
||||
if c.GetHeader("Authorization") != "Bearer client-key" {
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
c.Set(string(servermiddleware.ContextKeyAPIKey), &service.APIKey{GroupID: &group.ID, Group: group})
|
||||
c.Next()
|
||||
}), nil, nil, nil, nil, nil, cfg)
|
||||
request := func(path, key, etag string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("Authorization", key)
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
list := request("/v1/models", "Bearer client-key", "")
|
||||
require.Equal(t, http.StatusOK, list.Code)
|
||||
var catalog struct {
|
||||
Data []json.RawMessage `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(list.Body.Bytes(), &catalog))
|
||||
require.Len(t, catalog.Data, 1)
|
||||
for _, base := range []string{"/v1/models/", "/models/"} {
|
||||
path := base + "ordinary-upstream-model?client_version=ignored"
|
||||
require.Equal(t, http.StatusUnauthorized, request(path, "", "").Code)
|
||||
got := request(path, "Bearer client-key", list.Header().Get("ETag"))
|
||||
require.Equal(t, http.StatusOK, got.Code, got.Body.String())
|
||||
require.JSONEq(t, string(catalog.Data[0]), got.Body.String())
|
||||
require.Empty(t, got.Header().Get("ETag"), "a collection validator cannot validate one model")
|
||||
missing := request(base+"unknown-model", "Bearer client-key", "")
|
||||
require.Equal(t, http.StatusNotFound, missing.Code)
|
||||
require.Contains(t, missing.Body.String(), `"error"`)
|
||||
}
|
||||
require.Zero(t, upstream.codexCalls.Load(), "retrieve never dispatches a Codex manifest")
|
||||
}
|
||||
|
||||
@@ -556,13 +556,21 @@ func postRawJSON(ctx context.Context, fullURL string, payload []byte, headers ma
|
||||
return respBody, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// joinURL 把 base origin 与 path 拼成完整 URL。
|
||||
// 容忍 base 末尾有/无斜杠,path 必带前导斜杠。
|
||||
// joinURL 保留 base 的上游路径前缀,并避免重复追加已有的 API 路径前缀。
|
||||
// 使用 EscapedPath 匹配完整路径段,避免把 hostname 或编码斜杠当作路径。
|
||||
func joinURL(base, path string) string {
|
||||
base = strings.TrimRight(base, "/")
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
if u, err := url.Parse(base); err == nil {
|
||||
basePath := u.EscapedPath()
|
||||
for end := strings.LastIndex(path, "/"); end > 0; end = strings.LastIndex(path[:end], "/") {
|
||||
if strings.HasSuffix(basePath, path[:end]) {
|
||||
return base + path[end:]
|
||||
}
|
||||
}
|
||||
}
|
||||
return base + path
|
||||
}
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ var (
|
||||
"CHANNEL_MONITOR_ENDPOINT_SCHEME", "endpoint must use https scheme",
|
||||
)
|
||||
ErrChannelMonitorEndpointPath = infraerrors.BadRequest(
|
||||
"CHANNEL_MONITOR_ENDPOINT_PATH", "endpoint must be base origin only (no path/query/fragment)",
|
||||
"CHANNEL_MONITOR_ENDPOINT_PATH", "endpoint must not contain query parameters or a fragment",
|
||||
)
|
||||
ErrChannelMonitorEndpointPrivate = infraerrors.BadRequest(
|
||||
"CHANNEL_MONITOR_ENDPOINT_PRIVATE", "endpoint must be a public host",
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidateMonitorEndpoint_BasePath(t *testing.T) {
|
||||
for _, endpoint := range []string{
|
||||
"https://8.8.8.8",
|
||||
"https://8.8.8.8/anthropic",
|
||||
"https://8.8.8.8/anthropic/",
|
||||
"https://8.8.8.8/anthropic/v1",
|
||||
"https://8.8.8.8/tenant%2Fone/anthropic",
|
||||
} {
|
||||
t.Run(endpoint, func(t *testing.T) {
|
||||
require.NoError(t, validateEndpoint(endpoint))
|
||||
})
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
endpoint string
|
||||
want error
|
||||
}{
|
||||
{"http://8.8.8.8/anthropic", ErrChannelMonitorEndpointScheme},
|
||||
{"https://8.8.8.8/anthropic?key=secret", ErrChannelMonitorEndpointPath},
|
||||
{"https://8.8.8.8/anthropic#fragment", ErrChannelMonitorEndpointPath},
|
||||
{"https://127.0.0.1/anthropic", ErrChannelMonitorEndpointPrivate},
|
||||
{"https://10.0.0.1/anthropic", ErrChannelMonitorEndpointPrivate},
|
||||
{"https://169.254.169.254/anthropic", ErrChannelMonitorEndpointPrivate},
|
||||
{"https://[::1]/anthropic", ErrChannelMonitorEndpointPrivate},
|
||||
{"https://[fd00::1]/anthropic", ErrChannelMonitorEndpointPrivate},
|
||||
{"https:///anthropic", ErrChannelMonitorInvalidEndpoint},
|
||||
} {
|
||||
t.Run(tc.endpoint, func(t *testing.T) {
|
||||
require.ErrorIs(t, validateEndpoint(tc.endpoint), tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinMonitorURL_DoesNotMatchHostOrEncodedSlash(t *testing.T) {
|
||||
require.Equal(t, "https://v1/v1/messages", joinURL("https://v1", "/v1/messages"))
|
||||
require.Equal(t, "https://relay.example/tenant%2Fv1/v1/messages",
|
||||
joinURL("https://relay.example/tenant%2Fv1", "/v1/messages"))
|
||||
}
|
||||
|
||||
func TestNormalizeMonitorEndpoint_PreservesBasePath(t *testing.T) {
|
||||
require.Equal(t, "https://api.deepseek.com/anthropic",
|
||||
normalizeEndpoint(" https://api.deepseek.com/anthropic/ "))
|
||||
require.Equal(t, "https://relay.example/tenant%2Fone/anthropic/v1",
|
||||
normalizeEndpoint("https://relay.example/tenant%2Fone/anthropic/v1/"))
|
||||
}
|
||||
|
||||
func TestCallProvider_BasePath(t *testing.T) {
|
||||
swapMonitorHTTPClient(t)
|
||||
requests := make(chan string, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests <- r.RequestURI
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
provider string
|
||||
apiMode string
|
||||
basePath string
|
||||
wantPath string
|
||||
}{
|
||||
{"anthropic origin", MonitorProviderAnthropic, "", "", "/v1/messages"},
|
||||
{"anthropic prefix", MonitorProviderAnthropic, "", "/anthropic", "/anthropic/v1/messages"},
|
||||
{"anthropic trailing slash", MonitorProviderAnthropic, "", "/anthropic/", "/anthropic/v1/messages"},
|
||||
{"anthropic version", MonitorProviderAnthropic, "", "/anthropic/v1", "/anthropic/v1/messages"},
|
||||
{"version lookalike", MonitorProviderAnthropic, "", "/anthropic/v10", "/anthropic/v10/v1/messages"},
|
||||
{"encoded prefix", MonitorProviderAnthropic, "", "/tenant%2Fone/anthropic", "/tenant%2Fone/anthropic/v1/messages"},
|
||||
{"openai chat version", MonitorProviderOpenAI, "", "/relay/v1/", "/relay/v1/chat/completions"},
|
||||
{"openai responses version", MonitorProviderOpenAI, MonitorAPIModeResponses, "/relay/v1", "/relay/v1/responses"},
|
||||
{"gemini version", MonitorProviderGemini, "", "/relay/v1beta", "/relay/v1beta/models/test-model:generateContent"},
|
||||
{"zhipu version", MonitorProviderZhipu, "", "/api/paas/v4", "/api/paas/v4/chat/completions"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _, status, err := callProvider(context.Background(), tc.provider,
|
||||
server.URL+tc.basePath, "test-key", "test-model", "hello", &CheckOptions{APIMode: tc.apiMode})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, tc.wantPath, <-requests)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -120,8 +120,7 @@ func validateJitter(jitterSec, intervalSec int) error {
|
||||
|
||||
// validateEndpoint 校验 endpoint:
|
||||
// - scheme 强制 https(拒绝 http,避免明文凭证 + 部分 SSRF 利用面)
|
||||
// - 必须为 origin(无 path/query/fragment),防止用户填 https://api.openai.com/v1
|
||||
// 导致 joinURL 拼出 /v1/v1/chat/completions
|
||||
// - 允许上游路径前缀(如 /anthropic),不允许 query/fragment
|
||||
// - hostname 不能是 localhost/metadata 等已知元数据 hostname
|
||||
// - 解析所有 IP,任一落在 loopback/RFC1918/link-local/ULA 段即拒绝(防 SSRF)
|
||||
//
|
||||
@@ -141,9 +140,6 @@ func validateEndpoint(ep string) error {
|
||||
if u.Host == "" {
|
||||
return ErrChannelMonitorInvalidEndpoint
|
||||
}
|
||||
if u.Path != "" && u.Path != "/" {
|
||||
return ErrChannelMonitorEndpointPath
|
||||
}
|
||||
if u.RawQuery != "" || u.Fragment != "" {
|
||||
return ErrChannelMonitorEndpointPath
|
||||
}
|
||||
@@ -161,8 +157,8 @@ func validateEndpoint(ep string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeEndpoint 去除前后空白与末尾 `/`,保证存储统一为 origin。
|
||||
// validateEndpoint 已确保格式合法(仅 origin),这里只做最终归一化。
|
||||
// normalizeEndpoint 去除前后空白与末尾 `/`,保留上游路径前缀。
|
||||
// validateEndpoint 已确保格式合法,这里只做最终归一化。
|
||||
func normalizeEndpoint(ep string) string {
|
||||
ep = strings.TrimSpace(ep)
|
||||
ep = strings.TrimRight(ep, "/")
|
||||
|
||||
@@ -42,9 +42,8 @@ func (s *GatewayService) replaceModelInBody(body []byte, newModel string) []byte
|
||||
}
|
||||
|
||||
type claudeOAuthNormalizeOptions struct {
|
||||
injectMetadata bool
|
||||
metadataUserID string
|
||||
stripSystemCacheControl bool
|
||||
injectMetadata bool
|
||||
metadataUserID string
|
||||
}
|
||||
|
||||
// sanitizeSystemText rewrites only the fixed OpenCode identity sentence (if present).
|
||||
@@ -141,7 +140,14 @@ func deleteJSONPathBytes(body []byte, path string) ([]byte, bool) {
|
||||
return next, true
|
||||
}
|
||||
|
||||
func normalizeClaudeOAuthSystemBody(body []byte, opts claudeOAuthNormalizeOptions) ([]byte, bool) {
|
||||
// normalizeClaudeOAuthSystemBody 只做 system 文本的规范化,**不动 cache_control**。
|
||||
//
|
||||
// 这里曾经按 opts 剥离客户端打在 system 上的断点。那个动作是「system 必然被整个
|
||||
// 重写」时代的配套:内容都搬进 messages 了,残留断点指着空气。system 注入变成
|
||||
// 可配置之后前提就没了——注入开启时留在 system 上的断点是我们自己拼的稳定锚点,
|
||||
// 注入关闭时它是客户端的缓存意图,两种情形都没有删它的理由。
|
||||
// 4 块上限属于上游硬约束,由 enforceCacheControlLimit 在各条出口兜底。
|
||||
func normalizeClaudeOAuthSystemBody(body []byte) ([]byte, bool) {
|
||||
sys := gjson.GetBytes(body, "system")
|
||||
if !sys.Exists() {
|
||||
return body, false
|
||||
@@ -176,13 +182,6 @@ func normalizeClaudeOAuthSystemBody(body []byte, opts claudeOAuthNormalizeOption
|
||||
}
|
||||
}
|
||||
|
||||
if opts.stripSystemCacheControl && item.Get("cache_control").Exists() {
|
||||
if next, ok := deleteJSONPathBytes(out, fmt.Sprintf("system.%d.cache_control", index)); ok {
|
||||
out = next
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
|
||||
index++
|
||||
return true
|
||||
})
|
||||
@@ -229,7 +228,7 @@ func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAu
|
||||
out := body
|
||||
modified := false
|
||||
|
||||
if next, changed := normalizeClaudeOAuthSystemBody(out, opts); changed {
|
||||
if next, changed := normalizeClaudeOAuthSystemBody(out); changed {
|
||||
out = next
|
||||
modified = true
|
||||
}
|
||||
@@ -389,14 +388,12 @@ func (s *GatewayService) applyClaudeCodeOAuthMimicryToBody(
|
||||
}
|
||||
|
||||
systemPromptInjectionEnabled, systemPrompt, systemPromptBlocks := s.claudeOAuthSystemPromptInjectionSettings(ctx)
|
||||
systemRewritten := false
|
||||
if systemPromptInjectionEnabled {
|
||||
systemPromptBlocks = claudeOAuthSystemPromptBlocksForModel(model, systemPromptBlocks)
|
||||
body = rewriteSystemForNonClaudeCodeWithPromptBlocks(body, normalizeSystemParam(systemRaw), systemPrompt, systemPromptBlocks)
|
||||
systemRewritten = true
|
||||
}
|
||||
|
||||
normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: !systemRewritten}
|
||||
normalizeOpts := claudeOAuthNormalizeOptions{}
|
||||
|
||||
if s.identityService != nil && c != nil && c.Request != nil {
|
||||
if fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header); err == nil && fp != nil {
|
||||
|
||||
@@ -59,9 +59,8 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
|
||||
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCodeCT
|
||||
|
||||
if shouldMimicClaudeCode {
|
||||
normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true}
|
||||
var normalizedBody []byte
|
||||
normalizedBody, reqModel = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts)
|
||||
normalizedBody, reqModel = normalizeClaudeOAuthRequestBody(body, reqModel, claudeOAuthNormalizeOptions{})
|
||||
if err := replaceBody(normalizedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -78,6 +77,13 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 4 块上限的兜底:其余四条出口都在自己的转发路径上调过一次,只有这里没有。
|
||||
// 不再剥离客户端 system 断点之后,「客户端 system + 客户端 messages +
|
||||
// 上面刚注入的 tools[-1]」可以直接顶到 5 块,而上游对超限是 400。
|
||||
if err := replaceBody(enforceCacheControlLimit(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Antigravity 账户不支持 count_tokens,返回 404 让客户端 fallback 到本地估算。
|
||||
|
||||
@@ -201,20 +201,15 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
// Code..." system prompt 但缺少 billing attribution block,导致 Anthropic
|
||||
// 检测到"有 CC prompt 但无 billing block"的不一致而判为 third-party。
|
||||
// Parrot 的 transform_request 从不检查客户端 system 内容,直接覆盖。
|
||||
systemRewritten := false
|
||||
systemRaw, _ := parsed.SystemValue()
|
||||
systemPromptInjectionEnabled, systemPrompt, systemPromptBlocks := s.claudeOAuthSystemPromptInjectionSettings(ctx)
|
||||
if systemPromptInjectionEnabled {
|
||||
if err := replaceBody(rewriteSystemForNonClaudeCodeWithPromptBlocks(body, systemRaw, systemPrompt, systemPromptBlocks)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
systemRewritten = true
|
||||
}
|
||||
|
||||
// system 被重写时保留 CC prompt 的 cache_control: ephemeral(匹配真实 Claude Code 行为);
|
||||
// 未重写时(注入开关关闭)剥离客户端 cache_control,与原有行为一致。
|
||||
// 两种情况下 enforceCacheControlLimit 都会兜底处理上限。
|
||||
normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: !systemRewritten}
|
||||
normalizeOpts := claudeOAuthNormalizeOptions{}
|
||||
if s.identityService != nil && c != nil {
|
||||
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header)
|
||||
if err == nil && fp != nil {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// 客户端打在 system 上的 cache_control 是它自己的缓存意图,网关不该替它删掉。
|
||||
//
|
||||
// 这些用例守的是 #2369 里被漏掉的那一半:messages 层的同类改写已经收进
|
||||
// rewrite_message_cache_control 开关(默认不动客户端断点),system 层却一直没有
|
||||
// 对应的保护——mimic 路径无条件剥离,且不打日志、不报错。
|
||||
//
|
||||
// 剥离在两种情形下都没有成立的前提:注入开启时 system 已被整个替换、客户端断点
|
||||
// 早就不在了;注入关闭时 system 是客户端原样,断点就是它的意图。
|
||||
|
||||
// 多块 system 各自带断点时,一块都不能少:Anthropic 允许最多 4 个,
|
||||
// 真正的上限兜底是 enforceCacheControlLimit 的事,不该在这里提前砍。
|
||||
func TestNormalizeClaudeOAuthRequestBody_KeepsEveryClientSystemBreakpoint(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-sonnet-4-6","system":[` +
|
||||
`{"type":"text","text":"block one","cache_control":{"type":"ephemeral","ttl":"5m"}},` +
|
||||
`{"type":"text","text":"block two"},` +
|
||||
`{"type":"text","text":"block three","cache_control":{"type":"ephemeral","ttl":"1h"}}` +
|
||||
`],"messages":[{"role":"user","content":"hi"}]}`)
|
||||
|
||||
out, _ := normalizeClaudeOAuthRequestBody(body, "claude-sonnet-4-6", claudeOAuthNormalizeOptions{})
|
||||
|
||||
require.Equal(t, "5m", gjson.GetBytes(out, "system.0.cache_control.ttl").String(),
|
||||
"客户端选的 TTL 要原样留着")
|
||||
require.False(t, gjson.GetBytes(out, "system.1.cache_control").Exists(), "没有的不该凭空长出来")
|
||||
require.Equal(t, "1h", gjson.GetBytes(out, "system.2.cache_control.ttl").String())
|
||||
}
|
||||
|
||||
// 保留断点不等于放弃 system 文本的规范化:OpenCode 身份句该改的还得改。
|
||||
// 这条用例区分「拿掉剥离」与「把整段 normalize 关掉」——后者会让第三方指纹漏上去。
|
||||
func TestNormalizeClaudeOAuthRequestBody_StillSanitizesTextWhileKeepingBreakpoint(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-sonnet-4-6","system":[{"type":"text","text":"You are OpenCode, the best coding agent on the planet.","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":"hi"}]}`)
|
||||
|
||||
out, _ := normalizeClaudeOAuthRequestBody(body, "claude-sonnet-4-6", claudeOAuthNormalizeOptions{})
|
||||
|
||||
require.Equal(t, strings.TrimSpace(claudeCodeSystemPrompt), gjson.GetBytes(out, "system.0.text").String())
|
||||
require.True(t, gjson.GetBytes(out, "system.0.cache_control").Exists(),
|
||||
"文本被规范化,断点仍要留着")
|
||||
}
|
||||
|
||||
// 注入开启这条路径上,system 是我们自己拼的;blocks 配置里自带的 cache_control
|
||||
// 是稳定缓存锚点(对齐真实 CLI 的形态),同样不能在 normalize 里掉。
|
||||
func TestNormalizeClaudeOAuthRequestBody_KeepsInjectedSystemBreakpoint(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-sonnet-4-6","system":"client instructions","messages":[{"role":"user","content":"hi"}]}`)
|
||||
rewritten := rewriteSystemForNonClaudeCode(body, "client instructions")
|
||||
|
||||
out, _ := normalizeClaudeOAuthRequestBody(rewritten, "claude-sonnet-4-6", claudeOAuthNormalizeOptions{})
|
||||
|
||||
// 只断言「注入后的 system 仍带着锚点」,不写死是第几块:
|
||||
// 块数与布局由 blocks 配置决定,焊进断言会让配置一改就误报。
|
||||
_, _, _, systemPaths := collectCacheControlPaths(out)
|
||||
require.NotEmpty(t, systemPaths, "注入路径自带的稳定锚点不该在 normalize 里掉")
|
||||
}
|
||||
|
||||
// count_tokens 是五条出口里唯一没有在自己转发路径上调过 enforceCacheControlLimit 的
|
||||
// 那条。不再剥离客户端 system 断点之后,客户端自带的断点会和 tools[-1] 上注入的那个
|
||||
// 叠加,可以直接顶破 4 块上限——而上游对超限是 400。
|
||||
//
|
||||
// 用 setup-token 账号构造 mimic 分支:IsOAuth 认它,取 token 只读 credentials,
|
||||
// 不碰 DB。UA 非 claude-cli 且无 metadata.user_id,于是 shouldMimicClaudeCode 成立。
|
||||
func TestForwardCountTokens_EnforcesCacheControlLimitOnMimicPath(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil)
|
||||
c.Request.Header.Set("User-Agent", "third-party-client/1.0")
|
||||
|
||||
// 客户端自己打满 4 个断点:system 一个、messages 三个。
|
||||
// 加上 mimic 注入的 tools[-1] 与 system blocks 自带的锚点,必然超过 4。
|
||||
body := []byte(`{"model":"claude-sonnet-4-6",` +
|
||||
`"system":[{"type":"text","text":"stable client prefix","cache_control":{"type":"ephemeral","ttl":"5m"}}],` +
|
||||
`"tools":[{"name":"probe","description":"d","input_schema":{"type":"object"}}],` +
|
||||
`"messages":[` +
|
||||
`{"role":"user","content":[{"type":"text","text":"one","cache_control":{"type":"ephemeral"}}]},` +
|
||||
`{"role":"assistant","content":[{"type":"text","text":"two","cache_control":{"type":"ephemeral"}}]},` +
|
||||
`{"role":"user","content":[{"type":"text","text":"three","cache_control":{"type":"ephemeral"}}]}` +
|
||||
`]}`)
|
||||
parsed := &ParsedRequest{Body: NewRequestBodyRef(body), Model: "claude-sonnet-4-6"}
|
||||
|
||||
upstream := &anthropicHTTPUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"input_tokens":42}`)),
|
||||
},
|
||||
}
|
||||
svc := &GatewayService{
|
||||
cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}},
|
||||
httpUpstream: upstream,
|
||||
rateLimitService: &RateLimitService{},
|
||||
}
|
||||
account := &Account{
|
||||
ID: 401,
|
||||
Name: "count-tokens-ceiling-test",
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeSetupToken,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{"access_token": "test-oauth-token"},
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
}
|
||||
|
||||
require.NoError(t, svc.ForwardCountTokens(context.Background(), c, account, parsed))
|
||||
|
||||
_, messagePaths, toolPaths, systemPaths := collectCacheControlPaths(upstream.lastBody)
|
||||
total := len(messagePaths) + len(toolPaths) + len(systemPaths)
|
||||
require.LessOrEqual(t, total, maxCacheControlBlocks,
|
||||
"出站 body 的 cache_control 块数必须被砍到上限内,实测 %d 块", total)
|
||||
}
|
||||
@@ -114,7 +114,7 @@ func codexCanonicalUserAgent() string {
|
||||
resolver := codexCanonicalUAResolver
|
||||
codexCanonicalUAMu.RUnlock()
|
||||
if resolver != nil {
|
||||
if ua := strings.TrimSpace(resolver()); ua != "" {
|
||||
if ua := resolver(); strings.TrimSpace(ua) != "" {
|
||||
return ua
|
||||
}
|
||||
}
|
||||
@@ -139,8 +139,11 @@ type codexOutboundIdentity struct {
|
||||
// 需要固定版本请填「Codex 客户端版本号」并关闭自动同步。
|
||||
func resolveCodexOutboundIdentity(candidateUA string) codexOutboundIdentity {
|
||||
canonical := codexCanonicalUserAgent()
|
||||
ua := strings.TrimSpace(candidateUA)
|
||||
if ua == "" {
|
||||
if _, _, ok := openai.PairCodexClientIdentity(canonical); !ok {
|
||||
canonical = codexCLIUserAgent
|
||||
}
|
||||
ua := candidateUA
|
||||
if strings.TrimSpace(ua) == "" {
|
||||
ua = canonical
|
||||
}
|
||||
originator, pairedUA, ok := openai.PairCodexClientIdentity(ua)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCanonicalCodexIdentityValidatesBeforeTrimming(t *testing.T) {
|
||||
codexCanonicalUAMu.RLock()
|
||||
previous := codexCanonicalUAResolver
|
||||
codexCanonicalUAMu.RUnlock()
|
||||
t.Cleanup(func() { SetCodexCanonicalUserAgentResolver(previous) })
|
||||
|
||||
for _, raw := range []string{
|
||||
"\ncodex-tui/0.200.1 (Windows)",
|
||||
"codex-tui/0.200.1 (Windows)\n",
|
||||
"codex-tui/0.200.1 bad\x00",
|
||||
} {
|
||||
SetCodexCanonicalUserAgentResolver(func() string { return raw })
|
||||
identity := resolveCodexOutboundIdentity("")
|
||||
require.Equal(t, codexCLIUserAgent, identity.userAgent)
|
||||
require.Equal(t, codexCLIVersion, identity.version)
|
||||
}
|
||||
|
||||
SetCodexCanonicalUserAgentResolver(nil)
|
||||
identity := resolveCodexOutboundIdentity("codex_cli_rs/0.145.2 (Windows)\n")
|
||||
require.Equal(t, codexCLIUserAgent, identity.userAgent)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -17,7 +18,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/repository"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
"github.com/lib/pq"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -30,6 +31,8 @@ const (
|
||||
defaultUserConcurrency = 5
|
||||
simpleModeAdminConcurrency = 30
|
||||
defaultMigrationTimeout = 60 * time.Second
|
||||
postgresBootstrapDatabase = "postgres"
|
||||
databasePingTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
func setupDefaultAdminConcurrency() int {
|
||||
@@ -185,39 +188,63 @@ func buildPostgresDSN(cfg *DatabaseConfig, dbName string) string {
|
||||
)
|
||||
}
|
||||
|
||||
func buildDatabaseConnectionDSNs(cfg *DatabaseConfig) (bootstrapDSN, targetDSN string) {
|
||||
return buildPostgresDSN(cfg, "postgres"), buildPostgresDSN(cfg, cfg.DBName)
|
||||
func isDatabaseNotFoundError(err error) bool {
|
||||
var pqErr *pq.Error
|
||||
return errors.As(err, &pqErr) && pqErr.Code == "3D000"
|
||||
}
|
||||
|
||||
func openAndPingPostgresDatabase(cfg *DatabaseConfig, dbName string) (*sql.DB, error) {
|
||||
db, err := sql.Open("postgres", buildPostgresDSN(cfg, dbName))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), databasePingTimeout)
|
||||
err = db.PingContext(ctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
type postgresDatabaseOpener func(*DatabaseConfig, string) (*sql.DB, error)
|
||||
|
||||
// TestDatabaseConnection tests the database connection and creates database if not exists
|
||||
func TestDatabaseConnection(cfg *DatabaseConfig) error {
|
||||
// First, connect to the default 'postgres' database to check/create target database.
|
||||
// Connecting to cfg.DBName here fails when the target database has not been
|
||||
// created yet, so the bootstrap connection must use PostgreSQL's maintenance DB.
|
||||
defaultDSN, targetDSN := buildDatabaseConnectionDSNs(cfg)
|
||||
return testDatabaseConnection(cfg, openAndPingPostgresDatabase)
|
||||
}
|
||||
|
||||
db, err := sql.Open("postgres", defaultDSN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to PostgreSQL: %w", err)
|
||||
func testDatabaseConnection(cfg *DatabaseConfig, openDatabase postgresDatabaseOpener) error {
|
||||
// Prefer the configured database so existing installations remain
|
||||
// independent of any server-specific maintenance database.
|
||||
targetDB, err := openDatabase(cfg, cfg.DBName)
|
||||
if err == nil {
|
||||
if closeErr := targetDB.Close(); closeErr != nil {
|
||||
logger.LegacyPrintf("setup", "failed to close postgres connection: %v", closeErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !isDatabaseNotFoundError(err) {
|
||||
return fmt.Errorf("ping target database failed: %w", err)
|
||||
}
|
||||
|
||||
// Preserve the existing postgres bootstrap path only when the target is missing.
|
||||
db, err := openDatabase(cfg, postgresBootstrapDatabase)
|
||||
if err != nil {
|
||||
return fmt.Errorf("target database '%s' does not exist; failed to connect to bootstrap database '%s': %w", cfg.DBName, postgresBootstrapDatabase, err)
|
||||
}
|
||||
defer func() {
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
logger.LegacyPrintf("setup", "failed to close postgres connection: %v", err)
|
||||
logger.LegacyPrintf("setup", "failed to close %s connection: %v", postgresBootstrapDatabase, err)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), databasePingTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
return fmt.Errorf("ping failed: %w", err)
|
||||
}
|
||||
|
||||
// Check if target database exists
|
||||
var exists bool
|
||||
row := db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", cfg.DBName)
|
||||
if err := row.Scan(&exists); err != nil {
|
||||
@@ -236,30 +263,17 @@ func TestDatabaseConnection(cfg *DatabaseConfig) error {
|
||||
logger.LegacyPrintf("setup", "Database '%s' created successfully", cfg.DBName)
|
||||
}
|
||||
|
||||
// Now connect to the target database to verify
|
||||
if err := db.Close(); err != nil {
|
||||
logger.LegacyPrintf("setup", "failed to close postgres connection: %v", err)
|
||||
}
|
||||
db = nil
|
||||
|
||||
targetDB, err := sql.Open("postgres", targetDSN)
|
||||
// Now connect to the target database to verify.
|
||||
targetDB, err = openDatabase(cfg, cfg.DBName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database '%s': %w", cfg.DBName, err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := targetDB.Close(); err != nil {
|
||||
logger.LegacyPrintf("setup", "failed to close postgres connection: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel2()
|
||||
|
||||
if err := targetDB.PingContext(ctx2); err != nil {
|
||||
return fmt.Errorf("ping target database failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
func TestDecideAdminBootstrap(t *testing.T) {
|
||||
@@ -226,7 +231,7 @@ func TestWriteConfigFileIncludesRedisUsername(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDatabaseConnectionDSNsUsesPostgresForBootstrap(t *testing.T) {
|
||||
func TestDatabaseConnectionDSNsUseConfiguredTargetAndLegacyBootstrapDatabase(t *testing.T) {
|
||||
cfg := &DatabaseConfig{
|
||||
Host: "db",
|
||||
Port: 5432,
|
||||
@@ -236,15 +241,117 @@ func TestBuildDatabaseConnectionDSNsUsesPostgresForBootstrap(t *testing.T) {
|
||||
SSLMode: "disable",
|
||||
}
|
||||
|
||||
bootstrapDSN, targetDSN := buildDatabaseConnectionDSNs(cfg)
|
||||
targetDSN := buildPostgresDSN(cfg, cfg.DBName)
|
||||
bootstrapDSN := buildPostgresDSN(cfg, postgresBootstrapDatabase)
|
||||
|
||||
if !strings.Contains(bootstrapDSN, "dbname=postgres") {
|
||||
t.Fatalf("bootstrap DSN = %q, want default postgres database", bootstrapDSN)
|
||||
}
|
||||
if strings.Contains(bootstrapDSN, "dbname=sub2api") {
|
||||
t.Fatalf("bootstrap DSN = %q, should not connect to target database before checking/creating it", bootstrapDSN)
|
||||
}
|
||||
if !strings.Contains(targetDSN, "dbname=sub2api") {
|
||||
t.Fatalf("target DSN = %q, want configured database", targetDSN)
|
||||
}
|
||||
if !strings.Contains(bootstrapDSN, "dbname=postgres") {
|
||||
t.Fatalf("bootstrap DSN = %q, want legacy postgres bootstrap database", bootstrapDSN)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDatabaseNotFoundError(t *testing.T) {
|
||||
if !isDatabaseNotFoundError(&pq.Error{Code: "3D000"}) {
|
||||
t.Fatal("isDatabaseNotFoundError() = false, want true for PostgreSQL invalid_catalog_name")
|
||||
}
|
||||
if isDatabaseNotFoundError(&pq.Error{Code: "28P01"}) {
|
||||
t.Fatal("isDatabaseNotFoundError() = true, want false for invalid_password")
|
||||
}
|
||||
if !isDatabaseNotFoundError(fmt.Errorf("wrapped: %w", &pq.Error{Code: "3D000"})) {
|
||||
t.Fatal("isDatabaseNotFoundError() = false, want true for wrapped PostgreSQL error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseConnectionUsesConfiguredTargetBeforeBootstrapDatabase(t *testing.T) {
|
||||
cfg := &DatabaseConfig{DBName: "customdb"}
|
||||
targetDB, _, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer func() { _ = targetDB.Close() }()
|
||||
|
||||
var opened []string
|
||||
openDatabase := func(_ *DatabaseConfig, dbName string) (*sql.DB, error) {
|
||||
opened = append(opened, dbName)
|
||||
return targetDB, nil
|
||||
}
|
||||
|
||||
if err := testDatabaseConnection(cfg, openDatabase); err != nil {
|
||||
t.Fatalf("testDatabaseConnection() error = %v", err)
|
||||
}
|
||||
if len(opened) != 1 || opened[0] != cfg.DBName {
|
||||
t.Fatalf("opened databases = %v, want only configured target %q", opened, cfg.DBName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseConnectionUsesLegacyBootstrapOnlyForMissingTarget(t *testing.T) {
|
||||
cfg := &DatabaseConfig{DBName: "customdb"}
|
||||
bootstrapDB, bootstrapMock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() bootstrap error = %v", err)
|
||||
}
|
||||
defer func() { _ = bootstrapDB.Close() }()
|
||||
targetDB, _, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() target error = %v", err)
|
||||
}
|
||||
defer func() { _ = targetDB.Close() }()
|
||||
|
||||
bootstrapMock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM pg_database WHERE datname = \$1\)`).
|
||||
WithArgs(cfg.DBName).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
|
||||
bootstrapMock.ExpectExec(`CREATE DATABASE customdb`).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
var opened []string
|
||||
targetAttempts := 0
|
||||
openDatabase := func(_ *DatabaseConfig, dbName string) (*sql.DB, error) {
|
||||
opened = append(opened, dbName)
|
||||
switch dbName {
|
||||
case cfg.DBName:
|
||||
targetAttempts++
|
||||
if targetAttempts == 1 {
|
||||
return nil, &pq.Error{Code: "3D000"}
|
||||
}
|
||||
return targetDB, nil
|
||||
case postgresBootstrapDatabase:
|
||||
return bootstrapDB, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected database %q", dbName)
|
||||
}
|
||||
}
|
||||
|
||||
if err := testDatabaseConnection(cfg, openDatabase); err != nil {
|
||||
t.Fatalf("testDatabaseConnection() error = %v", err)
|
||||
}
|
||||
if err := bootstrapMock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("bootstrap database expectations: %v", err)
|
||||
}
|
||||
wantOpened := []string{cfg.DBName, postgresBootstrapDatabase, cfg.DBName}
|
||||
if len(opened) != len(wantOpened) {
|
||||
t.Fatalf("opened databases = %v, want %v", opened, wantOpened)
|
||||
}
|
||||
for i := range wantOpened {
|
||||
if opened[i] != wantOpened[i] {
|
||||
t.Fatalf("opened databases = %v, want %v", opened, wantOpened)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseConnectionDoesNotFallbackForTargetAuthenticationError(t *testing.T) {
|
||||
cfg := &DatabaseConfig{DBName: "customdb"}
|
||||
var opened []string
|
||||
openDatabase := func(_ *DatabaseConfig, dbName string) (*sql.DB, error) {
|
||||
opened = append(opened, dbName)
|
||||
return nil, &pq.Error{Code: "28P01"}
|
||||
}
|
||||
|
||||
if err := testDatabaseConnection(cfg, openDatabase); err == nil {
|
||||
t.Fatal("testDatabaseConnection() error = nil, want authentication error")
|
||||
}
|
||||
if len(opened) != 1 || opened[0] != cfg.DBName {
|
||||
t.Fatalf("opened databases = %v, want only configured target %q", opened, cfg.DBName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ Sub2API can run as a native three-service stack with Apple's `container` CLI. Th
|
||||
|
||||
Apple `container` support is intended for local development and operator-managed deployments on a Mac. Docker Compose remains the recommended production deployment path.
|
||||
|
||||
Apple `container` 1.1 does not provide restart policies, automatic startup, workload health scheduling, a Docker API socket, or full Compose orchestration. `apple-container.sh` supplies ordered startup and readiness checks when you invoke it, but it is not a continuously running supervisor.
|
||||
Apple `container` 1.1 does not provide restart policies, automatic startup, workload health scheduling, a Docker API socket, or full Compose orchestration. `apple-container.sh` supplies ordered startup and readiness checks when you invoke it. Inside the application container, a small supervisor relaunches the Sub2API process after the Web UI requests a restart; it does not restart stopped containers or the stack itself.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -146,7 +146,7 @@ The script creates only resources carrying the `org.sub2api.stack=apple-containe
|
||||
| Network | `sub2api-apple` |
|
||||
| Volumes | `sub2api-apple-data`, `sub2api-apple-postgres-data`, `sub2api-apple-redis-data` |
|
||||
|
||||
The PostgreSQL volume is mounted at `/var/lib/postgresql`, retaining PostgreSQL 18's default child data directory. Sub2API and Redis also store data in child directories below their Apple volume mount points. This is required because Apple named volumes do not have Docker's copy-up and mount-point ownership behavior.
|
||||
The PostgreSQL volume is mounted at `/var/lib/postgresql`, retaining PostgreSQL 18's default child data directory. Sub2API data and its updatable runtime binary use separate child directories in `sub2api-apple-data`; Redis also stores data below its Apple volume mount point. This is required because Apple named volumes do not have Docker's copy-up and mount-point ownership behavior.
|
||||
|
||||
## Networking
|
||||
|
||||
@@ -158,6 +158,14 @@ The application container is intentionally recreated by every `up` and `restart`
|
||||
|
||||
The script checks the published `/health` endpoint from macOS before reporting success. Approve the Local Network prompt on first startup. If the internal probe succeeds but the host-port probe fails with a connection reset, enable Local Network access for `container-runtime-linux`, run `container system stop` followed by `container system start`, and then run `up` again. Runtime upgrades may prompt for permission again.
|
||||
|
||||
## Web UI Updates
|
||||
|
||||
The Web UI uses the same update flow as the Docker deployment: it downloads a release over GitHub, atomically replaces the active executable, and asks the application to restart. Docker supplies the restart policy in a Compose deployment; `apple-container.sh` supplies an equivalent process supervisor inside the Apple application container.
|
||||
|
||||
The active executable lives in `sub2api-apple-data` so a later `up`, `restart`, or `up --recreate` does not discard an update downloaded from the Web UI. The script records the configured base image ID alongside it; when `APPLE_CONTAINER_SUB2API_IMAGE` resolves to a different image ID, the image's `/app/sub2api` becomes the new active executable. This keeps explicit image upgrades authoritative while preserving in-place updates across routine application-container recreation.
|
||||
|
||||
If GitHub is not reachable directly, set `UPDATE_PROXY_URL` to a proxy address reachable from the Apple container VM. A proxy listening only on the Mac's `127.0.0.1` is not reachable as `127.0.0.1` from inside the VM; use an appropriately restricted host gateway listener instead.
|
||||
|
||||
## Backup and Upgrade
|
||||
|
||||
Pin image release tags or digests in `.env` before using this workflow for persistent data. Before an application or database image upgrade, create backups while the stack is healthy:
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ Apple-silicon Macs running macOS 26 can run the complete Sub2API, PostgreSQL, an
|
||||
./apple-container.sh logs app -f
|
||||
```
|
||||
|
||||
The script uses Apple named volumes, starts dependencies in order, and performs live readiness checks. It does not provide a continuous restart supervisor; run `./apple-container.sh up` after a host reboot. Docker Compose remains the recommended production deployment path.
|
||||
The script uses Apple named volumes, starts dependencies in order, and performs live readiness checks. The application container supervises the Sub2API process so the Web UI's update-and-restart flow can relaunch an updated binary. It does not provide host-level automatic startup; run `./apple-container.sh up` after a host reboot. Docker Compose remains the recommended production deployment path.
|
||||
|
||||
See [APPLE_CONTAINER.md](./APPLE_CONTAINER.md) for configuration, upgrades, persistence, networking behavior, and limitations.
|
||||
|
||||
|
||||
@@ -469,8 +469,8 @@ EOF
|
||||
}
|
||||
|
||||
prepare_app_environment() {
|
||||
[[ -n "${POSTGRES_ADDRESS}" && -n "${REDIS_ADDRESS}" ]] || \
|
||||
die "Dependency network addresses are not available."
|
||||
[[ -n "${POSTGRES_ADDRESS}" && -n "${REDIS_ADDRESS}" && -n "${APP_IMAGE_ID}" ]] || \
|
||||
die "Dependency network addresses or application image ID are not available."
|
||||
|
||||
cp "${ENV_FILE}" "${APP_ENV_FILE}"
|
||||
cat >>"${APP_ENV_FILE}" <<EOF
|
||||
@@ -488,6 +488,7 @@ REDIS_HOST=${REDIS_ADDRESS}
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=${REDIS_PASSWORD}
|
||||
DATA_DIR=/app/storage/data
|
||||
APPLE_CONTAINER_SUB2API_IMAGE_ID=${APP_IMAGE_ID}
|
||||
EOF
|
||||
chmod 600 "${APP_ENV_FILE}"
|
||||
}
|
||||
@@ -533,10 +534,61 @@ create_app_container() {
|
||||
--volume "${APP_VOLUME}:/app/storage" \
|
||||
--entrypoint /bin/sh \
|
||||
"${APP_IMAGE}" \
|
||||
-c 'set -e; mkdir -p "$DATA_DIR"; chown -R sub2api:sub2api "$DATA_DIR"; exec su-exec sub2api /app/sub2api' \
|
||||
-c '
|
||||
set -e
|
||||
mkdir -p "$DATA_DIR"
|
||||
chown -R sub2api:sub2api "$DATA_DIR"
|
||||
|
||||
runtime_dir=/app/storage/runtime
|
||||
runtime_binary="$runtime_dir/sub2api"
|
||||
image_marker="$runtime_dir/base-image-id"
|
||||
installed_image_id=""
|
||||
if [ -f "$image_marker" ]; then
|
||||
installed_image_id="$(cat "$image_marker")"
|
||||
fi
|
||||
if [ ! -x "$runtime_binary" ] || [ "$installed_image_id" != "$APPLE_CONTAINER_SUB2API_IMAGE_ID" ]; then
|
||||
mkdir -p "$runtime_dir"
|
||||
cp /app/sub2api "${runtime_binary}.new"
|
||||
chown sub2api:sub2api "${runtime_binary}.new"
|
||||
chmod 0755 "${runtime_binary}.new"
|
||||
mv "${runtime_binary}.new" "$runtime_binary"
|
||||
printf "%s\n" "$APPLE_CONTAINER_SUB2API_IMAGE_ID" >"${image_marker}.new"
|
||||
mv "${image_marker}.new" "$image_marker"
|
||||
rm -f "${runtime_binary}.backup"
|
||||
fi
|
||||
chown -R sub2api:sub2api "$runtime_dir"
|
||||
|
||||
child_pid=""
|
||||
stop() {
|
||||
trap - TERM INT
|
||||
if [ -n "$child_pid" ]; then
|
||||
kill -TERM "$child_pid" 2>/dev/null || true
|
||||
wait "$child_pid" 2>/dev/null || true
|
||||
fi
|
||||
exit 0
|
||||
}
|
||||
trap stop TERM INT
|
||||
|
||||
while true; do
|
||||
su-exec sub2api "$runtime_binary" &
|
||||
child_pid=$!
|
||||
set +e
|
||||
wait "$child_pid"
|
||||
status=$?
|
||||
set -e
|
||||
child_pid=""
|
||||
echo "Sub2API exited with status ${status}; restarting in 1 second..." >&2
|
||||
sleep 1
|
||||
done
|
||||
' \
|
||||
>/dev/null
|
||||
}
|
||||
|
||||
application_image_id() {
|
||||
container image inspect "${APP_IMAGE}" | \
|
||||
plutil -extract 0.id raw -o - -
|
||||
}
|
||||
|
||||
ensure_container() {
|
||||
local container_name=$1
|
||||
local create_function=$2
|
||||
@@ -680,6 +732,9 @@ cmd_up() {
|
||||
ensure_image_available "${APP_IMAGE}"
|
||||
ensure_image_available "${POSTGRES_IMAGE}"
|
||||
ensure_image_available "${REDIS_IMAGE}"
|
||||
APP_IMAGE_ID="$(application_image_id)" || \
|
||||
die "Unable to inspect application image ID for ${APP_IMAGE}."
|
||||
[[ -n "${APP_IMAGE_ID}" ]] || die "Application image ID must not be empty."
|
||||
|
||||
if [[ "${recreate}" == true ]]; then
|
||||
delete_container_if_present "${APP_CONTAINER}"
|
||||
|
||||
@@ -48,6 +48,16 @@ assert_exists "${STATE_DIR}/containers/sub2api-apple"
|
||||
assert_exists "${STATE_DIR}/containers/sub2api-apple-postgres"
|
||||
assert_exists "${STATE_DIR}/containers/sub2api-apple-redis"
|
||||
assert_exists "${STATE_DIR}/running/sub2api-apple"
|
||||
grep -q '^while true; do$' "${STATE_DIR}/create-arguments/sub2api-apple" || \
|
||||
fail "app container does not supervise the Sub2API process"
|
||||
grep -q '^ su-exec sub2api "$runtime_binary" &$' "${STATE_DIR}/create-arguments/sub2api-apple" || \
|
||||
fail "app supervisor does not launch the updatable Sub2API binary"
|
||||
grep -q '^trap stop TERM INT$' "${STATE_DIR}/create-arguments/sub2api-apple" || \
|
||||
fail "app supervisor does not handle container stop signals"
|
||||
grep -q '^runtime_binary="$runtime_dir/sub2api"$' "${STATE_DIR}/create-arguments/sub2api-apple" || \
|
||||
fail "app container does not run its updatable binary from persistent storage"
|
||||
grep -q '^APPLE_CONTAINER_SUB2API_IMAGE_ID=fake-image-id$' "${STATE_DIR}/env-files/sub2api-apple" || \
|
||||
fail "app container did not receive the inspected base image ID"
|
||||
[[ ! -s "${STATE_DIR}/network-subnets/sub2api-apple" ]] || \
|
||||
fail "up passed a subnet when APPLE_CONTAINER_NETWORK_SUBNET was unset"
|
||||
"${SCRIPT}" status >/dev/null
|
||||
|
||||
Vendored
+15
-2
@@ -9,6 +9,8 @@ mkdir -p \
|
||||
"${STATE_DIR}/networks" \
|
||||
"${STATE_DIR}/network-subnets" \
|
||||
"${STATE_DIR}/volumes" \
|
||||
"${STATE_DIR}/create-arguments" \
|
||||
"${STATE_DIR}/env-files" \
|
||||
"${STATE_DIR}/unowned/container" \
|
||||
"${STATE_DIR}/unowned/network" \
|
||||
"${STATE_DIR}/unowned/volume"
|
||||
@@ -129,19 +131,26 @@ case "${command}" in
|
||||
image)
|
||||
subcommand=${1-}
|
||||
case "${subcommand}" in
|
||||
inspect|pull) exit 0 ;;
|
||||
inspect) printf '[{"id":"fake-image-id"}]\n' ;;
|
||||
pull) exit 0 ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
;;
|
||||
create)
|
||||
create_arguments=("$@")
|
||||
name=""
|
||||
env_file=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name)
|
||||
name=$2
|
||||
shift 2
|
||||
;;
|
||||
--label|--network|--platform|--ulimit|--env-file|--volume|--entrypoint|--publish)
|
||||
--env-file)
|
||||
env_file=$2
|
||||
shift 2
|
||||
;;
|
||||
--label|--network|--platform|--ulimit|--volume|--entrypoint|--publish)
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
@@ -150,6 +159,10 @@ case "${command}" in
|
||||
esac
|
||||
done
|
||||
[[ -n "${name}" ]]
|
||||
printf '%s\n' "${create_arguments[@]}" >"${STATE_DIR}/create-arguments/${name}"
|
||||
if [[ -n "${env_file}" ]]; then
|
||||
cp "${env_file}" "${STATE_DIR}/env-files/${name}"
|
||||
fi
|
||||
touch "${STATE_DIR}/containers/${name}"
|
||||
;;
|
||||
inspect)
|
||||
|
||||
@@ -129,7 +129,7 @@ export interface CreateParams {
|
||||
name: string
|
||||
provider: Provider
|
||||
api_mode?: APIMode
|
||||
/** 探活模式必填(base origin);quota 模式可留空 */
|
||||
/** 探活模式必填(可含路径前缀的 HTTPS base URL);quota 模式可留空 */
|
||||
endpoint: string
|
||||
/** 探活模式必填;quota 模式可留空 */
|
||||
api_key: string
|
||||
|
||||
@@ -410,19 +410,19 @@
|
||||
<div class="text-xs font-semibold text-gray-300 mb-1">{{ t('usage.costDetails') }}</div>
|
||||
<div v-if="tooltipData && tooltipData.input_cost > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.inputCost') }}</span>
|
||||
<span class="font-medium text-white">${{ tooltipData.input_cost.toFixed(6) }}</span>
|
||||
<span class="font-medium text-white">${{ tooltipData.input_cost.toFixed(8) }}</span>
|
||||
</div>
|
||||
<div v-if="tooltipData && hasImageInputCost(tooltipData)" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('usage.imageInputCost') }}</span>
|
||||
<span class="font-medium text-fuchsia-300">${{ tooltipData.image_input_cost.toFixed(6) }}</span>
|
||||
<span class="font-medium text-fuchsia-300">${{ tooltipData.image_input_cost.toFixed(8) }}</span>
|
||||
</div>
|
||||
<div v-if="tooltipData && tooltipData.output_cost > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
|
||||
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
|
||||
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(8) }}</span>
|
||||
</div>
|
||||
<div v-if="tooltipData && hasImageOutputCost(tooltipData)" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('usage.imageOutputCost') }}</span>
|
||||
<span class="font-medium text-pink-300">${{ tooltipData.image_output_cost.toFixed(6) }}</span>
|
||||
<span class="font-medium text-pink-300">${{ tooltipData.image_output_cost.toFixed(8) }}</span>
|
||||
</div>
|
||||
<!-- Token billing: show unit prices per 1M tokens -->
|
||||
<template v-if="tooltipData && !isImageUsage(tooltipData) && (!tooltipData.billing_mode || tooltipData.billing_mode === BILLING_MODE_TOKEN)">
|
||||
@@ -470,24 +470,24 @@
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('usage.imageUnitPrice') }}</span>
|
||||
<span class="font-medium text-sky-300">${{ imageUnitPrice(tooltipData).toFixed(6) }}</span>
|
||||
<span class="font-medium text-sky-300">${{ imageUnitPrice(tooltipData).toFixed(8) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('usage.imageTotalPrice') }}</span>
|
||||
<span class="font-medium text-white">${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}</span>
|
||||
<span class="font-medium text-white">${{ tooltipData.total_cost?.toFixed(8) || '0.00000000' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('usage.unitPrice') }}</span>
|
||||
<span class="font-medium text-sky-300">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
|
||||
<span class="font-medium text-sky-300">${{ tooltipData?.total_cost?.toFixed(8) || '0.00000000' }}</span>
|
||||
</div>
|
||||
<div v-if="tooltipData && tooltipData.cache_creation_cost > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span>
|
||||
<span class="font-medium text-white">${{ tooltipData.cache_creation_cost.toFixed(6) }}</span>
|
||||
<span class="font-medium text-white">${{ tooltipData.cache_creation_cost.toFixed(8) }}</span>
|
||||
</div>
|
||||
<div v-if="tooltipData && tooltipData.cache_read_cost > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.cacheReadCost') }}</span>
|
||||
<span class="font-medium text-white">${{ tooltipData.cache_read_cost.toFixed(6) }}</span>
|
||||
<span class="font-medium text-white">${{ tooltipData.cache_read_cost.toFixed(8) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Rate and Summary -->
|
||||
@@ -501,11 +501,11 @@
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-gray-400">{{ t('usage.original') }}</span>
|
||||
<span class="font-medium text-white">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
|
||||
<span class="font-medium text-white">${{ tooltipData?.total_cost?.toFixed(8) || '0.00000000' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-gray-400">{{ t('usage.userBilled') }}</span>
|
||||
<span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</span>
|
||||
<span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(8) || '0.00000000' }}</span>
|
||||
</div>
|
||||
<!-- Account billing (separated from user billing) -->
|
||||
<template v-if="showAccountBilling">
|
||||
@@ -520,7 +520,7 @@
|
||||
total_cost: tooltipData?.total_cost,
|
||||
account_stats_cost: tooltipData?.account_stats_cost,
|
||||
account_rate_multiplier: tooltipData?.account_rate_multiplier,
|
||||
}).toFixed(6) }}
|
||||
}).toFixed(8) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -275,6 +275,53 @@ describe('admin UsageTable tooltip', () => {
|
||||
expect(text).toContain('$0.069568')
|
||||
})
|
||||
|
||||
it.each(['token', 'image', 'per_request'])('keeps eight decimal places in %s cost details', async (billingMode) => {
|
||||
const row = {
|
||||
...baseImageRow,
|
||||
billing_mode: billingMode,
|
||||
image_count: billingMode === 'image' ? 2 : 0,
|
||||
input_cost: 0.00000001,
|
||||
image_input_cost: 0.00000002,
|
||||
output_cost: 0.00000003,
|
||||
image_output_cost: 0.00000004,
|
||||
cache_creation_cost: 0.00000005,
|
||||
cache_read_cost: 0.00000006,
|
||||
total_cost: 0.00000022,
|
||||
actual_cost: 0.00000042,
|
||||
account_stats_cost: 0.00000012,
|
||||
account_rate_multiplier: 1.5,
|
||||
}
|
||||
const wrapper = mount(UsageTable, {
|
||||
props: { data: [row], loading: false, columns: [] },
|
||||
global: { stubs: { DataTable: DataTableStub, EmptyState: true, Icon: true, Teleport: true } },
|
||||
})
|
||||
const triggers = wrapper.findAll('.group.relative')
|
||||
await triggers[triggers.length - 1].trigger('mouseenter')
|
||||
const amounts = wrapper.get('.fixed').findAll('span').map(span => span.text())
|
||||
expect(amounts).toEqual(expect.arrayContaining([
|
||||
'$0.00000001', '$0.00000002', '$0.00000003', '$0.00000004',
|
||||
'$0.00000005', '$0.00000006', '$0.00000022', '$0.00000042', '$0.00000018',
|
||||
]))
|
||||
if (billingMode === 'image') expect(amounts).toContain('$0.00000011')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('uses eight decimal places for missing cost values', async () => {
|
||||
const wrapper = mount(UsageTable, {
|
||||
props: {
|
||||
data: [{ ...baseImageRow, billing_mode: 'per_request', image_count: 0, total_cost: undefined, actual_cost: undefined }],
|
||||
loading: false,
|
||||
columns: [],
|
||||
},
|
||||
global: { stubs: { DataTable: DataTableStub, EmptyState: true, Icon: true, Teleport: true } },
|
||||
})
|
||||
const triggers = wrapper.findAll('.group.relative')
|
||||
await triggers[triggers.length - 1].trigger('mouseenter')
|
||||
const amounts = wrapper.get('.fixed').findAll('span').map(span => span.text()).filter(text => text.startsWith('$'))
|
||||
expect(amounts).toEqual(['$0.00000000', '$0.00000000', '$0.00000000', '$0.00000000'])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows requested and upstream models separately for admin rows', () => {
|
||||
const row = {
|
||||
request_id: 'req-admin-model-1',
|
||||
|
||||
@@ -427,7 +427,7 @@ export default {
|
||||
workerActive: 'Processing an async audit or record task',
|
||||
workerIdle: 'Started, idle and ready',
|
||||
workerDisabled: 'Risk control or content audit is disabled',
|
||||
processed: 'Processed',
|
||||
processed: 'Async processed',
|
||||
droppedErrors: 'Dropped / Errors',
|
||||
autoRefresh: 'Auto refresh every 15s',
|
||||
lastCleanup: 'Last cleanup: {time}',
|
||||
|
||||
@@ -436,6 +436,13 @@ export default {
|
||||
title: 'User Management',
|
||||
description: 'Manage users and their permissions',
|
||||
createUser: 'Create User',
|
||||
bulkDelete: {
|
||||
action: 'Delete selected ({count})',
|
||||
title: 'Delete selected users',
|
||||
confirm: 'Delete the {count} selected users? This action cannot be undone. Administrator accounts cannot be deleted.',
|
||||
success: 'Deleted {count} users',
|
||||
failed: 'Failed to delete {count} users. They remain selected for retry.'
|
||||
},
|
||||
bulkLimits: {
|
||||
action: 'Set limits ({count})',
|
||||
title: 'Set user limits',
|
||||
|
||||
@@ -677,6 +677,7 @@ export default {
|
||||
namePlaceholder: 'e.g. Help Center',
|
||||
url: 'Page URL',
|
||||
urlPlaceholder: 'https://example.com/page',
|
||||
hideOpenButton: 'Hide the “Open in new tab” button',
|
||||
iconSvg: 'SVG Icon',
|
||||
iconSvgPlaceholder: '<svg>...</svg>',
|
||||
iconPreview: 'Icon Preview',
|
||||
|
||||
@@ -427,7 +427,7 @@ export default {
|
||||
workerActive: '正在处理异步审计或记录任务',
|
||||
workerIdle: '已启动,当前空闲可用',
|
||||
workerDisabled: '风控或内容审计未启用',
|
||||
processed: '已处理',
|
||||
processed: '异步已处理',
|
||||
droppedErrors: '丢弃/异常',
|
||||
autoRefresh: '每 15 秒自动刷新',
|
||||
lastCleanup: '上次清理:{time}',
|
||||
|
||||
@@ -436,6 +436,13 @@ export default {
|
||||
title: '用户管理',
|
||||
description: '管理用户账户和权限',
|
||||
createUser: '创建用户',
|
||||
bulkDelete: {
|
||||
action: '批量删除({count})',
|
||||
title: '删除已选用户',
|
||||
confirm: '确定删除已选的 {count} 个用户吗?此操作无法撤销。管理员账号无法删除。',
|
||||
success: '已删除 {count} 个用户',
|
||||
failed: '{count} 个用户删除失败,已保留选中,可重试。'
|
||||
},
|
||||
bulkLimits: {
|
||||
action: '批量设置限制({count})',
|
||||
title: '批量设置用户限制',
|
||||
|
||||
@@ -672,6 +672,7 @@ export default {
|
||||
namePlaceholder: '如:帮助中心',
|
||||
url: '页面 URL',
|
||||
urlPlaceholder: 'https://example.com/page',
|
||||
hideOpenButton: '隐藏“新窗口打开”按钮',
|
||||
iconSvg: 'SVG 图标',
|
||||
iconSvgPlaceholder: '<svg>...</svg>',
|
||||
iconPreview: '图标预览',
|
||||
|
||||
@@ -191,6 +191,7 @@ export interface CustomMenuItem {
|
||||
icon_svg: string
|
||||
url: string
|
||||
page_slug?: string
|
||||
hide_open_button?: boolean
|
||||
visibility: 'user' | 'admin'
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
@@ -350,7 +350,7 @@
|
||||
v-model.number="generateForm.validity_days"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
max="36500"
|
||||
required
|
||||
class="input"
|
||||
/>
|
||||
|
||||
@@ -6763,6 +6763,17 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 sm:col-span-2">
|
||||
<input
|
||||
v-model="item.hide_open_button"
|
||||
type="checkbox"
|
||||
data-testid="custom-menu-hide-open-button"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t("admin.settings.customMenu.hideOpenButton") }}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- SVG Icon (full width) -->
|
||||
<div class="sm:col-span-2">
|
||||
<label
|
||||
@@ -9617,6 +9628,7 @@ const form = reactive<SettingsForm>({
|
||||
url: string;
|
||||
visibility: "user" | "admin";
|
||||
sort_order: number;
|
||||
hide_open_button?: boolean;
|
||||
}>,
|
||||
custom_endpoints: [] as Array<{
|
||||
name: string;
|
||||
|
||||
@@ -769,7 +769,7 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { UserSubscription, Group, GroupPlatform, SubscriptionType } from '@/types'
|
||||
import type { AdminUser, UserSubscription, Group, GroupPlatform, SubscriptionType } from '@/types'
|
||||
import type { SimpleUser } from '@/api/admin/usage'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import { formatDateTimeToMinute } from '@/utils/format'
|
||||
@@ -944,10 +944,10 @@ let filterUserSearchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// User search state
|
||||
const userSearchKeyword = ref('')
|
||||
const userSearchResults = ref<SimpleUser[]>([])
|
||||
const userSearchResults = ref<AdminUser[]>([])
|
||||
const userSearchLoading = ref(false)
|
||||
const showUserDropdown = ref(false)
|
||||
const selectedUser = ref<SimpleUser | null>(null)
|
||||
const selectedUser = ref<AdminUser | null>(null)
|
||||
let userSearchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const filters = reactive({
|
||||
@@ -1148,7 +1148,10 @@ const searchUsers = async () => {
|
||||
|
||||
userSearchLoading.value = true
|
||||
try {
|
||||
userSearchResults.value = await adminAPI.usage.searchUsers(keyword)
|
||||
const result = await adminAPI.users.list(1, 30, {
|
||||
search: keyword, sort_by: 'email', sort_order: 'asc'
|
||||
})
|
||||
userSearchResults.value = result.items
|
||||
} catch (error) {
|
||||
console.error('Failed to search users:', error)
|
||||
userSearchResults.value = []
|
||||
@@ -1157,7 +1160,7 @@ const searchUsers = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const selectUser = (user: SimpleUser) => {
|
||||
const selectUser = (user: AdminUser) => {
|
||||
selectedUser.value = user
|
||||
userSearchKeyword.value = user.email
|
||||
showUserDropdown.value = false
|
||||
|
||||
@@ -252,6 +252,17 @@
|
||||
{{ t('admin.users.bulkLimits.action', { count: selectedCount }) }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="selectedCount > 0"
|
||||
class="btn btn-danger flex-1 md:flex-initial"
|
||||
data-test="bulk-delete-users"
|
||||
:disabled="bulkDeleting"
|
||||
@click="bulkDeleteIds = [...selectedIds]"
|
||||
>
|
||||
<Icon name="trash" size="md" class="mr-2" />
|
||||
{{ t('admin.users.bulkDelete.action', { count: selectedCount }) }}
|
||||
</button>
|
||||
|
||||
<!-- Create User Button (full width on mobile, auto width on desktop) -->
|
||||
<button @click="showCreateModal = true" class="btn btn-primary flex-1 md:flex-initial">
|
||||
<Icon name="plus" size="md" class="mr-2" />
|
||||
@@ -748,6 +759,15 @@
|
||||
</Teleport>
|
||||
|
||||
<ConfirmDialog :show="showDeleteDialog" :title="t('admin.users.deleteUser')" :message="t('admin.users.deleteConfirm', { email: deletingUser?.email })" :danger="true" @confirm="confirmDelete" @cancel="showDeleteDialog = false" />
|
||||
<ConfirmDialog
|
||||
:show="bulkDeleteIds.length > 0"
|
||||
:title="t('admin.users.bulkDelete.title')"
|
||||
:message="t('admin.users.bulkDelete.confirm', { count: bulkDeleteIds.length })"
|
||||
:confirm-text="t('common.delete')"
|
||||
danger
|
||||
@confirm="confirmBulkDelete"
|
||||
@cancel="bulkDeleteIds = []"
|
||||
/>
|
||||
<UserCreateModal :show="showCreateModal" @close="showCreateModal = false" @success="loadUsers" />
|
||||
<UserEditModal :show="showEditModal" :user="editingUser" @close="closeEditModal" @success="loadUsers" />
|
||||
<BulkEditUserModal
|
||||
@@ -1295,7 +1315,8 @@ const {
|
||||
selectedIds,
|
||||
selectedCount,
|
||||
setSelectedIds,
|
||||
clear: clearSelection
|
||||
clear: clearSelection,
|
||||
removeMany: removeSelectedIds
|
||||
} = useTableSelection<AdminUser>({
|
||||
rows: sortedUsers,
|
||||
getId: (user) => user.id
|
||||
@@ -1322,6 +1343,8 @@ const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showBulkEditModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const bulkDeleteIds = ref<number[]>([])
|
||||
const bulkDeleting = ref(false)
|
||||
const showApiKeysModal = ref(false)
|
||||
const showAttributesModal = ref(false)
|
||||
const showPlatformQuotaModal = ref(false)
|
||||
@@ -1785,6 +1808,30 @@ const confirmDelete = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const confirmBulkDelete = async () => {
|
||||
const ids = bulkDeleteIds.value
|
||||
bulkDeleteIds.value = []
|
||||
bulkDeleting.value = true
|
||||
const deletedIds: number[] = []
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await adminAPI.users.delete(id)
|
||||
deletedIds.push(id)
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error)
|
||||
}
|
||||
}
|
||||
removeSelectedIds(deletedIds)
|
||||
if (deletedIds.length > 0) {
|
||||
appStore.showSuccess(t('admin.users.bulkDelete.success', { count: deletedIds.length }))
|
||||
pagination.page = 1
|
||||
}
|
||||
const failed = ids.length - deletedIds.length
|
||||
if (failed > 0) appStore.showError(t('admin.users.bulkDelete.failed', { count: failed }))
|
||||
await loadUsers()
|
||||
bulkDeleting.value = false
|
||||
}
|
||||
|
||||
const handleDeposit = (user: AdminUser) => {
|
||||
balanceUser.value = user
|
||||
balanceOperation.value = 'add'
|
||||
|
||||
@@ -720,6 +720,31 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
adminSettingsFetch.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("loads and saves the open button visibility for each custom menu", async () => {
|
||||
const menuItems = [
|
||||
{ id: "docs", label: "Docs", url: "https://example.com/docs", icon_svg: "", visibility: "user", sort_order: 0 },
|
||||
{ id: "help", label: "Help", url: "https://example.com/help", icon_svg: "", visibility: "user", sort_order: 1, hide_open_button: true },
|
||||
];
|
||||
getSettings.mockResolvedValue({ ...baseSettingsResponse, custom_menu_items: menuItems });
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
const toggles = wrapper.findAll<HTMLInputElement>('[data-testid="custom-menu-hide-open-button"]');
|
||||
expect(toggles.map(toggle => toggle.element.checked)).toEqual([false, true]);
|
||||
await toggles[0].setValue(true);
|
||||
await toggles[1].setValue(false);
|
||||
await wrapper.find("form").trigger("submit.prevent");
|
||||
await flushPromises();
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({
|
||||
custom_menu_items: [
|
||||
{ ...menuItems[0], hide_open_button: true },
|
||||
{ ...menuItems[1], hide_open_button: false },
|
||||
],
|
||||
}));
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("submits the compact home page toggle", async () => {
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
@@ -4,15 +4,19 @@ import { defineComponent } from 'vue'
|
||||
|
||||
import SubscriptionsView from '../SubscriptionsView.vue'
|
||||
|
||||
const { listSubscriptions, getAllGroups } = vi.hoisted(() => ({
|
||||
const { listSubscriptions, getAllGroups, listUsers, searchUsageUsers } = vi.hoisted(() => ({
|
||||
listSubscriptions: vi.fn(),
|
||||
getAllGroups: vi.fn()
|
||||
getAllGroups: vi.fn(),
|
||||
listUsers: vi.fn(),
|
||||
searchUsageUsers: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
subscriptions: { list: listSubscriptions },
|
||||
groups: { getAll: getAllGroups }
|
||||
groups: { getAll: getAllGroups },
|
||||
users: { list: listUsers },
|
||||
usage: { searchUsers: searchUsageUsers }
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -51,7 +55,7 @@ const RouterLinkStub = defineComponent({
|
||||
template: '<a :href="`${to.path}?user_id=${to.query.user_id}`"><slot /></a>'
|
||||
})
|
||||
|
||||
describe('admin subscription user usage link', () => {
|
||||
describe('admin subscription users', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
@@ -77,17 +81,28 @@ describe('admin subscription user usage link', () => {
|
||||
pages: 1
|
||||
})
|
||||
getAllGroups.mockResolvedValue([])
|
||||
listUsers.mockResolvedValue({
|
||||
items: [{ id: 42, email: 'reader@example.com' }],
|
||||
total: 1,
|
||||
pages: 1
|
||||
})
|
||||
searchUsageUsers.mockResolvedValue([
|
||||
{ id: 14, email: 'deleted@example.com', deleted: true }
|
||||
])
|
||||
})
|
||||
|
||||
const mountView = () => mount(SubscriptionsView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: { template: '<div><slot /></div>' },
|
||||
TablePageLayout: { template: '<div><slot name="table" /></div>' },
|
||||
TablePageLayout: { template: '<div><slot name="filters" /><slot name="table" /></div>' },
|
||||
DataTable: DataTableStub,
|
||||
RouterLink: RouterLinkStub,
|
||||
Pagination: true,
|
||||
BaseDialog: true,
|
||||
BaseDialog: {
|
||||
props: ['show'],
|
||||
template: '<div v-if="show"><slot /><slot name="footer" /></div>'
|
||||
},
|
||||
ConfirmDialog: true,
|
||||
EmptyState: true,
|
||||
Select: true,
|
||||
@@ -99,6 +114,60 @@ describe('admin subscription user usage link', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('searches current users when assigning a subscription', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const wrapper = mountView()
|
||||
try {
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button')
|
||||
.find((button) => button.text() === 'admin.subscriptions.assignSubscription')!
|
||||
.trigger('click')
|
||||
const search = wrapper.get('[data-assign-user-search] input')
|
||||
await search.trigger('focus')
|
||||
await search.setValue(' example.com ')
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
await flushPromises()
|
||||
|
||||
expect(listUsers).toHaveBeenCalledWith(1, 30, {
|
||||
search: 'example.com', sort_by: 'email', sort_order: 'asc'
|
||||
})
|
||||
expect(searchUsageUsers).not.toHaveBeenCalled()
|
||||
const picker = wrapper.get('[data-assign-user-search]')
|
||||
expect(picker.text()).toContain('reader@example.com')
|
||||
expect(picker.text()).not.toContain('deleted@example.com')
|
||||
await picker.get('button').trigger('click')
|
||||
expect((search.element as HTMLInputElement).value).toBe('reader@example.com')
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps deleted users available when filtering subscription history', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const wrapper = mountView()
|
||||
try {
|
||||
await flushPromises()
|
||||
const search = wrapper.get('[data-filter-user-search] input')
|
||||
await search.trigger('focus')
|
||||
await search.setValue('deleted')
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
await flushPromises()
|
||||
|
||||
expect(searchUsageUsers).toHaveBeenCalledWith('deleted')
|
||||
expect(listUsers).not.toHaveBeenCalled()
|
||||
const picker = wrapper.get('[data-filter-user-search]')
|
||||
expect(picker.text()).toContain('deleted@example.com')
|
||||
await picker.get('button').trigger('click')
|
||||
expect(listSubscriptions).toHaveBeenLastCalledWith(
|
||||
1, expect.any(Number), expect.objectContaining({ user_id: 14 }), expect.any(Object)
|
||||
)
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders the user email as a link to that user filtered usage records', async () => {
|
||||
const wrapper = mountView()
|
||||
|
||||
|
||||
@@ -6,12 +6,18 @@ import UsersView from '../UsersView.vue'
|
||||
|
||||
const {
|
||||
listUsers,
|
||||
deleteUser,
|
||||
showError,
|
||||
showSuccess,
|
||||
getAllGroups,
|
||||
getBatchUsersUsage,
|
||||
listEnabledDefinitions,
|
||||
getBatchUserAttributes
|
||||
} = vi.hoisted(() => ({
|
||||
listUsers: vi.fn(),
|
||||
deleteUser: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
getAllGroups: vi.fn(),
|
||||
getBatchUsersUsage: vi.fn(),
|
||||
listEnabledDefinitions: vi.fn(),
|
||||
@@ -23,7 +29,7 @@ vi.mock('@/api/admin', () => ({
|
||||
users: {
|
||||
list: listUsers,
|
||||
toggleStatus: vi.fn(),
|
||||
delete: vi.fn()
|
||||
delete: deleteUser
|
||||
},
|
||||
groups: {
|
||||
getAll: getAllGroups
|
||||
@@ -40,8 +46,8 @@ vi.mock('@/api/admin', () => ({
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn()
|
||||
showError,
|
||||
showSuccess
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -50,7 +56,8 @@ vi.mock('vue-i18n', async () => {
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
t: (key: string, params?: { count?: number }) =>
|
||||
params?.count === undefined ? key : `${key}:${params.count}`
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -119,12 +126,53 @@ const BulkEditUserModalStub = {
|
||||
`
|
||||
}
|
||||
|
||||
const mountBulkDeleteView = () => mount(UsersView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: { template: '<div><slot /></div>' },
|
||||
TablePageLayout: {
|
||||
template: '<div><slot name="filters" /><slot name="table" /><slot name="pagination" /></div>'
|
||||
},
|
||||
DataTable: DataTableStub,
|
||||
Pagination: PaginationStub,
|
||||
ConfirmDialog: {
|
||||
props: ['show', 'message'],
|
||||
emits: ['confirm', 'cancel'],
|
||||
template: `<div v-if="show" data-test="delete-dialog">
|
||||
<span>{{ message }}</span>
|
||||
<button data-test="confirm-delete" @click="$emit('confirm')">confirm</button>
|
||||
<button data-test="cancel-delete" @click="$emit('cancel')">cancel</button>
|
||||
</div>`
|
||||
},
|
||||
EmptyState: true,
|
||||
GroupBadge: true,
|
||||
Select: true,
|
||||
UserAttributesConfigModal: true,
|
||||
UserConcurrencyCell: true,
|
||||
UserCreateModal: true,
|
||||
UserEditModal: true,
|
||||
BulkEditUserModal: true,
|
||||
UserPlatformQuotaModal: true,
|
||||
UserApiKeysModal: true,
|
||||
UserAllowedGroupsModal: true,
|
||||
UserBalanceModal: true,
|
||||
UserBalanceHistoryModal: true,
|
||||
GroupReplaceModal: true,
|
||||
Icon: true,
|
||||
Teleport: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('admin UsersView', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
localStorage.clear()
|
||||
|
||||
listUsers.mockReset()
|
||||
deleteUser.mockReset()
|
||||
showError.mockReset()
|
||||
showSuccess.mockReset()
|
||||
getAllGroups.mockReset()
|
||||
getBatchUsersUsage.mockReset()
|
||||
listEnabledDefinitions.mockReset()
|
||||
@@ -147,6 +195,83 @@ describe('admin UsersView', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('cancels bulk deletion without deleting or clearing selected users', async () => {
|
||||
const wrapper = mountBulkDeleteView()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-test="bulk-delete-users"]').exists()).toBe(false)
|
||||
await wrapper.get('[data-test="select-42"]').trigger('click')
|
||||
await wrapper.get('[data-test="bulk-delete-users"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="delete-dialog"]').text()).toContain('admin.users.bulkDelete.confirm:1')
|
||||
expect(deleteUser).not.toHaveBeenCalled()
|
||||
|
||||
await wrapper.get('[data-test="cancel-delete"]').trigger('click')
|
||||
expect(wrapper.find('[data-test="delete-dialog"]').exists()).toBe(false)
|
||||
expect(deleteUser).not.toHaveBeenCalled()
|
||||
expect(wrapper.get('[data-test="selected-keys"]').text()).toBe('42')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ failedIds: [], remaining: '', deleted: 2 },
|
||||
{ failedIds: [43], remaining: '43', deleted: 1 },
|
||||
{ failedIds: [42, 43], remaining: '42,43', deleted: 0 }
|
||||
])('deletes across pages and retains failures: $remaining', async ({ failedIds, remaining, deleted }) => {
|
||||
listUsers.mockImplementation(async (page: number) => ({
|
||||
items: [createAdminUser({ id: page === 2 ? 43 : 42 })],
|
||||
total: 2, page, page_size: 20, pages: 2
|
||||
}))
|
||||
deleteUser.mockImplementation(async (id: number) => {
|
||||
if (failedIds.includes(id)) throw new Error('Cannot delete user')
|
||||
})
|
||||
const wrapper = mountBulkDeleteView()
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-test="select-42"]').trigger('click')
|
||||
await wrapper.get('[data-test="next-page"]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-test="select-43"]').trigger('click')
|
||||
await wrapper.get('[data-test="bulk-delete-users"]').trigger('click')
|
||||
expect(deleteUser).not.toHaveBeenCalled()
|
||||
|
||||
await wrapper.get('[data-test="confirm-delete"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(deleteUser.mock.calls).toEqual([[42], [43]])
|
||||
expect(wrapper.get('[data-test="selected-keys"]').text()).toBe(remaining)
|
||||
expect(wrapper.find('[data-test="delete-dialog"]').exists()).toBe(false)
|
||||
if (deleted) {
|
||||
expect(showSuccess).toHaveBeenCalledWith(`admin.users.bulkDelete.success:${deleted}`)
|
||||
expect(listUsers.mock.lastCall?.[0]).toBe(1)
|
||||
} else {
|
||||
expect(showSuccess).not.toHaveBeenCalled()
|
||||
}
|
||||
if (failedIds.length) expect(showError).toHaveBeenCalledWith(`admin.users.bulkDelete.failed:${failedIds.length}`)
|
||||
else expect(showError).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('deletes the confirmed selection while preserving users selected during deletion', async () => {
|
||||
listUsers.mockResolvedValue({
|
||||
items: [createAdminUser({ id: 42 }), createAdminUser({ id: 43 })],
|
||||
total: 2, page: 1, page_size: 20, pages: 1
|
||||
})
|
||||
let finishDelete!: () => void
|
||||
deleteUser.mockImplementation(() => new Promise<void>(resolve => { finishDelete = resolve }))
|
||||
const wrapper = mountBulkDeleteView()
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-test="select-42"]').trigger('click')
|
||||
await wrapper.get('[data-test="bulk-delete-users"]').trigger('click')
|
||||
await wrapper.get('[data-test="confirm-delete"]').trigger('click')
|
||||
expect(wrapper.get('[data-test="bulk-delete-users"]').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('[data-test="select-43"]').trigger('click')
|
||||
finishDelete()
|
||||
await flushPromises()
|
||||
|
||||
expect(deleteUser.mock.calls).toEqual([[42]])
|
||||
expect(wrapper.get('[data-test="selected-keys"]').text()).toBe('43')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows active, used, and created activity columns in order and requests last_used_at sort', async () => {
|
||||
const wrapper = mount(UsersView, {
|
||||
global: {
|
||||
|
||||
@@ -276,6 +276,7 @@ watch(
|
||||
|
||||
<OpsErrorLogTable
|
||||
class="min-h-0 flex-1"
|
||||
summary-first
|
||||
:rows="rows"
|
||||
:total="total"
|
||||
:loading="loading"
|
||||
|
||||
@@ -215,11 +215,18 @@ const allColumns = computed<Column[]>(() => [
|
||||
])
|
||||
|
||||
// 传入 visibleColumnKeys 时按其过滤(列设置);未传则全量(Ops 弹窗等使用方)
|
||||
const columns = computed<Column[]>(() =>
|
||||
props.visibleColumnKeys
|
||||
const columns = computed<Column[]>(() => {
|
||||
const visibleColumns = props.visibleColumnKeys
|
||||
? allColumns.value.filter((c) => props.visibleColumnKeys!.includes(c.key))
|
||||
: allColumns.value
|
||||
)
|
||||
if (!props.summaryFirst) return visibleColumns
|
||||
|
||||
return [
|
||||
...visibleColumns.filter((c) => c.key === 'created_at'),
|
||||
...visibleColumns.filter((c) => c.key === 'message'),
|
||||
...visibleColumns.filter((c) => c.key !== 'created_at' && c.key !== 'message'),
|
||||
]
|
||||
})
|
||||
|
||||
function isUpstreamRow(log: OpsErrorLog): boolean {
|
||||
const phase = String(log.phase || '').toLowerCase()
|
||||
@@ -288,6 +295,8 @@ interface Props {
|
||||
userClickable?: boolean
|
||||
/** 列设置:仅显示这些 key 的列;不传则全量 */
|
||||
visibleColumnKeys?: string[]
|
||||
/** 运维弹窗优先展示时间和响应内容 */
|
||||
summaryFirst?: boolean
|
||||
/** 嵌入统一卡片内使用:去掉自身卡片外观 */
|
||||
flat?: boolean
|
||||
}
|
||||
|
||||
@@ -75,6 +75,37 @@ describe('OpsErrorLogTable user/api-key/account columns', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('OpsErrorLogTable column order', () => {
|
||||
it('puts time and response content first for ops without changing time sorting', async () => {
|
||||
const wrapper = mountTable({})
|
||||
await wrapper.setProps({ summaryFirst: true })
|
||||
|
||||
const headers = wrapper.findAll('thead th')
|
||||
expect(headers.slice(0, 3).map((header) => header.text())).toEqual([
|
||||
'admin.ops.errorLog.time',
|
||||
'admin.ops.errorLog.message',
|
||||
'admin.ops.errorLog.user',
|
||||
])
|
||||
expect(wrapper.findAll('tbody td')[1].text()).toBe('boom')
|
||||
|
||||
await headers[0].trigger('click')
|
||||
expect(wrapper.emitted('sort')).toEqual([['created_at', 'asc']])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('preserves the usage column order and visibility by default', async () => {
|
||||
const wrapper = mountTable({})
|
||||
await wrapper.setProps({ visibleColumnKeys: ['created_at', 'user', 'message'] })
|
||||
|
||||
expect(wrapper.findAll('thead th').map((header) => header.text())).toEqual([
|
||||
'admin.ops.errorLog.user',
|
||||
'admin.ops.errorLog.message',
|
||||
'admin.ops.errorLog.time',
|
||||
])
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
// 防回归:组件用 admin.ops.errorLog.* 命名空间。若 i18n 键写错命名空间(如误放到
|
||||
// errorDetail),真实 vue-i18n 会回退返回 key 本身 → 界面显示原始路径字符串。
|
||||
// 这里用真实 locale 校验键确实可解析(返回译文而非 key)。
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
<!-- Iframe embed mode -->
|
||||
<div v-else ref="embedShell" class="custom-embed-shell">
|
||||
<a
|
||||
v-if="!menuItem?.hide_open_button"
|
||||
ref="openButton"
|
||||
:href="embeddedUrl"
|
||||
target="_blank"
|
||||
|
||||
@@ -79,6 +79,13 @@ describe('custom page open button', () => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it.each([undefined, false, true])('honors the per-menu hide button setting %s while keeping the iframe', (hidden) => {
|
||||
Object.assign(appStore.cachedPublicSettings.custom_menu_items[0], { hide_open_button: hidden })
|
||||
const wrapper = mountPage()
|
||||
expect(wrapper.find('.custom-open-fab').exists()).toBe(hidden !== true)
|
||||
expect(wrapper.get('iframe').attributes('src')).toContain('https://example.com/docs')
|
||||
})
|
||||
|
||||
it('preserves the embedded URL, secure link attributes, and normal clicks with small pointer movements', async () => {
|
||||
const { wrapper, button } = mountEmbed()
|
||||
expect(button.href).toBe(wrapper.get('iframe').attributes('src'))
|
||||
|
||||
Reference in New Issue
Block a user