feat: bootstrap supply intelligence baseline
This commit is contained in:
67
internal/integration/adapter.go
Normal file
67
internal/integration/adapter.go
Normal 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
|
||||
}
|
||||
242
internal/integration/platform.go
Normal file
242
internal/integration/platform.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user