feat: bootstrap supply intelligence baseline

This commit is contained in:
Your Name
2026-05-07 10:16:46 +08:00
commit afdbea6fb5
62 changed files with 9170 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
package integration
import (
"context"
"supply-intelligence/internal/domain"
)
// AccountStateReader defines the interface for reading account routing state
// from the supply-api repository layer
type AccountStateReader interface {
GetRoutingStateContext(ctx context.Context, accountID int64) (domain.AccountRoutingState, bool)
}
// CandidateStore defines the interface for persisting model candidates
type CandidateStore interface {
GetDiscoveryCandidateByIDContext(ctx context.Context, candidateID string) (domain.DiscoveryCandidate, bool)
FindDiscoveryCandidateContext(ctx context.Context, accountID int64, platform, model string) (domain.DiscoveryCandidate, bool)
UpsertDiscoveryCandidateContext(ctx context.Context, candidate domain.DiscoveryCandidate) domain.DiscoveryCandidate
ListDiscoveryCandidatesContext(ctx context.Context, status domain.DiscoveryCandidateStatus) []domain.DiscoveryCandidate
}
// PackageEventStore defines the interface for persisting package change events
type PackageEventStore interface {
AppendPackageEventContext(ctx context.Context, evt domain.PackageChangeEvent) (domain.PackageChangeEvent, error)
ListPackageEventsAfter(cursor string) ([]domain.PackageChangeEvent, string)
AckPackageEvent(eventID, consumer string, result domain.GatewayAckResult, detail string, ackedAt interface{}) (domain.PackageChangeEvent, error)
}
// ProbeLogStore defines the interface for persisting probe execution logs
type ProbeLogStore interface {
AppendProbeLog(ctx context.Context, log ProbeExecutionLog) error
ListProbeLogsByAccount(ctx context.Context, accountID int64, limit int) ([]ProbeExecutionLog, error)
}
// ProbeExecutionLog represents a single probe execution record
type ProbeExecutionLog struct {
LogID int64
AccountID int64
Platform string
ProbeResult domain.ProbeClassification
FailureClass string
HTTPStatus int
LatencyMs int
RiskScore int
EvaluatedTransition string
ExecutedAt interface{} // time.Time or string
RequestID string
Version int64
}
// NewAccountStateAdapter creates an adapter that connects to supply-api's account store
// For now, returns nil — actual implementation requires supply-api repo access
func NewAccountStateAdapter(repo interface{}) *AccountStateAdapter {
return &AccountStateAdapter{repo: repo}
}
// AccountStateAdapter implements AccountStateReader over supply-api repository
type AccountStateAdapter struct {
repo interface{}
}
func (a *AccountStateAdapter) GetRoutingStateContext(ctx context.Context, accountID int64) (domain.AccountRoutingState, bool) {
// TODO: implement when supply-api integration is ready
// This will call into supply-api's account repository
return domain.AccountRoutingState{}, false
}

View File

@@ -0,0 +1,242 @@
package integration
import (
"context"
"encoding/json"
"net/http"
)
// SupplierAdapter defines the interface for interacting with a supplier platform
type SupplierAdapter interface {
// Platform returns the platform name (e.g., "openai", "anthropic")
Platform() string
// ProbeAccount sends a health check request to the supplier API
// Returns the HTTP response details needed for probe classification
ProbeAccount(ctx context.Context, account SupplierAccount) ProbeResult
// GetModels fetches the list of available models from the supplier
GetModels(ctx context.Context, account SupplierAccount) ([]ModelInfo, error)
// HealthCheck verifies connectivity to the supplier API
HealthCheck(ctx context.Context, account SupplierAccount) error
}
// SupplierAccount holds credentials and configuration for a supplier account
type SupplierAccount struct {
AccountID int64
Platform string
APIKey string
BaseURL string // defaults to supplier's public endpoint if empty
Endpoint string // custom endpoint override
}
// ProbeResult holds the raw result of a probe request
type ProbeResult struct {
StatusCode int
TransportError error
ResponseBody string
}
// ModelInfo describes a model available from a supplier
type ModelInfo struct {
ModelID string // supplier's model identifier
ModelName string // display name
ContextLength int // max context length in tokens
IsActive bool // whether the model is currently available
}
// NewOpenAIAdapter creates an adapter for OpenAI-compatible APIs
func NewOpenAIAdapter(httpClient HTTPClient) SupplierAdapter {
return &OpenAIAdapter{httpClient: httpClient}
}
// OpenAIAdapter implements SupplierAdapter for OpenAI and OpenAI-compatible APIs
type OpenAIAdapter struct {
httpClient HTTPClient
}
func (a *OpenAIAdapter) Platform() string { return "openai" }
func (a *OpenAIAdapter) ProbeAccount(ctx context.Context, account SupplierAccount) ProbeResult {
baseURL := account.BaseURL
if baseURL == "" {
baseURL = "https://api.openai.com"
}
endpoint := account.Endpoint
if endpoint == "" {
endpoint = baseURL + "/v1/models"
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return ProbeResult{TransportError: err}
}
req.Header.Set("Authorization", "Bearer "+account.APIKey)
req.Header.Set("User-Agent", "supply-intelligence-probe/1.0")
resp, err := a.httpClient.Do(req)
if err != nil {
return ProbeResult{TransportError: err}
}
defer resp.Body.Close()
body := make([]byte, 1024)
n, _ := resp.Body.Read(body)
return ProbeResult{
StatusCode: resp.StatusCode,
ResponseBody: string(body[:n]),
}
}
func (a *OpenAIAdapter) GetModels(ctx context.Context, account SupplierAccount) ([]ModelInfo, error) {
baseURL := account.BaseURL
if baseURL == "" {
baseURL = "https://api.openai.com"
}
endpoint := baseURL + "/v1/models"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+account.APIKey)
resp, err := a.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// Parse the OpenAI models list response
// {"object": "list", "data": [{"id": "gpt-4", "object": "model", ...}, ...]}
var raw struct {
Data []struct {
ID string `json:"id"`
Object string `json:"object"`
Context int `json:"context_window,omitempty"`
} `json:"data"`
}
if err := decodeJSON(resp, &raw); err != nil {
return nil, err
}
models := make([]ModelInfo, 0, len(raw.Data))
for _, m := range raw.Data {
if m.Object == "model" {
models = append(models, ModelInfo{
ModelID: m.ID,
ModelName: m.ID,
ContextLength: m.Context,
IsActive: true,
})
}
}
return models, nil
}
func (a *OpenAIAdapter) HealthCheck(ctx context.Context, account SupplierAccount) error {
result := a.ProbeAccount(ctx, account)
if result.TransportError != nil {
return result.TransportError
}
if result.StatusCode == http.StatusOK || result.StatusCode == http.StatusUnauthorized {
return nil
}
return ErrHealthCheckFailed
}
// NewAnthropicAdapter creates an adapter for Anthropic APIs
func NewAnthropicAdapter(httpClient HTTPClient) SupplierAdapter {
return &AnthropicAdapter{httpClient: httpClient}
}
// AnthropicAdapter implements SupplierAdapter for Anthropic Claude API
type AnthropicAdapter struct {
httpClient HTTPClient
}
func (a *AnthropicAdapter) Platform() string { return "anthropic" }
func (a *AnthropicAdapter) ProbeAccount(ctx context.Context, account SupplierAccount) ProbeResult {
baseURL := account.BaseURL
if baseURL == "" {
baseURL = "https://api.anthropic.com"
}
endpoint := baseURL + "/v1/models"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return ProbeResult{TransportError: err}
}
req.Header.Set("x-api-key", account.APIKey)
req.Header.Set("User-Agent", "supply-intelligence-probe/1.0")
req.Header.Set("anthropic-version", "2023-06-01")
resp, err := a.httpClient.Do(req)
if err != nil {
return ProbeResult{TransportError: err}
}
defer resp.Body.Close()
body := make([]byte, 1024)
n, _ := resp.Body.Read(body)
return ProbeResult{
StatusCode: resp.StatusCode,
ResponseBody: string(body[:n]),
}
}
func (a *AnthropicAdapter) GetModels(ctx context.Context, account SupplierAccount) ([]ModelInfo, error) {
// Anthropic doesn't have a public models list endpoint in the same way OpenAI does.
// We return a known static list for Claude models.
// In production this would be fetched from configuration or a dynamic source.
return []ModelInfo{
{ModelID: "claude-3-5-sonnet-20241022", ModelName: "Claude 3.5 Sonnet", ContextLength: 200000, IsActive: true},
{ModelID: "claude-3-5-haiku-20241022", ModelName: "Claude 3.5 Haiku", ContextLength: 200000, IsActive: true},
{ModelID: "claude-3-opus-20240229", ModelName: "Claude 3 Opus", ContextLength: 200000, IsActive: true},
{ModelID: "claude-3-sonnet-20240229", ModelName: "Claude 3 Sonnet", ContextLength: 200000, IsActive: true},
{ModelID: "claude-3-haiku-20240307", ModelName: "Claude 3 Haiku", ContextLength: 200000, IsActive: true},
}, nil
}
func (a *AnthropicAdapter) HealthCheck(ctx context.Context, account SupplierAccount) error {
result := a.ProbeAccount(ctx, account)
if result.TransportError != nil {
return result.TransportError
}
// Anthropic returns 200 on success, 401 on auth failure
if result.StatusCode == http.StatusOK || result.StatusCode == http.StatusUnauthorized {
return nil
}
return ErrHealthCheckFailed
}
// HTTPClient interface for testability
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
// DefaultHTTPClient is the standard HTTP client used for platform adapters
type DefaultHTTPClient struct{}
func (c *DefaultHTTPClient) Do(req *http.Request) (*http.Response, error) {
return http.DefaultClient.Do(req)
}
// NewDefaultHTTPClient creates a new default HTTP client
func NewDefaultHTTPClient() HTTPClient {
return &DefaultHTTPClient{}
}
var ErrHealthCheckFailed = &HealthCheckError{}
type HealthCheckError struct{}
func (e *HealthCheckError) Error() string { return "health check failed" }
func decodeJSON(resp *http.Response, v interface{}) error {
return json.NewDecoder(resp.Body).Decode(v)
}