mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 14:57:36 +08:00
fix(typesafe): 堵住校验解析差异与审计盲区,收紧模型列表口径
- 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。
This commit is contained in:
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -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`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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: 默认使用映射模式并填充默认映射
|
||||
|
||||
Reference in New Issue
Block a user