Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ Structured state (trace index, sessions, channel/provider config, upstream targe
- Production and the tracked default config use Postgres; the checked-in SQL migrations live in `ent/postgres-migrations/`.
- SQLite is a local/dev/test fallback only, with default file `{{output_dir}}/llm_tracelab.sqlite3`; SQLite schema is applied at startup rather than by versioned migrations.
- Raw `.http` cassettes remain the source of truth for replay and detail views; the database is a derived index for lists, filters, and aggregates.
- YAML channel configuration is a first-bootstrap input only. The first database write stores the application-database `app_settings` key `channels.initialized`; afterwards the database owns routing configuration even when every channel was disabled or deleted. `GET /api/settings/channels` reports the marker and `DELETE /api/settings/channels` clears it, which only re-opens the YAML bootstrap while the database still has no channels. A YAML config with an explicit `credentials` list stays YAML-managed and rejects Monitor channel/model/alias writes with 409.
- All management writes (channels, models, aliases, provider setup and probe apply) run in one `store.ConfigurationTransaction`, which holds the process-wide configuration lock, the upstream write lock, and one SQL transaction; runtime routing is published only after the commit succeeds. Background upstream refresh persists through the same upstream write lock on a best-effort basis.

Current protocol families are documented in `docs/protocol-reference/implemented-protocols.md`.
The proxy is protocol-aware pass-through plus recording/parsing; it does not currently translate requests between OpenAI, Anthropic, Gemini, and Vertex protocol families in the forwarding hot path.
Expand Down
44 changes: 44 additions & 0 deletions cmd/server/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5473,3 +5473,47 @@ func (t authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
clone.Header.Set("Authorization", "Bearer "+t.Token)
return http.DefaultTransport.RoundTrip(clone)
}

func TestRouterConfigurationKeepsDisabledAndDeletedChannelsAcrossBootstrap(t *testing.T) {
for _, deleted := range []bool{false, true} {
t.Run(map[bool]string{false: "disabled", true: "deleted"}[deleted], func(t *testing.T) {
st, err := store.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer st.Close()
cfg := &config.Config{Upstream: config.UpstreamConfig{BaseURL: "https://yaml.example.invalid/v1", ProviderPreset: "openai"}}
svc := channel.NewService(st)
if _, err := svc.BootstrapFromConfig(cfg); err != nil {
t.Fatal(err)
}
channels, err := st.ListChannelConfigs()
if err != nil {
t.Fatal(err)
}
if len(channels) != 1 {
t.Fatalf("channels=%v", channels)
}
if deleted {
err = st.DeleteChannelConfig(channels[0].ID)
} else {
record := channels[0]
record.Enabled = false
_, err = st.UpsertChannelConfig(record)
}
if err != nil {
t.Fatal(err)
}
if imported, err := svc.BootstrapFromConfig(cfg); err != nil || imported != 0 {
t.Fatalf("bootstrap=%d %v", imported, err)
}
runtimeCfg, source, err := routerConfigFromChannels(cfg, svc)
if err != nil {
t.Fatal(err)
}
if source != "database" || len(runtimeCfg.EffectiveUpstreams()) != 0 {
t.Fatalf("source=%s targets=%v", source, runtimeCfg.EffectiveUpstreams())
}
})
}
}
2 changes: 1 addition & 1 deletion cmd/server/management.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func newManagementMuxWithFunctionExecutorManager(
}
monitor.RegisterRoutes(mux, traceStore, monitor.RouteOptions{
Router: rtr,
ChannelService: channel.NewService(traceStore),
ChannelService: channel.NewService(traceStore).WithReadOnly(configHasExplicitCredentials(cfg)),
AuthVerifier: verifier,
MonitorAuthVerifier: monitorVerifier,
MonitorJWT: monitorJWT,
Expand Down
6 changes: 5 additions & 1 deletion cmd/server/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,11 @@ func routerConfigFromChannels(cfg *config.Config, channelService *channel.Servic
if err != nil {
return nil, "", err
}
if len(targets) == 0 {
initialized, err := channelService.HasConfiguration()
if err != nil {
return nil, "", err
}
if !initialized {
return cfg, "yaml", nil
}
routerCfg := *cfg
Expand Down
6 changes: 5 additions & 1 deletion docs/MAINTAINER_BASELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,11 @@ YAML `upstream` / `upstreams` 是兼容 bootstrap 输入。
修改渠道管理时必须保持:

- DB 优先。
- legacy YAML 可首次导入。
- legacy YAML 可首次导入。首次数据库写入会记录 `app_settings` 键 `channels.initialized`;此后 DB 是路由配置来源,全部渠道停用或删除也不会回退到 YAML。`GET /api/settings/channels` 报告该标记,`DELETE /api/settings/channels` 仅清除标记:只有在 DB 中确实没有渠道时,下次启动才会重新导入 YAML。
- API key 和敏感 header 本地加密。
- channel/model 启停能 reload router。
- 所有管理写入(渠道、模型、别名、provider setup/probe apply)共用 `store.ConfigurationTransaction`:它按 `configMu` -> `upstreamMu` -> `reloadMu` -> `router.mu` 的顺序持锁并使用单个 SQL 事务,只有 commit 成功后才发布新路由快照。不要在事务内再开事务(会返回 `store.ErrNestedTransaction`),也不要新增 `reloadMu` -> `upstreamMu` 的反向获取。
- 后台刷新和代理侧 `RefreshNow` 只以 best-effort 方式获取 `upstreamMu`:配置变更持锁时跳过落库但仍更新内存,并在获得锁后按当前 live target 集合过滤,避免为已删除的 target 复活 `upstream_targets`/`upstream_models` 行。

## 协议边界

Expand All @@ -125,6 +127,8 @@ YAML `upstream` / `upstreams` 是兼容 bootstrap 输入。
- `/api/models`
- `/api/channels`
- `/api/routing/summary`
- `/api/settings/routing`
- `/api/settings/channels`
- `/api/events`
- `/api/findings`
- `/api/analysis`
Expand Down
18 changes: 15 additions & 3 deletions docs/MONITOR_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,15 +96,27 @@ Monitor 使用两类数据:
- 设置 provider preset、base URL、API key、headers、routing 字段。
- 设置 provider API surface:`api_type`、`mode`,以及 Responses、Chat Completions、tool calling、models 等 capability 开关;这些字段会写入 channel store,并在运行时还原为 upstream routing target。
- 创建前可用 Detect provider 做临时探测,不落库返回 API surface 建议;需要采用建议时,使用 Apply suggestions 显式写入表单。
- 创建前需用 Validate setup 调用 provider setup validate:它会组合 base URL、API key、provider preset、model discovery 和 capability 字段做一次探测并把归一化配置写回表单,但不会落库;dialog 会展示 normalized config、probe 和 redacted secret state,字段变更会清空旧验证结果;Create provider 才通过 setup apply 写入 channel store;若 probe 未检测成功,需显式提供 `api_type` 与 `protocol_family`。
- 创建前可用 Validate setup 调用 provider setup validate:它会组合 base URL、API key、provider preset、model discovery 和 capability 字段做一次探测并把归一化配置写回表单,但不会落库;dialog 会展示 normalized config、probe 和 redacted secret state,字段变更会清空旧验证结果;Create provider 通过 setup apply 写入 channel store;显式提供协议信息时也允许直接创建,若 probe 未检测成功,需显式提供 `api_type` 与 `protocol_family`。
- setup validate/apply 响应不会回显 API key;只返回 `api_key_hint`、secret storage mode 和 redacted header 状态。
- 探测模型,并查看 provider detection 建议;需要写回建议时,使用 Apply suggestions 显式更新 channel 配置。
- 在 Providers 列表页使用 Batch probe and apply 先运行只读 `POST /api/provider-probe/report` 预览,再对 detected 且有可补字段的 provider 执行批量 Apply detected suggestions;Monitor 不展示或传递 API key。批量应用只填缺失的 `api_type`、`protocol_family` 和未设置 capability,不覆盖显式配置,也不覆盖显式 `false` capability。
- 在 Providers 列表页使用 Batch probe and apply:先运行只读 `POST /api/provider-probe/report` 预览,再通过 `POST /api/provider-probe/report/apply` 对 detected 且有可补字段的 provider 批量应用建议;单个 provider 的探测入口在卡片上。Monitor 不展示或传递 API key。批量应用只填缺失的 `api_type`、`protocol_family` 和未设置 capability,不覆盖显式配置,也不覆盖显式 `false` capability。
- 启停渠道。
- 启停单个模型。
- 查看渠道用量、token、失败和 probe 结果。

长期渠道配置保存在 application store;Postgres 部署使用版本化迁移,SQLite 仍作为本地 fallback。YAML 只作为启动和首次 bootstrap 输入。
长期渠道配置保存在 application store;Postgres 部署使用版本化迁移,SQLite 仍作为本地 fallback。通常 YAML 只作为启动和首次 bootstrap 输入;首次初始化会在应用库 `app_settings` 写入 `channels.initialized` 标记(`GET /api/settings/channels` 报告,`DELETE /api/settings/channels` 清除),即使后来停用或删除全部渠道,重启也不会重新导入 YAML。清除该标记只是重新放行 YAML bootstrap:只要数据库里仍有渠道配置,数据库依旧是路由配置来源;只有数据库确实为空时,下次启动才重新导入 YAML。使用显式 `credentials` 列表的 YAML 配置仍由 YAML 管理,此模式下 Monitor 的渠道、模型和别名写操作返回 409,避免数据库操作替换 YAML 中的凭据路由。

### 上游和模型的启停语义

- 创建上游只保存连接配置。创建成功后进入详情页,发现或手动添加模型,再选择启用。没有启用模型且未允许未知模型时,不接受命名模型请求。
- 上游启用表示允许参与新请求路由;停用会移除该上游的路由资格,但保留各模型的选择。已在途请求不会因此被取消。
- 模型启用只作用于当前上游,不会启动或停止远端模型进程,也不会改变同名模型在其他上游的配置。
- 模型停用是明确拒绝:自动发现、未知模型放行和 fallback 策略均不能重新放行该上游上的已停用模型。删除模型则是移除配置,不等同于停用;允许未知模型时,删除后的模型可能再次作为未知模型被调用。
- 模型发现默认不启用新模型,保留已有模型的启停选择和能力配置。API 调用方可显式传入 `enable_discovered: true` 启用本次新发现的模型。数据库管理的运行时只从已启用配置建立模型目录,不通过周期刷新扩大准入范围。
- 配置开关与健康状态相互独立。上游停用时,模型行仍保留“已启用”的选择,同时提示路由被上游开关阻止;健康检查和熔断仍可能使已启用模型暂时不可用。
- 仅来自历史请求的模型行不提供启停开关;需要管理时先手动添加模型。发现后停用的模型不再被标为“新模型”。
- 渠道、模型、模型别名及批量修改通过同一数据库事务完成。运行时配置准备成功后才提交并切换路由;校验、批量更新或提交失败时保留原配置和原路由。返回失败的模型探测仍保留诊断记录。


### Connect

Expand Down
149 changes: 109 additions & 40 deletions internal/channel/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import (
)

type Store interface {
LoadAppSettingJSON(context.Context, string, any) (bool, error)
SaveAppSettingJSON(context.Context, string, any) error
DeleteAppSetting(context.Context, string) (bool, error)
ListChannelConfigs() ([]store.ChannelConfigRecord, error)
GetChannelConfig(channelID string) (store.ChannelConfigRecord, error)
UpsertChannelConfig(store.ChannelConfigRecord) (store.ChannelConfigRecord, error)
Expand All @@ -36,15 +39,65 @@ type Store interface {
type Service struct {
store Store
httpClient *http.Client
readOnly bool
}

func NewService(st Store) *Service {
return &Service{store: st}
}

// WithHTTPClient returns a copy of the service that probes with client.
//
// Every With* helper returns a derived service and leaves the receiver
// untouched, so a service shared by several callers (for example the one built
// for the Monitor) can be specialised without mutating it in place.
func (s *Service) WithHTTPClient(client *http.Client) *Service {
s.httpClient = client
return s
next := *s
next.httpClient = client
return &next
}

// WithStore preserves probe options when applying a configuration transaction.
func (s *Service) WithStore(st Store) *Service {
next := *s
next.store = st
return &next
}

func (s *Service) WithReadOnly(readOnly bool) *Service {
next := *s
next.readOnly = readOnly
return &next
}

func (s *Service) ReadOnly() bool { return s != nil && s.readOnly }

const configurationInitializedKey = "channels.initialized"

// HasConfiguration distinguishes an intentionally empty database from first startup.
func (s *Service) HasConfiguration() (bool, error) {
var initialized bool
if _, err := s.store.LoadAppSettingJSON(context.Background(), configurationInitializedKey, &initialized); err != nil {
return false, err
}
if initialized {
return true, nil
}
channels, err := s.store.ListChannelConfigs()
return len(channels) > 0, err
}

func (s *Service) MarkConfigurationInitialized() error {
return s.store.SaveAppSettingJSON(context.Background(), configurationInitializedKey, true)
}

// ResetConfigurationInitialized clears the explicit bootstrap marker so the next
// start may import YAML again. It deliberately does not delete channels: a
// database that still holds channel configuration keeps counting as
// initialized, because HasConfiguration also falls back to the stored channels.
func (s *Service) ResetConfigurationInitialized() error {
_, err := s.store.DeleteAppSetting(context.Background(), configurationInitializedKey)
return err
}

type ProbeResult struct {
Expand Down Expand Up @@ -85,15 +138,19 @@ type ProviderProbeApplyItem struct {
SkippedReason string `json:"skipped_reason,omitempty"`
}

// BootstrapFromConfig imports YAML upstreams into the channel store on first
// startup only. It never writes once the database owns the configuration: the
// marker is stored by the first import or by the first management write, so a
// startup that is already initialized stays read-only.
func (s *Service) BootstrapFromConfig(cfg *config.Config) (int, error) {
if s == nil || s.store == nil {
return 0, fmt.Errorf("channel service store is required")
}
existing, err := s.store.ListChannelConfigs()
initialized, err := s.HasConfiguration()
if err != nil {
return 0, err
}
if len(existing) > 0 {
if initialized {
return 0, nil
}

Expand All @@ -114,6 +171,10 @@ func (s *Service) BootstrapFromConfig(cfg *config.Config) (int, error) {
if target.Enabled != nil {
enabled = *target.Enabled
}
allowUnknown := false
if target.AllowUnknownModels != nil {
allowUnknown = *target.AllowUnknownModels
}
headersJSON := "{}"
if len(target.Upstream.Headers) > 0 {
data, err := json.Marshal(target.Upstream.Headers)
Expand All @@ -127,29 +188,30 @@ func (s *Service) BootstrapFromConfig(cfg *config.Config) (int, error) {
return imported, err
}
_, err = s.store.UpsertChannelConfig(store.ChannelConfigRecord{
ID: channelID,
Name: defaultChannelName(channelID, target.Upstream.ProviderPreset),
Source: "bootstrap",
BaseURL: target.Upstream.BaseURL,
ProviderPreset: target.Upstream.ProviderPreset,
APIType: target.Upstream.APIType,
Mode: target.Upstream.Mode,
CapabilitiesJSON: capabilitiesJSON,
ProtocolFamily: target.Upstream.ProtocolFamily,
RoutingProfile: target.Upstream.RoutingProfile,
APIVersion: target.Upstream.APIVersion,
Deployment: target.Upstream.Deployment,
Project: target.Upstream.Project,
Location: target.Upstream.Location,
ModelResource: target.Upstream.ModelResource,
APIKeyCiphertext: []byte(target.Upstream.ApiKey),
APIKeyHint: secretHint(target.Upstream.ApiKey),
HeadersJSON: headersJSON,
Enabled: enabled,
Priority: target.Priority,
Weight: target.Weight,
CapacityHint: target.CapacityHint,
ModelDiscovery: target.ModelDiscovery,
ID: channelID,
Name: defaultChannelName(channelID, target.Upstream.ProviderPreset),
Source: "bootstrap",
BaseURL: target.Upstream.BaseURL,
ProviderPreset: target.Upstream.ProviderPreset,
APIType: target.Upstream.APIType,
Mode: target.Upstream.Mode,
CapabilitiesJSON: capabilitiesJSON,
ProtocolFamily: target.Upstream.ProtocolFamily,
RoutingProfile: target.Upstream.RoutingProfile,
APIVersion: target.Upstream.APIVersion,
Deployment: target.Upstream.Deployment,
Project: target.Upstream.Project,
Location: target.Upstream.Location,
ModelResource: target.Upstream.ModelResource,
APIKeyCiphertext: []byte(target.Upstream.ApiKey),
APIKeyHint: secretHint(target.Upstream.ApiKey),
HeadersJSON: headersJSON,
Enabled: enabled,
Priority: target.Priority,
Weight: target.Weight,
CapacityHint: target.CapacityHint,
ModelDiscovery: target.ModelDiscovery,
AllowUnknownModels: allowUnknown,
})
if err != nil {
return imported, err
Expand All @@ -176,7 +238,7 @@ func (s *Service) BootstrapFromConfig(cfg *config.Config) (int, error) {
}
imported++
}
return imported, nil
return imported, s.MarkConfigurationInitialized()
}

func configuredUpstreams(cfg *config.Config) []config.UpstreamTargetConfig {
Expand Down Expand Up @@ -218,10 +280,20 @@ func (s *Service) RuntimeTargets() ([]config.UpstreamTargetConfig, error) {
if !channel.Enabled {
continue
}
models, err := s.store.ListChannelModels(channel.ID, true)
models, err := s.store.ListChannelModels(channel.ID, false)
if err != nil {
return nil, err
}
var enabledModels []store.ChannelModelRecord
var disabledModels []string
for _, model := range models {
if model.Enabled {
enabledModels = append(enabledModels, model)
} else {
disabledModels = append(disabledModels, model.Model)
}
}
models = enabledModels
headers := map[string]string{}
if strings.TrimSpace(channel.HeadersJSON) != "" {
if err := json.Unmarshal([]byte(channel.HeadersJSON), &headers); err != nil {
Expand All @@ -243,6 +315,7 @@ func (s *Service) RuntimeTargets() ([]config.UpstreamTargetConfig, error) {
StaticModels: channelModelNamesWithAliases(models, aliases, channel.ID),
ModelAliases: channelModelAliases(models, aliases, channel.ID),
ConfiguredModelsOnly: true,
DisabledModels: disabledModels,
AllowUnknownModels: &channel.AllowUnknownModels,
Upstream: config.UpstreamConfig{
BaseURL: channel.BaseURL,
Expand Down Expand Up @@ -539,16 +612,12 @@ func (s *Service) mergeDiscoveredChannelModels(channelID string, discovered []st
}
record.Enabled = enableNew
if previous, ok := existingByModel[model]; ok {
record.Enabled = previous.Enabled
if strings.TrimSpace(previous.Source) != "" && previous.Source != "discovered" {
record.Source = previous.Source
}
if strings.TrimSpace(previous.DisplayName) != "" {
record.DisplayName = previous.DisplayName
}
if !previous.FirstSeenAt.IsZero() {
record.FirstSeenAt = previous.FirstSeenAt
}
// Re-discovery updates observation timestamps, not operator choices
// or an adopted model profile.
lastSeen, lastProbe := record.LastSeenAt, record.LastProbeAt
record = previous
record.LastSeenAt = lastSeen
record.LastProbeAt = lastProbe
}
saved, err := s.store.UpsertChannelModel(channelID, record)
if err != nil {
Expand All @@ -561,7 +630,7 @@ func (s *Service) mergeDiscoveredChannelModels(channelID string, discovered []st

func enableDiscoveredByDefault(options ProbeOptions) bool {
if options.EnableDiscovered == nil {
return true
return false
}
return *options.EnableDiscovered
}
Expand Down
Loading
Loading