Skip to content
44 changes: 0 additions & 44 deletions docs/session-persistence-design.md

This file was deleted.

16 changes: 8 additions & 8 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1231,10 +1231,10 @@ func TestDescriptorFromRawModel(t *testing.T) {
t.Parallel()

tests := []struct {
name string
raw map[string]any
want ModelDescriptor
wantOK bool
name string
raw map[string]any
want ModelDescriptor
wantOK bool
}{
{
name: "empty map returns false",
Expand All @@ -1252,10 +1252,10 @@ func TestDescriptorFromRawModel(t *testing.T) {
{
name: "full descriptor",
raw: map[string]any{
"id": "gpt-4.1",
"display_name": "GPT-4.1",
"description": "desc",
"context_window": 128000,
"id": "gpt-4.1",
"display_name": "GPT-4.1",
"description": "desc",
"context_window": 128000,
"max_output_tokens": 16384,
},
want: ModelDescriptor{
Expand Down
2 changes: 1 addition & 1 deletion internal/config/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const (
DefaultWebFetchMaxResponseBytes int64 = 256 * 1024
DefaultCompactManualKeepRecentMessages = 10
DefaultCompactMaxSummaryChars = 1200
DefaultAutoCompactInputTokenThreshold = 100000
DefaultAutoCompactInputTokenThreshold = 100000
)

const (
Expand Down
43 changes: 43 additions & 0 deletions internal/provider/capabilities.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package provider

// CapabilitySupport 表示模型能力的确认状态。
type CapabilitySupport string

const (
CapabilitySupportUnknown CapabilitySupport = "unknown"
CapabilitySupportUnsupported CapabilitySupport = "unsupported"
CapabilitySupportSupported CapabilitySupport = "supported"
)

// ReasoningMode 表示模型推理能力的形态。
type ReasoningMode string

const (
ReasoningModeUnknown ReasoningMode = "unknown"
ReasoningModeNone ReasoningMode = "none"
ReasoningModeNative ReasoningMode = "native"
ReasoningModeConfigurable ReasoningMode = "configurable"
)

// DriverCapabilities 描述 driver 层本身是否能传输某类能力。
type DriverCapabilities struct {
Streaming bool
ToolTransport bool
ModelDiscovery bool
ImageInputTransport bool
}

// ModelCapabilities 描述具体模型的能力声明。
type ModelCapabilities struct {
ToolCalling CapabilitySupport
ImageInput CapabilitySupport
ReasoningMode ReasoningMode
MaxContextTokens int
MaxOutputTokens int
}

// ResolvedCapabilities 是 driver 能力与模型能力的合并视图。
type ResolvedCapabilities struct {
Driver DriverCapabilities
Model ModelCapabilities
}
188 changes: 187 additions & 1 deletion internal/provider/catalog/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package catalog

import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"

Expand Down Expand Up @@ -203,6 +205,190 @@ func TestDescriptorsFromIDsHelper(t *testing.T) {
}
}

func TestServiceValidateErrors(t *testing.T) {
t.Parallel()

t.Run("nil service", func(t *testing.T) {
t.Parallel()

var service *Service
_, err := service.ListProviderModels(context.Background(), config.OpenAIProvider())
if err == nil || err.Error() != "provider catalog: service is nil" {
t.Fatalf("expected nil service error, got %v", err)
}
})

t.Run("nil registry", func(t *testing.T) {
t.Parallel()

service := NewService("", nil, newMemoryStore())
_, err := service.ListProviderModels(context.Background(), config.OpenAIProvider())
if err == nil || err.Error() != "provider catalog: registry is nil" {
t.Fatalf("expected nil registry error, got %v", err)
}
})
}

func TestListProviderModelsHonorsContextError(t *testing.T) {
t.Parallel()

service := NewService("", provider.NewRegistry(), newMemoryStore())
ctx, cancel := context.WithCancel(context.Background())
cancel()

_, err := service.ListProviderModels(ctx, config.OpenAIProvider())
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled, got %v", err)
}
}

func TestListProviderModelsCachedUsesFreshCatalogWithoutDiscovery(t *testing.T) {
t.Setenv(testAPIKeyEnv, "test-key")

identity, err := config.OpenAIProvider().Identity()
if err != nil {
t.Fatalf("Identity() error = %v", err)
}

store := newMemoryStore()
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
if err := store.Save(context.Background(), ModelCatalog{
SchemaVersion: schemaVersion,
Identity: identity,
FetchedAt: now.Add(-time.Hour),
ExpiresAt: now.Add(time.Hour),
Models: []config.ModelDescriptor{
{ID: "cached-model", Name: "Cached Model"},
},
}); err != nil {
t.Fatalf("seed fresh catalog: %v", err)
}

var discoverCalls int32
registry := newRegistry(t, config.OpenAIName, func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) {
atomic.AddInt32(&discoverCalls, 1)
return []config.ModelDescriptor{{ID: "fresh-model", Name: "Fresh Model"}}, nil
})

service := NewService("", registry, store)
service.now = func() time.Time { return now }

models, err := service.ListProviderModelsCached(context.Background(), config.OpenAIProvider())
if err != nil {
t.Fatalf("ListProviderModelsCached() error = %v", err)
}
if !containsModelDescriptorID(models, "cached-model") {
t.Fatalf("expected cached model to be returned, got %+v", models)
}
if atomic.LoadInt32(&discoverCalls) != 0 {
t.Fatalf("expected no discovery for fresh cache, got %d", discoverCalls)
}
}

func TestDiscoverAndPersistFailurePaths(t *testing.T) {
t.Run("unsupported driver", func(t *testing.T) {
service := NewService("", provider.NewRegistry(), newMemoryStore())
if discovered, ok := service.discoverAndPersist(context.Background(), config.OpenAIProvider()); ok || discovered != nil {
t.Fatalf("expected unsupported driver to skip discovery, got ok=%v models=%+v", ok, discovered)
}
})

t.Run("resolve provider config failure", func(t *testing.T) {
service := NewService("", newRegistry(t, config.OpenAIName, func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) {
return nil, nil
}), newMemoryStore())

providerCfg := config.ProviderConfig{
Name: "broken-openai",
Driver: config.OpenAIName,
BaseURL: config.OpenAIDefaultBaseURL,
Model: config.OpenAIDefaultModel,
APIKeyEnv: "",
}

if discovered, ok := service.discoverAndPersist(context.Background(), providerCfg); ok || discovered != nil {
t.Fatalf("expected resolve failure to skip discovery, got ok=%v models=%+v", ok, discovered)
}
})

t.Run("discovery error", func(t *testing.T) {
t.Setenv(testAPIKeyEnv, "test-key")
service := NewService("", newRegistry(t, config.OpenAIName, func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) {
return nil, errors.New("discover failed")
}), newMemoryStore())

if discovered, ok := service.discoverAndPersist(context.Background(), config.OpenAIProvider()); ok || discovered != nil {
t.Fatalf("expected discovery error to skip persistence, got ok=%v models=%+v", ok, discovered)
}
})

t.Run("store nil still returns discovered models", func(t *testing.T) {
t.Setenv(testAPIKeyEnv, "test-key")
service := NewService("", newRegistry(t, config.OpenAIName, func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) {
return []config.ModelDescriptor{{ID: "gpt-4.1", Name: "GPT-4.1"}}, nil
}), nil)

discovered, ok := service.discoverAndPersist(context.Background(), config.OpenAIProvider())
if !ok {
t.Fatal("expected discovery without store to succeed")
}
if !containsModelDescriptorID(discovered, "gpt-4.1") {
t.Fatalf("expected discovered model to be returned, got %+v", discovered)
}
})
}

func TestQueueRefreshDeduplicatesInFlightRequests(t *testing.T) {
t.Setenv(testAPIKeyEnv, "test-key")

identity, err := config.OpenAIProvider().Identity()
if err != nil {
t.Fatalf("Identity() error = %v", err)
}

started := make(chan struct{}, 1)
release := make(chan struct{})
var discoverCalls int32
registry := newRegistry(t, config.OpenAIName, func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) {
atomic.AddInt32(&discoverCalls, 1)
select {
case started <- struct{}{}:
default:
}
select {
case <-release:
case <-ctx.Done():
}
return []config.ModelDescriptor{{ID: "gpt-4o", Name: "GPT-4o"}}, nil
})

service := NewService("", registry, newMemoryStore())
service.backgroundTimeout = time.Second

service.queueRefresh(config.OpenAIProvider(), identity)
<-started
service.queueRefresh(config.OpenAIProvider(), identity)

time.Sleep(50 * time.Millisecond)
if calls := atomic.LoadInt32(&discoverCalls); calls != 1 {
t.Fatalf("expected exactly one in-flight refresh, got %d", calls)
}

close(release)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
service.refreshMu.Lock()
_, exists := service.inFlightByID[identity.Key()]
service.refreshMu.Unlock()
if !exists {
return
}
time.Sleep(20 * time.Millisecond)
}

t.Fatal("expected in-flight refresh marker to be cleared")
}

func newRegistry(t *testing.T, name string, discover provider.DiscoveryFunc) *provider.Registry {
t.Helper()

Expand Down Expand Up @@ -235,7 +421,7 @@ func containsModelDescriptorID(models []config.ModelDescriptor, modelID string)

type catalogTestProvider struct{}

func (catalogTestProvider) Chat(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error {
func (catalogTestProvider) Generate(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error {
return nil
}

Expand Down
Loading
Loading