* refactor(settings): 按业务语义重排配置分区,收敛默认态展示密度 - 路由策略并入亲和分区(路由与调度),validation_interval 移出超时分组并入重试/凭据健康, models_dev_auto_sync_enabled 从请求转发移至数据与维护,header_rules 与 inject_usage_options 合并为上游请求改写;6 个分区拆为 7 个语义更清晰的分区。 - 新增 SettingRow 承载默认态单行展示、覆盖态才展开控件与说明文字,默认态徽标与操作按钮收敛, 避免逐字段重复常驻的说明文案。 * refactor(settings): 抽取 SettingRow 为共享组件并同步分组设置页展示密度 - SettingRow 从设置页移至 components/config,替换分组设置页运行时区块里的 RuntimeOverrideRow,行为对齐:默认态收敛徽标/按钮、覆盖态才展开控件与说明。 - RuntimeOverrideRow 无剩余引用,一并删除。 - 顺带修正分组页 inject_usage_options 折叠态展示 help 文案而非当前值的问题, 改为展示已启用/已禁用,说明文字移入 help 提示。 - 请求亲和沿用原有 SegmentedControl 三态控件,未纳入本次迁移。 * fix(settings): 按反馈调整 SettingRow 交互细节 - 提示统一用已有 AppTooltip 组件,替换原生 title;且不论是否覆盖,说明都只在 标签旁以 tooltip 展示,不再于覆盖态额外常驻一份。 - 覆盖/恢复默认按钮从行尾移到值后面,改回可见的按钮样式,不再靠悬停淡入。 - 覆盖态不再叠加“显式覆盖”徽标,仅保留左侧强调色与底色区分;待恢复/环境变量 锁定两种状态的徽标保留。 - 路由策略改用 SegmentedControl 二选一切换,替换下拉选择,和代理等控件观感一致。 - 各分区行列表之间补上小间距,避免行与行贴在一起。 * fix(settings): 覆盖/恢复默认按钮改为图标 + tooltip 文字按钮换成小方形图标按钮:覆盖用 PencilLine,恢复默认用 RotateCcw,配色沿用 原有 action/warning 两种 tone;按钮不再显示文字,改为 hover/聚焦时通过 AppTooltip 展示对应文案,无障碍上保留 aria-label。 * fix(settings): 覆盖/恢复默认图标去掉按钮底色,仅悬停反馈 去掉常驻边框和背景色,图标默认裸露显示,与旁边的说明提示图标风格保持一致; 悬停/聚焦时才出现对应 tone 的浅色背景。 * fix(settings): 覆盖图标恢复悬停显示 未覆盖态(含待恢复默认)的“覆盖”图标恢复为鼠标悬停/键盘聚焦到该行才显示, 平时隐藏;已覆盖态的“恢复默认”图标保持常驻可见,因为该行本身已有强调色和 底色区分,不需要靠图标常驻来提示。窄屏和纯触屏环境下始终显示,避免无法触发。 * refactor(settings): 移除 inject_usage_options 并统一设置页控件尺寸 移除 inject_usage_options 配置,改为始终注入: - 该开关只在「OpenAI Completions + 原生透传 + 流式」这一条路径生效(转换路径的 usage 来自 SDK,Anthropic/Gemini 原生流自带 usage),默认即为开启,暴露为用户 配置的收益远低于理解成本。 - 后端删除设置键、分组覆盖与渠道约束校验,IncludeUsage 固定为 ObserveUsage; 新增迁移 0008 清理 system_settings 行与分组 overrides 中的历史键,否则解析器 会以 "unknown runtime setting" 拒绝整份配置。 - 前端删除两处设置行、资源投影字段与三语文案。 设置页与分组设置页的控件: - 新增 --setting-control-height,并在 .setting-row/.setting-block 上覆盖 --control-compact/xs/sm,让输入框、切换控件、下拉统一小一档;变量继承进子组件, 不需要和组件自身 scoped 样式比优先级。 - 固定行内容高度,折叠态与覆盖态不再有高度差,点开编辑时行不会跳动。 - 块级说明(CORS 策略、下游响应头规则)从常驻小字改为标签旁 tooltip,与设置行一致。 分组设置页删除只读的渠道展示:顶部已有渠道信息,此处重复且不可编辑。 * fix(storage): 修复迁移 0008 在 PostgreSQL 上的两处方言问题 - 反引号是 MySQL/SQLite 语法,PostgreSQL 解析 `key` = ? 直接报 syntax error, 导致启动中断。改用 GORM 结构体条件,由驱动按方言自行加引号。 - 往 json 列写 []byte 时 PostgreSQL 驱动按 bytea 处理,报 invalid input syntax for type json。改为写字符串。 已在 SQLite / PostgreSQL 16 / MySQL 8 三种方言上实跑升级路径:注入历史的 system_settings 行与分组 overrides 后启动,确认键被清除、分组只剩其余覆盖、 迁移正常记账。 * refactor(settings): 合并上游 Header 到跨域分区,统一控件尺寸与请求头/响应头命名 - 分组页控件尺寸未跟着变小,是因为上一轮的尺寸变量覆盖只挂在 .setting-row/ .setting-block 上;分组页的姓名/验活模型输入框、权重与请求亲和的 SegmentedControl 都不在这两个类名内。改为挂在 .settings__content/.group-settings__content 内容区 (不含侧边导航与保存栏),覆盖 --control-compact/xs/sm/md 四个尺寸变量,让设置页 和分组页的所有交互控件统一收敛到 26px。 - "上游请求改写"分区被删除,header_rules 合并进浏览器访问分区,与 CORS、 下游响应头规则放在一起(顺序:CORS → 上游请求头规则 → 下游响应头规则); 该分区原本只剩 header_rules 一项(inject_usage_options 上一轮已删除), 单独占一个侧边菜单已经没有意义。 - 请求头/响应头命名统一:全局设置页"全局 Header Rules"改名"上游请求头规则", 与已有的"下游响应头规则"对称;分组页"HeaderRules"同步改名"上游请求头规则"。 新分区标题定为"Header 与跨域"。 - 清理合并后失效的 settings.headers.* 键(title/currentPublishedRuleCount/ defaultSource/overrideSource/pendingRestoreSource/override/restoreDefault/ inherited/resetPending/replacementWarning/securityNotice),改用已有的 settings.runtime.* 通用状态词汇,与 CORS、下游响应头规则保持一致。 已用真实浏览器验证:分区合并后的顺序与文案、校验横幅跳转到正确分区并定位到 非法输入框、分组页控件高度与设置页一致。 * refactor(group-settings): 统一请求亲和与上游请求头规则的覆盖交互 请求亲和由三态 SegmentedControl 改为 SettingRow 覆盖/恢复默认模式; 上游请求头规则由 details 折叠改为 SettingBlock,并补上独立的覆盖开关, 与设置页的交互模式完全一致;同步移除废弃的展开态路由状态与文案。 * fix(group-settings): 恢复默认时不再回显未保存的旧覆盖值 分组已保存覆盖值后,草稿中点击恢复默认会读到仍是旧覆盖值的 effective 快照,导致视觉上"看起来没变"。为出站代理、超时/次数类、请求亲和、 上游请求头规则补充待恢复态:展示占位文案与徽标,保存后才回填真实的 全局生效值,与设置页面的重置反馈保持一致。 * feat(group-settings): 验活模型改为可搜索候选输入 复用参数覆盖规则里模型输入的 datalist 模式,把分组当前模型作为候选, 输入即过滤;候选取上游模型 ID(验活按该值请求上游),别名作为副标题 展示。后端不限制该值,仍保留自由输入。 * fix(settings): 按 review 处理迁移账本断言与三处设置项细节 - 外部数据库契约里的迁移账本断言硬编码到 0007,只在 PostgreSQL/MySQL 下 执行,本地 SQLite 会跳过,导致 0008 落库后仅 CI 报错 - 下游响应头规则的待恢复态未取草稿值,取消覆盖后仍回显旧规则,与同区的 上游请求头规则表现不一致 - 连接与可靠性分区的数字输入清空后写入 0,其中重试次数与拉黑阈值的 0 合法, 会被静默保存;改为与路由分区一致按 NaN 处理 - 修正 pendingRestoreSource 的英文文案
555 lines
20 KiB
Go
555 lines
20 KiB
Go
package control
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
|
|
"gpt-load/internal/catalog"
|
|
"gpt-load/internal/channel"
|
|
"gpt-load/internal/execution"
|
|
app_errors "gpt-load/internal/platform/errors"
|
|
"gpt-load/internal/pricing"
|
|
"gpt-load/internal/protocol"
|
|
"gpt-load/internal/state"
|
|
"gpt-load/internal/storage/models"
|
|
)
|
|
|
|
func TestGetGroupModelsReturnsClientNamesAndPricingStatus(t *testing.T) {
|
|
t.Parallel()
|
|
fixture := newServiceFixture(t)
|
|
fixture.catalogRuntime.Publish(&catalog.Snapshot{Providers: map[string]catalog.Provider{
|
|
"openai": {
|
|
ID: "openai",
|
|
Models: map[string]catalog.Model{
|
|
"gpt-4o": {
|
|
ID: "gpt-4o",
|
|
Cost: &catalog.ModelCost{Prices: pricing.Prices{
|
|
Input: pricing.Price{Set: true, NanoUSDPerMillion: 1},
|
|
}},
|
|
},
|
|
},
|
|
},
|
|
}})
|
|
created, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{
|
|
ChannelID: channel.OpenAI,
|
|
Params: json.RawMessage(`{}`),
|
|
Models: optionalGroupModels{Set: true, Values: []GroupModel{
|
|
{ID: "gpt-4o", Alias: "default", AliasEnabled: true},
|
|
{ID: "missing-price", Alias: ""},
|
|
}},
|
|
Credentials: "sk-model-read", ConnectionType: "api_key",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := fixture.service.GetGroupModels(t.Context(), created.GroupID)
|
|
if err != nil {
|
|
t.Fatalf("GetGroupModels() error = %v", err)
|
|
}
|
|
want := GroupModelsResponse{
|
|
Items: []GroupModelResponse{
|
|
{ID: "gpt-4o", Alias: "default", AliasEnabled: true, ClientModel: "default", PricingStatus: PricingStatusConfigured},
|
|
{ID: "missing-price", Alias: "", AliasEnabled: false, ClientModel: "missing-price", PricingStatus: PricingStatusPending},
|
|
},
|
|
Total: 2,
|
|
Pending: 1,
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("GetGroupModels() = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestMapGroupModelsResponseTreatsContextTierOnlyPriceAsConfigured(t *testing.T) {
|
|
t.Parallel()
|
|
result, err := mapGroupModelsResponse(
|
|
string(channel.OpenAI),
|
|
[]GroupModel{{ID: "tiered-model"}},
|
|
modelPriceRows{
|
|
{ChannelID: string(channel.OpenAI), ModelID: "tiered-model"}: {
|
|
ChannelID: string(channel.OpenAI),
|
|
ModelID: "tiered-model",
|
|
ContextPriceTiers: models.JSON(`[{"threshold_tokens":1000,"input_price_nano_usd_per_million_tokens":1,"output_price_nano_usd_per_million_tokens":null,"cache_read_price_nano_usd_per_million_tokens":null,"cache_write_price_nano_usd_per_million_tokens":null}]`),
|
|
},
|
|
},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("mapGroupModelsResponse() error = %v", err)
|
|
}
|
|
want := GroupModelsResponse{
|
|
Items: []GroupModelResponse{{
|
|
ID: "tiered-model", ClientModel: "tiered-model", PricingStatus: PricingStatusConfigured,
|
|
}},
|
|
Total: 1,
|
|
}
|
|
if !reflect.DeepEqual(result, want) {
|
|
t.Fatalf("mapGroupModelsResponse() = %#v, want %#v", result, want)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeGroupModelsAppliesAliasSwitchAndReportsStableConflicts(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
values []GroupModel
|
|
want []GroupModel
|
|
wantConflicts []ModelNameConflict
|
|
wantError error
|
|
}{
|
|
{
|
|
name: "disabled alias uses upstream ID and conflicts with enabled alias",
|
|
values: []GroupModel{
|
|
{ID: "a", Alias: ""},
|
|
{ID: "b", Alias: "a", AliasEnabled: true},
|
|
},
|
|
wantConflicts: []ModelNameConflict{{ClientModel: "a", Indexes: []int{0, 1}}},
|
|
wantError: app_errors.ErrModelNameConflict,
|
|
},
|
|
{
|
|
name: "enabled aliases conflict",
|
|
values: []GroupModel{
|
|
{ID: "a", Alias: "x", AliasEnabled: true},
|
|
{ID: "b", Alias: "x", AliasEnabled: true},
|
|
},
|
|
wantConflicts: []ModelNameConflict{{ClientModel: "x", Indexes: []int{0, 1}}},
|
|
wantError: app_errors.ErrModelNameConflict,
|
|
},
|
|
{
|
|
name: "model names remain case sensitive",
|
|
values: []GroupModel{
|
|
{ID: "a", Alias: "X", AliasEnabled: true},
|
|
{ID: "b", Alias: "x", AliasEnabled: true},
|
|
},
|
|
want: []GroupModel{
|
|
{ID: "a", Alias: "X"},
|
|
{ID: "b", Alias: "x"},
|
|
},
|
|
},
|
|
{
|
|
name: "trimmed IDs and aliases conflict",
|
|
values: []GroupModel{
|
|
{ID: " a ", Alias: ""},
|
|
{ID: "b", Alias: " a ", AliasEnabled: true},
|
|
},
|
|
wantConflicts: []ModelNameConflict{{ClientModel: "a", Indexes: []int{0, 1}}},
|
|
wantError: app_errors.ErrModelNameConflict,
|
|
},
|
|
{
|
|
name: "enabled alias cannot be blank after trimming",
|
|
values: []GroupModel{{ID: "a", Alias: " ", AliasEnabled: true}},
|
|
wantError: app_errors.ErrValidation,
|
|
},
|
|
{
|
|
name: "multiple conflicts use first occurrence order",
|
|
values: []GroupModel{
|
|
{ID: "a"},
|
|
{ID: "b", Alias: "a", AliasEnabled: true},
|
|
{ID: "c"},
|
|
{ID: "d", Alias: "c", AliasEnabled: true},
|
|
},
|
|
wantConflicts: []ModelNameConflict{
|
|
{ClientModel: "a", Indexes: []int{0, 1}},
|
|
{ClientModel: "c", Indexes: []int{2, 3}},
|
|
},
|
|
wantError: app_errors.ErrModelNameConflict,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
got, err := normalizeGroupModels(test.values)
|
|
if test.wantError == nil {
|
|
if err != nil {
|
|
t.Fatalf("normalizeGroupModels() error = %v", err)
|
|
}
|
|
if !reflect.DeepEqual(got, test.want) {
|
|
t.Fatalf("normalizeGroupModels() = %#v, want %#v", got, test.want)
|
|
}
|
|
return
|
|
}
|
|
|
|
var apiErr *app_errors.APIError
|
|
if !errors.As(err, &apiErr) || apiErr.Code != test.wantError.(*app_errors.APIError).Code {
|
|
t.Fatalf("normalizeGroupModels() error = %#v, want %q", err, test.wantError)
|
|
}
|
|
if test.wantConflicts == nil {
|
|
return
|
|
}
|
|
data, ok := apiErr.Data.(ModelNameConflictData)
|
|
if !ok || !reflect.DeepEqual(data.Conflicts, test.wantConflicts) {
|
|
t.Fatalf("conflict data = %#v, want %#v", apiErr.Data, test.wantConflicts)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNormalizeGroupModelsRejectsDuplicateExternalNames(t *testing.T) {
|
|
t.Parallel()
|
|
for _, values := range [][]GroupModel{
|
|
{{ID: "provider-a", Alias: "public", AliasEnabled: true}, {ID: "provider-b", Alias: "public", AliasEnabled: true}},
|
|
{{ID: "public"}, {ID: "provider-b", Alias: "public", AliasEnabled: true}},
|
|
} {
|
|
var apiErr *app_errors.APIError
|
|
if _, err := normalizeGroupModels(values); !errors.As(err, &apiErr) ||
|
|
apiErr.Code != app_errors.ErrModelNameConflict.Code {
|
|
t.Fatalf("normalizeGroupModels(%#v) error = %#v", values, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUpdateGroupModelsRequiresNonNullModelsField(t *testing.T) {
|
|
t.Parallel()
|
|
fixture := newServiceFixture(t)
|
|
groupID := createGroupForCredentialImport(t, fixture, "sk-required-models")
|
|
for _, request := range []GroupModelsUpdateRequest{
|
|
{},
|
|
{Models: optionalGroupModels{Set: false}},
|
|
} {
|
|
if _, err := fixture.service.UpdateGroupModels(t.Context(), groupID, request); !errors.Is(err, app_errors.ErrValidation) {
|
|
t.Fatalf("request %#v error = %v", request, err)
|
|
}
|
|
}
|
|
|
|
var request GroupModelsUpdateRequest
|
|
err := json.Unmarshal([]byte(`{"models":null}`), &request)
|
|
if !errors.Is(err, app_errors.ErrValidation) {
|
|
t.Fatalf("null models error = %v, want ErrValidation", err)
|
|
}
|
|
}
|
|
|
|
func TestUpdateGroupModelsReplacesAuthoritativeListAndPublishesOnce(t *testing.T) {
|
|
t.Parallel()
|
|
fixture := newServiceFixture(t)
|
|
mustEnsureInitialPrices(t, fixture)
|
|
created, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{
|
|
ChannelID: channel.OpenAICompatible,
|
|
Params: json.RawMessage(`{"base_url":"https://model-save.example.com/v1"}`),
|
|
Models: optionalGroupModels{
|
|
Set: true,
|
|
Values: []GroupModel{{ID: "provider-old", Alias: "old-public", AliasEnabled: true}},
|
|
},
|
|
Credentials: "sk-model-save-a\nsk-model-save-b", ConnectionType: "api_key",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
validation := "validation-model-must-stay"
|
|
if _, err := fixture.service.UpdateGroupSettings(t.Context(), created.GroupID, GroupSettingsUpdateRequest{
|
|
ValidationModel: optionalField[string]{Set: true, Value: validation},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := fixture.db.Model(&models.Group{}).
|
|
Where("id = ?", created.GroupID).
|
|
Update("overrides", models.JSON(`{
|
|
"stream_idle_timeout":45,
|
|
"header_rules":{"remove":["X-Trace"]}
|
|
}`)).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := fixture.db.Create(&models.SystemSetting{
|
|
Key: state.SettingRequestTimeout, Value: "701",
|
|
}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
beforeRevision := fixture.manager.Current().Revision
|
|
beforeRegistry := fixture.registry.Snapshot()
|
|
var beforeCredentials []models.Credential
|
|
if err := fixture.db.Where("group_id = ?", created.GroupID).Order("id ASC").Find(&beforeCredentials).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := fixture.service.UpdateGroupModels(t.Context(), created.GroupID, GroupModelsUpdateRequest{
|
|
Models: optionalGroupModels{
|
|
Set: true,
|
|
Values: []GroupModel{
|
|
{ID: "provider-b", Alias: "public-b", AliasEnabled: true},
|
|
{ID: "provider-a", Alias: "public-a", AliasEnabled: true},
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateGroupModels() error = %v", err)
|
|
}
|
|
wantModels := []GroupModel{
|
|
{ID: "provider-b", Alias: "public-b"},
|
|
{ID: "provider-a", Alias: "public-a"},
|
|
}
|
|
want := GroupModelsResponse{
|
|
Items: []GroupModelResponse{
|
|
{ID: "provider-b", Alias: "public-b", AliasEnabled: true, ClientModel: "public-b", PricingStatus: PricingStatusPending},
|
|
{ID: "provider-a", Alias: "public-a", AliasEnabled: true, ClientModel: "public-a", PricingStatus: PricingStatusPending},
|
|
},
|
|
Total: 2,
|
|
Pending: 2,
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("models response = %#v, want %#v", got, want)
|
|
}
|
|
settings, err := fixture.service.GetGroupSettings(t.Context(), created.GroupID)
|
|
if err != nil {
|
|
t.Fatalf("GetGroupSettings() error = %v", err)
|
|
}
|
|
summary, err := fixture.service.GetGroupSummary(t.Context(), created.GroupID)
|
|
if err != nil {
|
|
t.Fatalf("GetGroupSummary() error = %v", err)
|
|
}
|
|
if settings.ValidationModel == nil || *settings.ValidationModel != validation || summary.CredentialCount != 2 {
|
|
t.Fatalf("settings/summary = %#v/%#v", settings, summary)
|
|
}
|
|
streamIdle, ok := settings.Overrides[state.SettingStreamIdleTimeout].(json.Number)
|
|
if len(settings.Overrides) != 2 || !ok || streamIdle.String() != "45" ||
|
|
settings.Overrides[state.SettingHeaderRules] == nil {
|
|
t.Fatalf("preserved sparse config = %#v", settings.Overrides)
|
|
}
|
|
if settings.Effective.FirstByteTimeout != 120 ||
|
|
settings.Effective.RequestTimeout != 701 ||
|
|
settings.Effective.StreamIdleTimeout != 45 ||
|
|
len(settings.Effective.HeaderRules.Set) != 0 ||
|
|
!reflect.DeepEqual(settings.Effective.HeaderRules.Remove, []string{"X-Trace"}) {
|
|
t.Fatalf("post-write effective config = %#v", settings.Effective)
|
|
}
|
|
if settings.Effective.HeaderRules.Set == nil || settings.Effective.HeaderRules.Remove == nil {
|
|
t.Fatalf("effective header collections = %#v", settings.Effective.HeaderRules)
|
|
}
|
|
if stored := loadCreatedGroupModels(t, fixture, created.GroupID); !reflect.DeepEqual(stored, wantModels) {
|
|
t.Fatalf("stored models = %#v, want %#v", stored, wantModels)
|
|
}
|
|
if fixture.manager.Current().Revision != beforeRevision+1 {
|
|
t.Fatalf("revision = %d, want %d", fixture.manager.Current().Revision, beforeRevision+1)
|
|
}
|
|
if !reflect.DeepEqual(fixture.registry.Snapshot(), beforeRegistry) {
|
|
t.Fatal("models save changed Registry")
|
|
}
|
|
var afterCredentials []models.Credential
|
|
if err := fixture.db.Where("group_id = ?", created.GroupID).Order("id ASC").Find(&afterCredentials).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(afterCredentials, beforeCredentials) {
|
|
t.Fatalf("credentials changed: got=%#v want=%#v", afterCredentials, beforeCredentials)
|
|
}
|
|
snapshot := fixture.manager.Current()
|
|
view := snapshot.Groups[created.GroupID]
|
|
if settings.Effective.RequestTimeout != int64(view.Timeouts.Request/time.Second) ||
|
|
settings.Effective.StreamIdleTimeout != int64(view.Timeouts.StreamIdle/time.Second) ||
|
|
settings.Effective.AffinityEnabled != view.AffinityEnabled ||
|
|
!reflect.DeepEqual(settings.Effective.HeaderRules.Remove, view.HeaderRules.Remove) {
|
|
t.Fatalf("effective/snapshot = %#v/%#v", settings.Effective, view)
|
|
}
|
|
targets := snapshot.ExecutionCandidates[protocol.OpenAICompletions][execution.OperationChatCompletion]
|
|
if len(targets) != 2 ||
|
|
targets["public-a"][0].UpstreamModelID != "provider-a" ||
|
|
targets["public-b"][0].UpstreamModelID != "provider-b" {
|
|
t.Fatalf("candidate mapping = %#v", targets)
|
|
}
|
|
if _, exists := targets["old-public"]; exists {
|
|
t.Fatalf("authoritative replacement retained old model: %#v", targets)
|
|
}
|
|
routes := snapshot.ExecutionRouteCatalog[protocol.OpenAICompletions][execution.OperationChatCompletion]
|
|
if len(routes) != 2 ||
|
|
routes["public-a"][0].UpstreamModelID != "provider-a" ||
|
|
routes["public-b"][0].UpstreamModelID != "provider-b" {
|
|
t.Fatalf("route catalog = %#v", routes)
|
|
}
|
|
}
|
|
|
|
func TestUpdateGroupModelsAllowsEmptyList(t *testing.T) {
|
|
t.Parallel()
|
|
fixture := newServiceFixture(t)
|
|
mustEnsureInitialPrices(t, fixture)
|
|
created, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{
|
|
ChannelID: channel.OpenAICompatible,
|
|
Params: json.RawMessage(`{"base_url":"https://empty-models.example.com/v1"}`),
|
|
Models: optionalGroupModels{
|
|
Set: true,
|
|
Values: []GroupModel{{ID: "provider-old", Alias: "old-public", AliasEnabled: true}},
|
|
},
|
|
Credentials: "sk-empty-models", ConnectionType: "api_key",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before := fixture.manager.Current().Revision
|
|
got, err := fixture.service.UpdateGroupModels(t.Context(), created.GroupID, GroupModelsUpdateRequest{
|
|
Models: optionalGroupModels{Set: true, Values: []GroupModel{}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := GroupModelsResponse{Items: []GroupModelResponse{}, Total: 0, Pending: 0}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("models response = %#v, want %#v", got, want)
|
|
}
|
|
if fixture.manager.Current().Revision != before+1 {
|
|
t.Fatalf("revision = %d, want %d", fixture.manager.Current().Revision, before+1)
|
|
}
|
|
if len(fixture.manager.Current().ExecutionCandidates[protocol.OpenAICompletions][execution.OperationChatCompletion]) != 0 ||
|
|
len(fixture.manager.Current().ExecutionRouteCatalog[protocol.OpenAICompletions][execution.OperationChatCompletion]) != 0 {
|
|
t.Fatalf("model indexes = candidates:%#v routes:%#v",
|
|
fixture.manager.Current().ExecutionCandidates, fixture.manager.Current().ExecutionRouteCatalog)
|
|
}
|
|
}
|
|
|
|
func TestUpdateGroupModelsNeverCallsDiscoveryOrChangesAccessKeyFilters(t *testing.T) {
|
|
t.Parallel()
|
|
fixture := newServiceFixture(t)
|
|
mustEnsureInitialPrices(t, fixture)
|
|
groupID := createGroupForCredentialImport(t, fixture, "sk-no-discovery")
|
|
access, err := fixture.service.CreateAccessKey(t.Context(), AccessKeyCreateRequest{
|
|
Name: "filtered",
|
|
Filters: &AccessKeyFilters{
|
|
Groups: []uint{groupID},
|
|
Models: []string{"old-public"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var beforeAccess models.AccessKey
|
|
if err := fixture.db.First(&beforeAccess, access.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fixture.service.executor = newRecordingDiscoveryExecutor(&recordingDiscoveryExecutorTarget{
|
|
value: protocol.OpenAICompletions,
|
|
listFn: func(context.Context, string, string, state.HeaderRules) ([]string, error) {
|
|
t.Fatal("UpdateGroupModels must not call model discovery")
|
|
return nil, nil
|
|
},
|
|
})
|
|
|
|
_, err = fixture.service.UpdateGroupModels(t.Context(), groupID, GroupModelsUpdateRequest{
|
|
Models: optionalGroupModels{
|
|
Set: true,
|
|
Values: []GroupModel{{ID: "provider-new", Alias: "new-public", AliasEnabled: true}},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var afterAccess models.AccessKey
|
|
if err := fixture.db.First(&afterAccess, access.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(afterAccess, beforeAccess) {
|
|
t.Fatalf("persisted AccessKey changed: got=%#v want=%#v", afterAccess, beforeAccess)
|
|
}
|
|
filters, err := decodeStoredAccessKeyFilters(afterAccess.Filters)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(filters.Models, []string{"old-public"}) {
|
|
t.Fatalf("filters = %#v", filters)
|
|
}
|
|
}
|
|
|
|
func TestUpdateGroupModelsFailuresDoNotPublish(t *testing.T) {
|
|
t.Parallel()
|
|
t.Run("external collision", func(t *testing.T) {
|
|
fixture := newServiceFixture(t)
|
|
groupID := createGroupForCredentialImport(t, fixture, "sk-invalid-models")
|
|
beforeRevision := fixture.manager.Current().Revision
|
|
beforeRegistry := fixture.registry.Snapshot()
|
|
beforeModels := loadCreatedGroupModels(t, fixture, groupID)
|
|
|
|
_, err := fixture.service.UpdateGroupModels(t.Context(), groupID, GroupModelsUpdateRequest{
|
|
Models: optionalGroupModels{
|
|
Set: true,
|
|
Values: []GroupModel{
|
|
{ID: "provider-a", Alias: "public", AliasEnabled: true},
|
|
{ID: "provider-b", Alias: "public", AliasEnabled: true},
|
|
},
|
|
},
|
|
})
|
|
var apiErr *app_errors.APIError
|
|
if !errors.As(err, &apiErr) || apiErr.Code != app_errors.ErrModelNameConflict.Code {
|
|
t.Fatalf("UpdateGroupModels() error = %#v, want MODEL_NAME_CONFLICT", err)
|
|
}
|
|
assertModelsUpdateStateUnchanged(t, fixture, groupID, beforeRevision, beforeRegistry, beforeModels)
|
|
})
|
|
|
|
t.Run("full compile failure", func(t *testing.T) {
|
|
fixture := newServiceFixture(t)
|
|
groupID := createGroupForCredentialImport(t, fixture, "sk-compile-models")
|
|
corrupt := validControlGroup("model-save-corrupt-other")
|
|
if err := fixture.db.Create(corrupt).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := fixture.db.Exec("UPDATE groups SET channel_id = ? WHERE id = ?", "unknown", corrupt.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
beforeRevision := fixture.manager.Current().Revision
|
|
beforeRegistry := fixture.registry.Snapshot()
|
|
beforeModels := loadCreatedGroupModels(t, fixture, groupID)
|
|
|
|
_, err := fixture.service.UpdateGroupModels(t.Context(), groupID, GroupModelsUpdateRequest{
|
|
Models: optionalGroupModels{
|
|
Set: true,
|
|
Values: []GroupModel{{ID: "provider-new", Alias: "new-public", AliasEnabled: true}},
|
|
},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("UpdateGroupModels() error = nil, want full Compile failure")
|
|
}
|
|
assertModelsUpdateStateUnchanged(t, fixture, groupID, beforeRevision, beforeRegistry, beforeModels)
|
|
})
|
|
|
|
t.Run("commit failure", func(t *testing.T) {
|
|
fixture, dsn := newFileServiceFixture(t)
|
|
created, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{
|
|
ChannelID: channel.OpenAICompatible,
|
|
Params: json.RawMessage(`{"base_url":"https://commit-failure-models.example.com/v1"}`),
|
|
Models: optionalGroupModels{
|
|
Set: true,
|
|
Values: []GroupModel{{ID: "provider-old", Alias: "old-public", AliasEnabled: true}},
|
|
},
|
|
Credentials: "sk-commit-models", ConnectionType: "api_key",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
beforeRevision := fixture.manager.Current().Revision
|
|
beforeRegistry := fixture.registry.Snapshot()
|
|
beforeModels := loadCreatedGroupModels(t, fixture, created.GroupID)
|
|
releaseReader := holdRollbackJournalReadLock(t, fixture.db, dsn)
|
|
|
|
_, err = fixture.service.UpdateGroupModels(t.Context(), created.GroupID, GroupModelsUpdateRequest{
|
|
Models: optionalGroupModels{
|
|
Set: true,
|
|
Values: []GroupModel{{ID: "provider-new", Alias: "new-public", AliasEnabled: true}},
|
|
},
|
|
})
|
|
var apiErr *app_errors.APIError
|
|
if !errors.As(err, &apiErr) || apiErr.Code != app_errors.ErrDatabase.Code {
|
|
t.Fatalf("UpdateGroupModels() error = %#v, want DATABASE_ERROR", err)
|
|
}
|
|
releaseReader()
|
|
assertModelsUpdateStateUnchanged(
|
|
t, fixture, created.GroupID, beforeRevision, beforeRegistry, beforeModels,
|
|
)
|
|
})
|
|
}
|
|
|
|
func assertModelsUpdateStateUnchanged(
|
|
t *testing.T,
|
|
fixture serviceFixture,
|
|
groupID uint,
|
|
wantRevision uint64,
|
|
wantRegistry []state.CredentialRuntimeView,
|
|
wantModels []GroupModel,
|
|
) {
|
|
t.Helper()
|
|
if fixture.manager.Current().Revision != wantRevision {
|
|
t.Fatalf("revision = %d, want unchanged %d", fixture.manager.Current().Revision, wantRevision)
|
|
}
|
|
if !reflect.DeepEqual(fixture.registry.Snapshot(), wantRegistry) {
|
|
t.Fatal("Registry changed")
|
|
}
|
|
if got := loadCreatedGroupModels(t, fixture, groupID); !reflect.DeepEqual(got, wantModels) {
|
|
t.Fatalf("persisted models changed: got=%#v want=%#v", got, wantModels)
|
|
}
|
|
}
|