From 9eb394e3e98c9fc33f9c42191985e845876044bb Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 2 Oct 2026 13:51:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(typesafe):=20=E5=A0=B5=E4=BD=8F=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E8=A7=A3=E6=9E=90=E5=B7=AE=E5=BC=82=E4=B8=8E=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=E7=9B=B2=E5=8C=BA=EF=BC=8C=E6=94=B6=E7=B4=A7=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=88=97=E8=A1=A8=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - System One 校验拒绝重复键与大小写/Unicode 折叠变体键(请求顶层、 questions、问题对象):encoding/json 大小写不敏感且取最后一个重复键, 而原始 body 原样转发,此前 {"model":"x","MODEL":"jev-latest"} 可绕过 jev-latest 限制、模型白名单与流式限制。 - Prompt 审计与旧审核同时收集对象键名、问题 ID 与未知扩展字段(不含规范 字段名与已校验的 type),此前把内容放进键名即可完全绕过审计。 - 上游成功响应的 usage/model 宽松解码(浮点、数字字符串),避免上游已 计费而网关整体 502 不记账;解码失败记录 response_error ops 事件。 - 上游 413 与 400/422 同视为请求错误:透传状态码、不切换、不改账号状态。 - TypeSafe base_url 末尾的 /v1 自动剥离,避免拼出 /v1/v1/systemone; /v1/systemone 改挂文本请求体上限。 - Composite 静态兜底模型列表、管理端候选列表恢复为不含 jev-latest; Codex 清单永不列出 TypeSafe 模型;TypeSafe 分组候选默认 jev-latest。 - 创建账号从 Grok 切到 TypeSafe 时重置为白名单模式;错误透传平台列表补 typesafe。 --- README.md | 2 +- README_CN.md | 2 +- backend/internal/handler/gateway_handler.go | 17 ++++-- .../internal/handler/gateway_models_test.go | 44 +++++++++++++++ backend/internal/handler/gateway_systemone.go | 2 +- .../internal/model/error_passthrough_rule.go | 2 + backend/internal/pkg/typesafe/client.go | 52 +++++++++++++++-- backend/internal/pkg/typesafe/systemone.go | 48 ++++++++++++++++ .../internal/pkg/typesafe/systemone_test.go | 45 +++++++++++++++ .../internal/securityaudit/prompt_snapshot.go | 41 +++++++++----- .../securityaudit/prompt_snapshot_test.go | 33 +++++++---- backend/internal/server/routes/gateway.go | 3 +- backend/internal/service/account.go | 11 +++- backend/internal/service/admin_group.go | 8 ++- .../service/content_moderation_input.go | 56 ++++++++++++++----- .../content_moderation_systemone_test.go | 7 ++- backend/internal/service/gateway_systemone.go | 31 +++++++++- .../service/gateway_systemone_test.go | 36 +++++++++++- .../components/account/CreateAccountModal.vue | 2 + 19 files changed, 378 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index d602bf3950..8ec0b7f59c 100644 --- a/README.md +++ b/README.md @@ -848,7 +848,7 @@ curl https://your-sub2api.example.com/v1/systemone \ --data '{"model":"jev-latest","state":"Text to evaluate","questions":{"safety":{"type":"noul","instructions":"Evaluate whether the text is unsafe"}}}' ``` -The built-in `jev-latest` price is `$0.042` per million input tokens and `$0` for output tokens. Channel pricing can override both values. Credential, billing, permission, rate-limit, overload, server, and network failures (`401`, `402`, `403`, `429`, `529`, `5xx`, transport errors) use the existing account error policy (including custom error codes and temporary-unschedulable rules) and fail over to another account; request errors (`400` and `422`) are returned without retrying another account and never change account state. TypeSafe groups (and Composite requests routed to TypeSafe) reject Messages, Chat Completions, Responses, and count_tokens requests with `404`. +The built-in `jev-latest` price is `$0.042` per million input tokens and `$0` for output tokens. Channel pricing can override both values. Credential, billing, permission, rate-limit, overload, server, and network failures (`401`, `402`, `403`, `429`, `529`, `5xx`, transport errors) use the existing account error policy (including custom error codes and temporary-unschedulable rules) and fail over to another account; request errors (`400`, `413`, and `422`) are returned without retrying another account and never change account state. TypeSafe groups (and Composite requests routed to TypeSafe) reject Messages, Chat Completions, Responses, and count_tokens requests with `404`. --- diff --git a/README_CN.md b/README_CN.md index 8fe4bc5d83..70eef1990d 100644 --- a/README_CN.md +++ b/README_CN.md @@ -764,7 +764,7 @@ curl https://your-sub2api.example.com/v1/systemone \ --data '{"model":"jev-latest","state":"待评估文本","questions":{"safety":{"type":"noul","instructions":"评估文本是否不安全"}}}' ``` -`jev-latest` 内置价格为输入 `$0.042/百万 tokens`、输出 `$0`,渠道定价可以覆盖。凭据、欠费、权限、限流、过载、服务端和网络错误(`401`、`402`、`403`、`429`、`529`、`5xx`、传输错误)沿用现有账号错误策略(含自定义错误码与临时不可调度规则)并切换账号;请求错误(`400`、`422`)不会切换账号重试,也不会改变账号状态。TypeSafe 分组(以及路由到 TypeSafe 的 Composite 请求)调用 Messages、Chat Completions、Responses、count_tokens 时返回 `404`。 +`jev-latest` 内置价格为输入 `$0.042/百万 tokens`、输出 `$0`,渠道定价可以覆盖。凭据、欠费、权限、限流、过载、服务端和网络错误(`401`、`402`、`403`、`429`、`529`、`5xx`、传输错误)沿用现有账号错误策略(含自定义错误码与临时不可调度规则)并切换账号;请求错误(`400`、`413`、`422`)不会切换账号重试,也不会改变账号状态。TypeSafe 分组(以及路由到 TypeSafe 的 Composite 请求)调用 Messages、Chat Completions、Responses、count_tokens 时返回 `404`。 --- diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index afd77297f3..c12f281ff9 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1166,7 +1166,7 @@ func (h *GatewayHandler) Models(c *gin.Context) { } if platform == service.PlatformComposite { - availableModels := h.compositeAvailableModels(c.Request.Context(), groupID) + availableModels := h.compositeAvailableModels(c.Request.Context(), groupID, true) if apiKey != nil && apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() { source := availableModels if len(source) == 0 { @@ -1265,7 +1265,7 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi platform = group.Platform } if platform == service.PlatformComposite { - availableModels := h.compositeAvailableModels(ctx, groupID) + availableModels := h.compositeAvailableModels(ctx, groupID, false) fallbackModels := defaultCodexModelIDsForPlatform(service.PlatformComposite) if group.ModelAllowlistEnabled() { source := availableModels @@ -1291,7 +1291,10 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi return fallbackModels } -func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *int64) []string { +// compositeAvailableModels lists the models the composite group can serve. +// includeSystemOne adds TypeSafe models, which only work through /v1/systemone; +// LLM client catalogs (Codex) must exclude them. +func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *int64, includeSystemOne bool) []string { if h == nil || h.gatewayService == nil { return nil } @@ -1299,6 +1302,9 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID * models := make([]string, 0) schedulablePlatforms := h.gatewayService.GetSchedulablePlatforms(ctx, groupID) for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo, service.PlatformTypeSafe} { + if platform == service.PlatformTypeSafe && !includeSystemOne { + continue + } platformModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform) if len(platformModels) == 0 { // CN 供应商没有静态默认模型列表(defaultModelIDsForPlatform 的 @@ -1487,7 +1493,10 @@ func defaultModelIDsForPlatform(platform string) []string { case service.PlatformComposite: ids := make([]string, 0) seen := make(map[string]struct{}) - for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo, service.PlatformTypeSafe} { + // TypeSafe is deliberately absent: jev-latest only works through + // /v1/systemone, so the static fallback never advertises it to LLM + // clients. compositeAvailableModels lists it when the group can serve it. + for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo} { for _, id := range defaultModelIDsForPlatform(concretePlatform) { if _, ok := seen[id]; ok { continue diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index e0bec9431b..d2fdb5cfa0 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -1526,3 +1526,47 @@ func TestGatewayModels_GPT6SolLunaDiscoveryRespectsGroupAndAccountRestrictions(t }) } } + +// Scenario: jev-latest only works through /v1/systemone, so Composite groups list +// it in /v1/models only when they can serve it, and never in the Codex manifest. +func TestGatewayModels_CompositeTypeSafeListingScope(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := int64(66) + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: {{ID: 1, Platform: service.PlatformAnthropic}, {ID: 2, Platform: service.PlatformTypeSafe, Type: service.AccountTypeAPIKey}}, + }, + }) + newContext := func(path string) (*gin.Context, *httptest.ResponseRecorder) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, path, nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: service.PlatformComposite}, + }) + return c, rec + } + + c, rec := newContext("/v1/models") + h.Models(c) + require.Equal(t, http.StatusOK, rec.Code) + var models gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &models)) + require.Contains(t, modelIDsForTest(models.Data), "jev-latest") + require.Contains(t, modelIDsForTest(models.Data), "claude-opus-4-6") + + c, rec = newContext("/models?client_version=0.147.0") + h.CodexModels(c) + require.Equal(t, http.StatusOK, rec.Code) + var manifest codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &manifest)) + slugs := codexModelSlugsForTest(manifest.Models) + require.Contains(t, slugs, "claude-opus-4-6") + require.NotContains(t, slugs, "jev-latest") +} + +func TestDefaultModelIDsForPlatform_CompositeFallbackExcludesTypeSafe(t *testing.T) { + require.NotContains(t, defaultModelIDsForPlatform(service.PlatformComposite), "jev-latest") + require.NotContains(t, defaultCodexModelIDsForPlatform(service.PlatformComposite), "jev-latest") +} diff --git a/backend/internal/handler/gateway_systemone.go b/backend/internal/handler/gateway_systemone.go index 77ade84e95..f9cc753c79 100644 --- a/backend/internal/handler/gateway_systemone.go +++ b/backend/internal/handler/gateway_systemone.go @@ -228,7 +228,7 @@ func (h *GatewayHandler) SystemOne(c *gin.Context) { var upstreamErr *service.SystemOneUpstreamError if errors.As(forwardErr, &upstreamErr) { status := upstreamErr.StatusCode - if status != http.StatusBadRequest && status != http.StatusUnprocessableEntity { + if !service.IsSystemOneRequestErrorStatus(status) { status = http.StatusBadGateway } h.errorResponse(c, status, "upstream_error", "TypeSafe rejected the request") diff --git a/backend/internal/model/error_passthrough_rule.go b/backend/internal/model/error_passthrough_rule.go index a0c81934d7..da928125f2 100644 --- a/backend/internal/model/error_passthrough_rule.go +++ b/backend/internal/model/error_passthrough_rule.go @@ -46,6 +46,7 @@ const ( PlatformDeepseek = domain.PlatformDeepseek PlatformMiniMax = domain.PlatformMiniMax PlatformOpenCodeGo = domain.PlatformOpenCodeGo + PlatformTypeSafe = domain.PlatformTypeSafe ) // AllPlatforms 返回所有支持的平台列表 @@ -61,6 +62,7 @@ func AllPlatforms() []string { PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, + PlatformTypeSafe, } } diff --git a/backend/internal/pkg/typesafe/client.go b/backend/internal/pkg/typesafe/client.go index 7acbd9b4a4..0974204e2a 100644 --- a/backend/internal/pkg/typesafe/client.go +++ b/backend/internal/pkg/typesafe/client.go @@ -78,14 +78,54 @@ func DecodeSystemOneResponse(r io.Reader) (*SystemOneResponse, error) { if !json.Valid(body) { return nil, errors.New("typesafe invalid response") } - var envelope struct { - Model string `json:"model"` - Usage Usage `json:"usage"` - } - if err := json.Unmarshal(body, &envelope); err != nil { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(body, &envelope); err != nil || envelope == nil { return nil, errors.New("typesafe invalid response") } - return &SystemOneResponse{Body: body, Model: envelope.Model, Usage: envelope.Usage}, nil + // The upstream already answered (and charged); an unexpected model or usage + // shape must not discard the answer, so both are decoded leniently. + var model string + _ = json.Unmarshal(envelope["model"], &model) + var usage map[string]json.RawMessage + _ = json.Unmarshal(envelope["usage"], &usage) + return &SystemOneResponse{ + Body: body, + Model: model, + Usage: Usage{ + InputTokens: systemOneTokenCount(usage["input_tokens"]), + OutputTokens: systemOneTokenCount(usage["output_tokens"]), + }, + }, nil +} + +// maxSystemOneTokenCount bounds a reported token count before int conversion. +const maxSystemOneTokenCount = 1 << 40 + +// systemOneTokenCount accepts integer, float, or numeric-string token counts. +func systemOneTokenCount(raw json.RawMessage) int { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 { + return 0 + } + if raw[0] == '"' { + var text string + if json.Unmarshal(raw, &text) != nil { + return 0 + } + raw = json.RawMessage(strings.TrimSpace(text)) + } + var number json.Number + if json.Unmarshal(raw, &number) != nil { + return 0 + } + value, err := number.Float64() + if err != nil || math.IsNaN(value) || value <= 0 { + return 0 + } + if value > maxSystemOneTokenCount { + value = maxSystemOneTokenCount + } + return int(math.Round(value)) } // Evaluate performs one attempt. The caller owns timeouts, retries and key rotation. diff --git a/backend/internal/pkg/typesafe/systemone.go b/backend/internal/pkg/typesafe/systemone.go index 14093b2e24..4fab1f1d69 100644 --- a/backend/internal/pkg/typesafe/systemone.go +++ b/backend/internal/pkg/typesafe/systemone.go @@ -17,11 +17,22 @@ type systemOneEnvelope struct { Stream json.RawMessage `json:"stream"` } +var ( + systemOneRequestFields = []string{"model", "state", "questions", "stream"} + systemOneQuestionFields = []string{"type", "instructions", "criteria"} +) + func ValidateSystemOneRequest(body []byte) (string, error) { var envelope systemOneEnvelope if err := json.Unmarshal(body, &envelope); err != nil { return "", errors.New("invalid JSON request") } + // encoding/json matches struct fields case-insensitively and keeps the last + // duplicate, while the raw body is forwarded upstream unchanged. Reject any + // spelling the upstream could read differently from this validator. + if err := checkSystemOneObjectKeys(body, "request", systemOneRequestFields); err != nil { + return "", err + } model, err := requiredString(envelope.Model, "model") if err != nil { @@ -38,6 +49,9 @@ func ValidateSystemOneRequest(body []byte) (string, error) { if len(questionsRaw) == 0 || questionsRaw[0] != '{' || json.Unmarshal(questionsRaw, &questions) != nil || len(questions) == 0 { return "", errors.New("questions must be a non-empty object") } + if err := checkSystemOneObjectKeys(questionsRaw, "questions", nil); err != nil { + return "", err + } if len(envelope.Stream) > 0 && string(envelope.Stream) != "null" { var stream bool if err := json.Unmarshal(envelope.Stream, &stream); err != nil { @@ -65,6 +79,9 @@ func validateQuestion(id string, raw json.RawMessage) error { if len(raw) == 0 || raw[0] != '{' || json.Unmarshal(raw, &question) != nil { return fmt.Errorf("question %q must be an object", id) } + if err := checkSystemOneObjectKeys(raw, fmt.Sprintf("question %q", id), systemOneQuestionFields); err != nil { + return err + } typ, err := requiredString(question.Type, "question type") if err != nil { return fmt.Errorf("question %q: %w", id, err) @@ -152,3 +169,34 @@ func rawString(raw json.RawMessage) bool { var value string return json.Unmarshal(raw, &value) == nil } + +// checkSystemOneObjectKeys rejects duplicate keys and non-canonical spellings +// (any case variant) of the known fields in one JSON object level. +func checkSystemOneObjectKeys(raw []byte, scope string, canonical []string) error { + decoder := json.NewDecoder(bytes.NewReader(raw)) + if token, err := decoder.Token(); err != nil || token != json.Delim('{') { + return fmt.Errorf("%s must be an object", scope) + } + seen := make(map[string]struct{}) + for decoder.More() { + token, err := decoder.Token() + if err != nil { + return errors.New("invalid JSON request") + } + key, _ := token.(string) + if _, duplicate := seen[key]; duplicate { + return fmt.Errorf("%s contains duplicate field %q", scope, key) + } + seen[key] = struct{}{} + for _, field := range canonical { + if key != field && strings.EqualFold(key, field) { + return fmt.Errorf("%s field %q must be written as %q", scope, key, field) + } + } + var value json.RawMessage + if err := decoder.Decode(&value); err != nil { + return errors.New("invalid JSON request") + } + } + return nil +} diff --git a/backend/internal/pkg/typesafe/systemone_test.go b/backend/internal/pkg/typesafe/systemone_test.go index 3f6d3d9c00..65991cf143 100644 --- a/backend/internal/pkg/typesafe/systemone_test.go +++ b/backend/internal/pkg/typesafe/systemone_test.go @@ -2,6 +2,7 @@ package typesafe import ( "errors" + "strings" "testing" "github.com/stretchr/testify/require" @@ -66,6 +67,15 @@ func TestValidateSystemOneRequestRejectsInvalidRequests(t *testing.T) { {"numeric instructions", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","instructions":1}}}`, "instructions"}, {"boolean instructions", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"choice","instructions":true,"criteria":{}}}}`, "instructions"}, {"stream true", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","instructions":"x"}},"stream":true}`, "streaming"}, + {"case variant model smuggles upstream model", `{"model":"jev-pro","MODEL":"jev-latest","state":"x","questions":{"q":{"type":"noul"}}}`, `must be written as "model"`}, + {"case variant stream hides streaming", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul"}},"stream":true,"Stream":false}`, `must be written as "stream"`}, + {"unicode fold variant state", `{"model":"jev-latest","state":"x","ſtate":"y","questions":{"q":{"type":"noul"}}}`, `must be written as "state"`}, + {"duplicate model", `{"model":"jev-pro","model":"jev-latest","state":"x","questions":{"q":{"type":"noul"}}}`, `duplicate field "model"`}, + {"escaped duplicate model", `{"\u006dodel":"jev-pro","model":"jev-latest","state":"x","questions":{"q":{"type":"noul"}}}`, `duplicate field "model"`}, + {"duplicate state", `{"model":"jev-latest","state":"benign","state":"payload","questions":{"q":{"type":"noul"}}}`, `duplicate field "state"`}, + {"duplicate question id", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul"},"q":{"type":"noul"}}}`, `duplicate field "q"`}, + {"duplicate question instructions", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","instructions":"a","instructions":"b"}}}`, `duplicate field "instructions"`}, + {"case variant question type", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","Type":"choice"}}}`, `must be written as "type"`}, } { t.Run(tc.name, func(t *testing.T) { _, err := ValidateSystemOneRequest([]byte(tc.body)) @@ -77,3 +87,38 @@ func TestValidateSystemOneRequestRejectsInvalidRequests(t *testing.T) { }) } } + +func TestDecodeSystemOneResponseToleratesUsageShapes(t *testing.T) { + for _, tc := range []struct { + name string + body string + model string + input, output int + }{ + {"integers", `{"model":"jev-1","usage":{"input_tokens":12,"output_tokens":3}}`, "jev-1", 12, 3}, + {"floats", `{"model":"jev-1","usage":{"input_tokens":12.0,"output_tokens":2.6}}`, "jev-1", 12, 3}, + {"numeric strings", `{"usage":{"input_tokens":"15","output_tokens":" 4 "}}`, "", 15, 4}, + {"non numeric usage", `{"model":7,"usage":{"input_tokens":"many","output_tokens":null}}`, "", 0, 0}, + {"negative usage", `{"usage":{"input_tokens":-5}}`, "", 0, 0}, + {"usage not object", `{"model":"jev-1","usage":"none","answers":{}}`, "jev-1", 0, 0}, + {"missing usage", `{"answers":{}}`, "", 0, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + decoded, err := DecodeSystemOneResponse(strings.NewReader(tc.body)) + require.NoError(t, err) + require.Equal(t, []byte(tc.body), decoded.Body) + require.Equal(t, tc.model, decoded.Model) + require.Equal(t, tc.input, decoded.Usage.InputTokens) + require.Equal(t, tc.output, decoded.Usage.OutputTokens) + }) + } +} + +func TestDecodeSystemOneResponseRejectsNonObjects(t *testing.T) { + for _, body := range []string{`[]`, `"text"`, `null`, `{`, `data: {}`} { + _, err := DecodeSystemOneResponse(strings.NewReader(body)) + require.Error(t, err, body) + } + _, err := DecodeSystemOneResponse(strings.NewReader(`{"pad":"` + strings.Repeat("a", MaxSystemOneResponseBytes) + `"}`)) + require.ErrorIs(t, err, ErrSystemOneResponseTooLarge) +} diff --git a/backend/internal/securityaudit/prompt_snapshot.go b/backend/internal/securityaudit/prompt_snapshot.go index 12c1630b50..74a0d25974 100644 --- a/backend/internal/securityaudit/prompt_snapshot.go +++ b/backend/internal/securityaudit/prompt_snapshot.go @@ -123,31 +123,45 @@ func extractProtocolSegments(protocol string, document any) []promptSegment { } // extractSystemOneSegments collects every client-controlled text of a TypeSafe -// System One request. Question instructions and criteria come first and the -// evaluated state last, so the state is the prioritized segment. Object keys are -// visited in sorted order to keep the prompt hash stable across requests. +// System One request: question IDs, every question field except the validated +// type, unknown top-level extension fields, and the evaluated state. Object keys +// are text too (Jev reads the whole JSON), so they are collected with values. +// The state comes last so it is the prioritized segment, and keys are visited +// in sorted order to keep the prompt hash stable across requests. func extractSystemOneSegments(root map[string]any) []promptSegment { if root == nil { return nil } texts := make([]string, 0, 4) - questions, _ := root["questions"].(map[string]any) + questions, isObject := root["questions"].(map[string]any) + if !isObject { + texts = appendJSONStringLeaves(texts, root["questions"]) + } for _, id := range sortedJSONKeys(questions) { + texts = appendJSONStringLeaves(texts, id) question, ok := questions[id].(map[string]any) if !ok { + texts = appendJSONStringLeaves(texts, questions[id]) continue } - texts = appendJSONStringLeaves(texts, question["instructions"]) - criteria, isObject := question["criteria"].(map[string]any) - if !isObject || stringValue(question["type"]) != "choice" { - texts = appendJSONStringLeaves(texts, question["criteria"]) + for _, field := range sortedJSONKeys(question) { + switch field { + case "type": + continue + case "instructions", "criteria": + default: + texts = appendJSONStringLeaves(texts, field) + } + texts = appendJSONStringLeaves(texts, question[field]) + } + } + for _, field := range sortedJSONKeys(root) { + switch field { + case "model", "stream", "state", "questions": continue } - // Choice option labels are client-chosen text sent to the model as well. - for _, label := range sortedJSONKeys(criteria) { - texts = appendJSONStringLeaves(texts, label) - texts = appendJSONStringLeaves(texts, criteria[label]) - } + texts = appendJSONStringLeaves(texts, field) + texts = appendJSONStringLeaves(texts, root[field]) } texts = appendJSONStringLeaves(texts, root["state"]) return userPromptSegments(texts) @@ -165,6 +179,7 @@ func appendJSONStringLeaves(texts []string, value any) []string { } case map[string]any: for _, key := range sortedJSONKeys(typed) { + texts = appendJSONStringLeaves(texts, key) texts = appendJSONStringLeaves(texts, typed[key]) } } diff --git a/backend/internal/securityaudit/prompt_snapshot_test.go b/backend/internal/securityaudit/prompt_snapshot_test.go index 317cd98df7..751043f550 100644 --- a/backend/internal/securityaudit/prompt_snapshot_test.go +++ b/backend/internal/securityaudit/prompt_snapshot_test.go @@ -414,19 +414,22 @@ func metadataTextForTest(scanText string) string { } func TestPromptSnapshotTypeSafeSystemOneCollectsStateAndQuestions(t *testing.T) { - body := `{"model":"jev-latest","state":{"title":"STATE_TITLE","items":["STATE_ITEM",{"text":"STATE_NESTED"},3]},` + + body := `{"model":"jev-latest","state":{"STATE_KEY":"STATE_TITLE","items":["STATE_ITEM",{"text":"STATE_NESTED"},3]},` + `"questions":{"b":{"type":"choice","instructions":"CHOICE_INSTRUCTIONS","criteria":{"OPTION_LABEL":{"description":"OPTION_DESC"},"empty":null}},` + - `"a":{"type":"noul","instructions":["NOUL_INSTRUCTIONS"],"criteria":{"true":"NOUL_TRUE","false":"NOUL_FALSE"}},` + - `"c":{"type":"score","criteria":["SCORE_LOW",{"description":"SCORE_HIGH"}]}}}` + `"a":{"type":"noul","instructions":["NOUL_INSTRUCTIONS"],"criteria":{"true":"NOUL_TRUE","false":"NOUL_FALSE"},"EXTENSION_KEY":"EXTENSION_VALUE"},` + + `"QUESTION_ID":{"type":"score","criteria":["SCORE_LOW",{"description":"SCORE_HIGH"}]}},"TOP_EXTENSION":{"x":"TOP_VALUE"}}` snapshot, err := ExtractPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(body)}) require.NoError(t, err) - for _, text := range []string{"STATE_TITLE", "STATE_ITEM", "STATE_NESTED", "CHOICE_INSTRUCTIONS", "OPTION_LABEL", "OPTION_DESC", - "NOUL_INSTRUCTIONS", "NOUL_TRUE", "NOUL_FALSE", "SCORE_LOW", "SCORE_HIGH"} { + for _, text := range []string{"STATE_KEY", "STATE_TITLE", "STATE_ITEM", "STATE_NESTED", "CHOICE_INSTRUCTIONS", "OPTION_LABEL", "OPTION_DESC", + "NOUL_INSTRUCTIONS", "NOUL_TRUE", "NOUL_FALSE", "EXTENSION_KEY", "EXTENSION_VALUE", "QUESTION_ID", "SCORE_LOW", "SCORE_HIGH", + "TOP_EXTENSION", "TOP_VALUE"} { require.Contains(t, snapshot.ScanText, text) } - require.NotContains(t, snapshot.ScanText, "jev-latest") - require.Equal(t, 12, snapshot.MessageCount) + // Canonical field names and validated enum values are not client text. + for _, text := range []string{"jev-latest", "instructions", "criteria", "questions", "choice", "score"} { + require.NotContains(t, snapshot.ScanText, text) + } // Map iteration order must not change the audited text or its hash. for range 20 { @@ -440,14 +443,22 @@ func TestPromptSnapshotTypeSafeSystemOneCollectsStateAndQuestions(t *testing.T) require.NoError(t, err) require.Contains(t, blocking.ScanText, "STATE_TITLE") require.Contains(t, blocking.ScanText, "CHOICE_INSTRUCTIONS") + require.Contains(t, blocking.ScanText, "QUESTION_ID") } -func TestPromptSnapshotTypeSafeSystemOneStringStateIsAudited(t *testing.T) { +func TestPromptSnapshotTypeSafeSystemOneStringStateIsPrioritized(t *testing.T) { snapshot, err := ExtractPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(`{"model":"jev-latest","state":"plain state","questions":{"q":{"type":"noul"}}}`)}) require.NoError(t, err) require.True(t, strings.HasPrefix(snapshot.ScanText, "plain state")) - require.Equal(t, 1, snapshot.MessageCount) + require.Equal(t, 2, snapshot.MessageCount) +} - _, err = ExtractPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(`{"model":"jev-latest","state":{"n":1},"questions":{"q":{"type":"noul"}}}`)}) - require.ErrorIs(t, err, ErrNoPromptText) +func TestPromptSnapshotTypeSafeSystemOneAuditsKeyOnlyPayloads(t *testing.T) { + body := `{"model":"jev-latest","state":{"HIDDEN_STATE_KEY":1},"questions":{"HIDDEN_QUESTION_ID":{"type":"noul"}}}` + for _, latestTurnOnly := range []bool{false, true} { + snapshot, err := ExtractBlockingPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(body)}, latestTurnOnly) + require.NoError(t, err) + require.Contains(t, snapshot.ScanText, "HIDDEN_STATE_KEY") + require.Contains(t, snapshot.ScanText, "HIDDEN_QUESTION_ID") + } } diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index 18346eb219..870ae14c5c 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -202,7 +202,8 @@ func RegisterGatewayRoutes( } h.Gateway.Messages(c) }) - gateway.POST("/systemone", h.Gateway.SystemOne) + // System One carries only JSON text, so it uses the text body limit. + gateway.POST("/systemone", textBodyLimit, h.Gateway.SystemOne) // /v1/messages/count_tokens: OpenAI bridges upstream, Grok estimates // locally, and Anthropic-compatible platforms retain their existing path. gateway.POST("/messages/count_tokens", countTokensHandler) diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index ca8246677e..5262134a2b 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1014,10 +1014,15 @@ func (a *Account) GetTypeSafeBaseURL() string { if a == nil || !a.IsTypeSafe() || a.Type != AccountTypeAPIKey { return "" } - if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" { - return strings.TrimRight(baseURL, "/") + baseURL := strings.TrimRight(strings.TrimSpace(a.GetCredential("base_url")), "/") + // The System One path already carries /v1; accept a base URL pasted with it. + if len(baseURL) >= 3 && strings.EqualFold(baseURL[len(baseURL)-3:], "/v1") { + baseURL = strings.TrimRight(baseURL[:len(baseURL)-3], "/") } - return typesafe.DefaultBaseURL + if baseURL == "" { + return typesafe.DefaultBaseURL + } + return baseURL } func (a *Account) GetTypeSafeAPIKey() string { diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index 2dcdfb84a5..103c9edc25 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -17,6 +17,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/pkg/typesafe" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" ) @@ -296,6 +297,8 @@ func defaultModelsListCandidateIDs(platform string) []string { return xai.DefaultModelIDs() case PlatformOpenCodeGo: return DefaultOpenCodeGoModelIDs() + case PlatformTypeSafe: + return []string{typesafe.JevLatestModel} case PlatformComposite: return compositeDefaultModelsListCandidateIDs() default: @@ -316,7 +319,10 @@ func defaultAllowImageGenerationForPlatform(platform string) bool { func compositeDefaultModelsListCandidateIDs() []string { seen := make(map[string]struct{}) ids := make([]string, 0) - for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe} { + // TypeSafe stays out of the static composite candidates (jev-latest only works + // through /v1/systemone); groups with TypeSafe accounts still get it from the + // account model mappings collected by GetGroupModelsListCandidates. + for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo} { for _, id := range defaultModelsListCandidateIDs(platform) { if _, ok := seen[id]; ok { continue diff --git a/backend/internal/service/content_moderation_input.go b/backend/internal/service/content_moderation_input.go index 6ef090a100..b52adfaa21 100644 --- a/backend/internal/service/content_moderation_input.go +++ b/backend/internal/service/content_moderation_input.go @@ -65,25 +65,45 @@ func extractContentModerationInput(protocol string, body []byte, filterReminders return out } -// collectSystemOneInput moderates the evaluated state plus every question's -// instructions and criteria: all of them are client text sent to Jev. +// collectSystemOneInput moderates every client-controlled text of a System One +// request: question IDs, every question field except the validated type, +// unknown top-level extension fields, and the evaluated state. Object keys are +// sent to Jev as part of the JSON, so they are moderated like values. func (collector moderationTextCollector) collectSystemOneInput(body []byte, parts *[]string) { - collector.collectSystemOneText(gjson.GetBytes(body, "state"), parts) - gjson.GetBytes(body, "questions").ForEach(func(_, question gjson.Result) bool { - collector.collectSystemOneText(question.Get("instructions"), parts) - criteria := question.Get("criteria") - if question.Get("type").String() == "choice" && criteria.IsObject() { - // Choice option labels are client-chosen text as well. - criteria.ForEach(func(label, description gjson.Result) bool { - collector.addModerationText(parts, label.String()) - collector.collectSystemOneText(description, parts) - return true - }) + root := gjson.ParseBytes(body) + questions := root.Get("questions") + if !questions.IsObject() { + collector.collectSystemOneText(questions, parts) + } + questions.ForEach(func(id, question gjson.Result) bool { + collector.addModerationText(parts, id.String()) + if !question.IsObject() { + collector.collectSystemOneText(question, parts) return true } - collector.collectSystemOneText(criteria, parts) + question.ForEach(func(field, value gjson.Result) bool { + switch field.String() { + case "type": + return true + case "instructions", "criteria": + default: + collector.addModerationText(parts, field.String()) + } + collector.collectSystemOneText(value, parts) + return true + }) return true }) + root.ForEach(func(field, value gjson.Result) bool { + switch field.String() { + case "model", "stream", "state", "questions": + return true + } + collector.addModerationText(parts, field.String()) + collector.collectSystemOneText(value, parts) + return true + }) + collector.collectSystemOneText(root.Get("state"), parts) } func (collector moderationTextCollector) collectSystemOneText(value gjson.Result, parts *[]string) { @@ -92,11 +112,17 @@ func (collector moderationTextCollector) collectSystemOneText(value gjson.Result return case value.Type == gjson.String: collector.addModerationText(parts, value.String()) - case value.IsArray(), value.IsObject(): + case value.IsArray(): value.ForEach(func(_, child gjson.Result) bool { collector.collectSystemOneText(child, parts) return true }) + case value.IsObject(): + value.ForEach(func(key, child gjson.Result) bool { + collector.addModerationText(parts, key.String()) + collector.collectSystemOneText(child, parts) + return true + }) } } diff --git a/backend/internal/service/content_moderation_systemone_test.go b/backend/internal/service/content_moderation_systemone_test.go index c9a6ae9505..8f2eba816f 100644 --- a/backend/internal/service/content_moderation_systemone_test.go +++ b/backend/internal/service/content_moderation_systemone_test.go @@ -13,9 +13,10 @@ func TestExtractContentModerationInputTypeSafeSystemOne(t *testing.T) { want string }{ {"string", `{"state":"plain text"}`, "plain text"}, - {"object", `{"state":{"title":"hello","nested":{"body":"world"}}}`, "hello world"}, - {"array", `{"state":["first",{"text":"second"},3]}`, "first second"}, - {"questions", `{"state":"state text","questions":{"c":{"type":"choice","instructions":"pick one","criteria":{"label a":{"description":"desc a"},"label b":null}},"s":{"type":"score","criteria":["low",{"text":"high"}]},"n":{"type":"noul","instructions":["judge"],"criteria":{"true":"yes"}}}}`, "state text pick one label a desc a label b low high judge yes"}, + {"object", `{"state":{"title":"hello","nested":{"body":"world"}}}`, "title hello nested body world"}, + {"array", `{"state":["first",{"text":"second"},3]}`, "first text second"}, + {"questions", `{"state":"state text","questions":{"c":{"type":"choice","instructions":"pick one","criteria":{"label a":{"description":"desc a"},"label b":null}},"s":{"type":"score","criteria":["low",{"text":"high"}]},"n":{"type":"noul","instructions":["judge"],"criteria":{"true":"yes"},"ext":"extra"}},"top":{"k":"v"}}`, "c pick one label a description desc a label b s low text high n judge true yes ext extra top k v state text"}, + {"key only payload", `{"state":{"hidden state key":1},"questions":{"hidden question id":{"type":"noul"}}}`, "hidden question id hidden state key"}, } { t.Run(tc.name, func(t *testing.T) { input := ExtractContentModerationInput(ContentModerationProtocolTypeSafeSystemOne, []byte(tc.body)) diff --git a/backend/internal/service/gateway_systemone.go b/backend/internal/service/gateway_systemone.go index 261cb74b9e..0add1999df 100644 --- a/backend/internal/service/gateway_systemone.go +++ b/backend/internal/service/gateway_systemone.go @@ -67,6 +67,22 @@ func (s *GatewayService) ForwardSystemOne(ctx context.Context, c *gin.Context, a decoded, err := typesafe.DecodeSystemOneResponse(resp.Body) if err != nil { + // The upstream accepted (and may have charged) this request but the + // gateway cannot relay it; keep an ops trail for reconciliation. + setOpsUpstreamError(c, resp.StatusCode, err.Error(), "") + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Passthrough: true, + ProxyID: opsUpstreamProxyID(account), + ProxyName: opsUpstreamProxyName(account), + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + UpstreamURL: upstreamURL, + Kind: "response_error", + Message: err.Error(), + }) return nil, err } return &SystemOneForwardResult{ @@ -84,8 +100,19 @@ func (s *GatewayService) ForwardSystemOne(ctx context.Context, c *gin.Context, a }, nil } +// IsSystemOneRequestErrorStatus reports upstream statuses that describe the +// caller's own payload (malformed, unprocessable, or too large). +func IsSystemOneRequestErrorStatus(status int) bool { + switch status { + case http.StatusBadRequest, http.StatusRequestEntityTooLarge, http.StatusUnprocessableEntity: + return true + default: + return false + } +} + // handleSystemOneErrorResponse applies the shared account error policy to a -// non-2xx System One response. 400/422 describe the caller's own payload, so +// non-2xx System One response. 400/413/422 describe the caller's own payload, so // they never touch account state (a tenant must not be able to disable an // account with bad input) and are not retried elsewhere. Every other status // goes through the account error policy (custom error codes, temporary @@ -109,7 +136,7 @@ func (s *GatewayService) handleSystemOneErrorResponse(ctx context.Context, c *gi Message: upstreamMsg, } - if resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusUnprocessableEntity { + if IsSystemOneRequestErrorStatus(resp.StatusCode) { appendOpsUpstreamError(c, event) return &SystemOneUpstreamError{StatusCode: resp.StatusCode} } diff --git a/backend/internal/service/gateway_systemone_test.go b/backend/internal/service/gateway_systemone_test.go index 62b3051ee4..d3cb131414 100644 --- a/backend/internal/service/gateway_systemone_test.go +++ b/backend/internal/service/gateway_systemone_test.go @@ -248,7 +248,7 @@ func systemOneOpsEvents(t *testing.T, c *gin.Context) []*OpsUpstreamErrorEvent { } func TestForwardSystemOneRequestErrorsNeverTouchAccountState(t *testing.T) { - for _, status := range []int{http.StatusBadRequest, http.StatusUnprocessableEntity} { + for _, status := range []int{http.StatusBadRequest, http.StatusRequestEntityTooLarge, http.StatusUnprocessableEntity} { t.Run(http.StatusText(status), func(t *testing.T) { // Even a custom error-code rule must not let client input disable the account. account := &Account{ID: 21, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{ @@ -357,14 +357,46 @@ func TestForwardSystemOneRejectsOversizedResponse(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(oversized))}, nil }} account := &Account{ID: 26, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}} - _, err := newSystemOneTestService(upstream).ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, []byte(`{}`)) + c := newSystemOneTestContext() + _, err := newSystemOneTestService(upstream).ForwardSystemOne(context.Background(), c, account, []byte(`{}`)) require.ErrorIs(t, err, typesafe.ErrSystemOneResponseTooLarge) + events := systemOneOpsEvents(t, c) + require.Len(t, events, 1) + require.Equal(t, "response_error", events[0].Kind) + require.Equal(t, http.StatusOK, events[0].UpstreamStatusCode) +} + +func TestForwardSystemOneBillsLenientUsageShapes(t *testing.T) { + upstream := &systemOneHTTPUpstream{do: func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"answers":{},"usage":{"input_tokens":"21","output_tokens":1.0}}`))}, nil + }} + account := &Account{ID: 27, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}} + result, err := newSystemOneTestService(upstream).ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, []byte(`{}`)) + require.NoError(t, err) + require.Equal(t, 21, result.Usage.InputTokens) + require.Equal(t, 1, result.Usage.OutputTokens) } func TestTypeSafeAccountBaseURLNeverFallsBackToAnthropic(t *testing.T) { account := &Account{Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "ts-secret"}} require.Equal(t, typesafe.DefaultBaseURL, account.GetBaseURL()) require.Equal(t, typesafe.DefaultBaseURL, account.GetTypeSafeBaseURL()) + for raw, want := range map[string]string{ + "https://api.typesafe.ai/v1": "https://api.typesafe.ai", + "https://api.typesafe.ai/V1/": "https://api.typesafe.ai", + "https://proxy.example/typesafe/": "https://proxy.example/typesafe", + "https://proxy.example/apiv1": "https://proxy.example/apiv1", + " https://proxy.example/x/v1/ ": "https://proxy.example/x", + "/v1": typesafe.DefaultBaseURL, + } { + withBase := &Account{Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": raw}} + require.Equal(t, want, withBase.GetTypeSafeBaseURL(), raw) + } anthropic := &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "sk"}} require.Equal(t, "https://api.anthropic.com", anthropic.GetBaseURL()) } + +func TestTypeSafeModelsListCandidates(t *testing.T) { + require.Equal(t, []string{typesafe.JevLatestModel}, defaultModelsListCandidateIDs(PlatformTypeSafe)) + require.NotContains(t, compositeDefaultModelsListCandidateIDs(), typesafe.JevLatestModel) +} diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 7c2ee35965..64cea06900 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -4881,6 +4881,8 @@ watch( modelMappings.value = [] if (newPlatform === 'typesafe') { accountCategory.value = 'apikey' + // Grok 等平台会把模式切到映射;TypeSafe 只用白名单写入 jev-latest。 + modelRestrictionMode.value = 'whitelist' allowedModels.value = ['jev-latest'] } // Antigravity: 默认使用映射模式并填充默认映射