diff --git a/components/ambient-sdk/README.md b/components/ambient-sdk/README.md new file mode 100644 index 000000000..56c7b882e --- /dev/null +++ b/components/ambient-sdk/README.md @@ -0,0 +1,230 @@ +# Ambient Platform SDK + +**SDK for external developers integrating with the Ambient Platform.** + +## Overview + +This SDK provides a simple, HTTP-only client for interacting with the Ambient Code Platform via its public REST API. The SDK is designed for external developers who want to integrate AI agent capabilities into their applications without Kubernetes dependencies. + +## Design Philosophy + +The Ambient Platform SDK follows these core principles: +- **REST API**: Pure REST API client with no Kubernetes dependencies +- **Minimal Dependencies**: Uses only Go standard library +- **Simple Integration**: Easy to embed in any Go application +- **Type Safety**: Strongly-typed request and response structures +- **Clear Separation**: Public SDK vs internal platform implementation + +## Quick Start + +### Installation + +```bash +go get github.com/ambient/platform-sdk +``` + +### Basic Usage + +```go +package main + +import ( + "context" + "log" + "time" + + "github.com/ambient/platform-sdk/client" + "github.com/ambient/platform-sdk/types" +) + +func main() { + // Create HTTP client + client := client.NewClient("https://your-platform.example.com", "your-bearer-token", "your-project") + + // Create a new session + req := &types.CreateSessionRequest{ + Task: "Analyze this repository structure", + Model: "claude-3.5-sonnet", + Repos: []types.RepoHTTP{{ + URL: "https://github.com/user/repo", + Branch: "main", + }}, + } + + resp, err := client.CreateSession(context.Background(), req) + if err != nil { + log.Fatal(err) + } + + // Monitor session progress + session, err := client.WaitForCompletion( + context.Background(), + resp.ID, + 5*time.Second, + ) + if err != nil { + log.Fatal(err) + } + + log.Printf("Session completed: %s", session.Status) +} +``` + +## Architecture + +### Public SDK (This Repository) +- **HTTP Client**: Simple REST API client for session management +- **Type Safety**: Request/response types matching public API +- **Zero K8s Dependencies**: Pure Go standard library implementation + +### Internal Platform Usage +- **Backend**: Can import `types.internal_types` for Kubernetes struct compatibility +- **Operator**: Continues using existing Kubernetes client patterns +- **Shared Types**: Common type definitions support both public and internal usage + +## Implementation Status + +### Phase 1: HTTP-Only SDK ✅ +- [x] HTTP client with AgenticSession management +- [x] Type-safe request/response handling +- [x] Bearer token authentication +- [x] Session status polling and monitoring +- [x] Comprehensive examples and documentation +- [x] Zero Kubernetes dependencies + +### Phase 2: Frontend Integration +- [ ] Generate TypeScript types from OpenAPI +- [ ] Create TypeScript SDK with React Query integration +- [ ] Migrate frontend to use generated types +- [ ] Replace manual `fetch()` calls with SDK client + +### Phase 3: Python SDK for External Users +- [ ] Generate Pydantic models from OpenAPI +- [ ] Create Python client SDK with async support +- [ ] Add authentication (API key + kubeconfig) +- [ ] Implement real-time session monitoring + +### Phase 4: Advanced Features +- [ ] SDK-based testing utilities +- [ ] Cross-language validation rules +- [ ] Automatic type migration tools +- [ ] OpenTelemetry instrumentation + +## Directory Structure + +``` +ambient-sdk/ +├── README.md # This file +├── openapi.yaml # Canonical API specification +├── go-sdk/ # Go client library +│ ├── types/ # Generated Go types +│ ├── client/ # K8s client utilities +│ └── examples/ # Usage examples +├── python-sdk/ # Python client library +│ ├── ambient_platform/ # Generated Pydantic models +│ ├── client/ # HTTP client implementation +│ └── examples/ # Usage examples +└── typescript-sdk/ # TypeScript client library (future) + ├── types/ # Generated TypeScript types + ├── client/ # React Query integration + └── examples/ # Usage examples +``` + +## Benefits by Component + +### Backend (`components/backend/`) +**Before**: Manual JSON parsing with type assertions +```go +if timeout, ok := spec["timeout"].(float64); ok { + result.Timeout = int(timeout) +} +``` + +**After**: Type-safe operations +```go +import "github.com/ambient/platform-sdk/types" + +session := types.AgenticSession{} +// Compile-time type safety, automatic validation +``` + +### Operator (`components/operator/`) +**Before**: Fragile unstructured access +```go +spec, found, err := unstructured.NestedMap(obj.Object, "spec") +displayName := spec["displayName"].(string) // Can panic! +``` + +**After**: Type-safe field access +```go +session, err := sdk.FromUnstructured(obj) +displayName := session.Spec.DisplayName // Type-safe +``` + +### Frontend (`components/frontend/`) +**Before**: Manual type synchronization +```typescript +// Types drift from backend changes +export type AgenticSession = { /* manually maintained */ } +``` + +**After**: Generated types +```typescript +import { AgenticSession } from '@ambient/platform-types' +// Always in sync with API +``` + +### Python SDK (New) +**Target**: External automation users +```python +from ambient_platform_sdk import AmbientClient + +client = AmbientClient.from_env() +session = await client.sessions.create( + task="Review PR #123 for security vulnerabilities", + model="claude-4-5-sonnet", + repos=["github.com/myorg/myrepo"] +) + +# Real-time monitoring +async for update in session.watch(): + if update.status.phase == "Completed": + print(f"Session completed: {update.status}") + break +``` + +## Migration Strategy + +1. **Backward Compatibility**: Existing APIs remain unchanged +2. **Gradual Adoption**: Components migrate incrementally +3. **Type Safety**: Compile-time guarantees prevent regressions +4. **Automated Testing**: SDK includes comprehensive test suites + +## OpenAPI Design Principles + +- **Resource-First**: Model after Kubernetes CRDs +- **Versioning**: Support v1alpha1 → v1beta1 → v1 progression +- **Validation**: Rich JSON Schema constraints +- **Documentation**: Complete field descriptions and examples +- **Extensibility**: Support for custom fields and annotations + +## Getting Started + +### For Backend/Operator Development +```bash +cd components/ambient-sdk/go-sdk +go mod init github.com/ambient/platform-sdk +``` + +### For Python Automation +```bash +pip install ambient-platform-sdk +export AMBIENT_API_KEY="your-key" +``` + +### For Frontend Development +```bash +npm install @ambient/platform-types @ambient/platform-sdk +``` + +This SDK establishes the Ambient Platform as a cohesive system with shared types, eliminating the manual synchronization burden while providing rich, language-idiomatic client libraries. \ No newline at end of file diff --git a/components/ambient-sdk/go-sdk/README.md b/components/ambient-sdk/go-sdk/README.md new file mode 100644 index 000000000..98dfc2c1f --- /dev/null +++ b/components/ambient-sdk/go-sdk/README.md @@ -0,0 +1,398 @@ +# Ambient Platform Go SDK + +Simple HTTP client library for the Ambient Code Platform - Create and manage AI agent sessions without Kubernetes complexity. + +## Installation + +```bash +go get github.com/ambient/platform-sdk +``` + +## Quick Start + +```go +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/ambient/platform-sdk/client" + "github.com/ambient/platform-sdk/types" +) + +func main() { + // Create HTTP client + apiURL := "https://your-platform.example.com" + token := os.Getenv("AMBIENT_TOKEN") // Bearer token + project := os.Getenv("AMBIENT_PROJECT") // Project namespace + + client := client.NewClient(apiURL, token, project) + + // Create a session + createReq := &types.CreateSessionRequest{ + Task: "Analyze the repository structure and provide a summary", + Model: "claude-3.5-sonnet", + Repos: []types.RepoHTTP{ + { + URL: "https://github.com/ambient-code/platform", + Branch: "main", + }, + }, + } + + resp, err := client.CreateSession(context.Background(), createReq) + if err != nil { + log.Fatalf("Failed to create session: %v", err) + } + + fmt.Printf("Created session: %s\n", resp.ID) + + // Get session details + session, err := client.GetSession(context.Background(), resp.ID) + if err != nil { + log.Fatalf("Failed to get session: %v", err) + } + + fmt.Printf("Status: %s\n", session.Status) + + // List all sessions + listResp, err := client.ListSessions(context.Background()) + if err != nil { + log.Fatalf("Failed to list sessions: %v", err) + } + + fmt.Printf("Found %d sessions\n", len(listResp.Items)) +} +``` + +## Authentication & Authorization + +The SDK uses Bearer token authentication with project-scoped authorization: + +### Token Requirements + +- **Bearer Token**: Must be a valid authentication token (OpenShift, JWT, or GitHub format) +- **Project Header**: `X-Ambient-Project` specifies the target Kubernetes namespace +- **RBAC**: User must have appropriate permissions in the target namespace + +### Supported Token Formats + +- **OpenShift**: `sha256~...` format tokens from `oc whoami -t` +- **JWT**: Standard JSON Web Tokens with 3 base64 parts +- **GitHub**: Tokens starting with `ghp_`, `gho_`, `ghu_`, or `ghs_` + +### Required Permissions + +Your user account must have these Kubernetes RBAC permissions in the target project/namespace: + +```yaml +# Minimum required permissions +- apiGroups: ["vteam.ambient-code"] + resources: ["agenticsessions"] + verbs: ["get", "list", "create"] + +- apiGroups: [""] + resources: ["namespaces"] + verbs: ["get"] +``` + +### Common Permission Errors + +**403 Forbidden**: +```bash +# Check your permissions +oc auth can-i create agenticsessions.vteam.ambient-code -n your-project +oc auth can-i list agenticsessions.vteam.ambient-code -n your-project +``` + +**401 Unauthorized**: +```bash +# Check token validity +oc whoami # Should return your username +oc whoami -t # Should return a token starting with sha256~ +``` + +**400 Bad Request - Project required**: +- Ensure `AMBIENT_PROJECT` environment variable is set +- Project must be a valid Kubernetes namespace name +- User must have access to the specified project + +```bash +# Set environment variables +export AMBIENT_TOKEN="your-bearer-token" # Required +export AMBIENT_PROJECT="your-project-name" # Required +export AMBIENT_API_URL="https://your-api.com" # Optional +``` + +**OpenShift Users:** +```bash +# Use your OpenShift token +export AMBIENT_TOKEN="$(oc whoami -t)" +export AMBIENT_PROJECT="$(oc project -q)" +``` + +## Core Operations + +### Create Session + +```go +createReq := &types.CreateSessionRequest{ + Task: "Review this code for security issues", + Model: "claude-3.5-sonnet", // Optional, uses platform default if omitted + Repos: []types.RepoHTTP{ + {URL: "https://github.com/user/repo", Branch: "main"}, + }, +} + +resp, err := client.CreateSession(ctx, createReq) +``` + +### Get Session Details + +```go +session, err := client.GetSession(ctx, "session-1234567") +if err != nil { + log.Printf("Session error: %v", err) +} + +fmt.Printf("Status: %s\n", session.Status) +if session.Status == types.StatusCompleted { + fmt.Printf("Result: %s\n", session.Result) +} +``` + +### List Sessions + +```go +listResp, err := client.ListSessions(ctx) +if err != nil { + return err +} + +for _, session := range listResp.Items { + fmt.Printf("- %s (%s): %s\n", session.ID, session.Status, session.Task) +} +``` + +### Monitor Session Completion + +```go +// Wait for session to complete +completed, err := client.WaitForCompletion(ctx, sessionID, 5*time.Second) +if err != nil { + return fmt.Errorf("monitoring failed: %w", err) +} + +if completed.Status == types.StatusCompleted { + fmt.Printf("Success: %s\n", completed.Result) +} else { + fmt.Printf("Failed: %s\n", completed.Error) +} +``` + +## Session Status Values + +```go +const ( + StatusPending = "pending" // Session created, waiting to start + StatusRunning = "running" // AI agent actively working + StatusCompleted = "completed" // Task finished successfully + StatusFailed = "failed" // Task failed with error +) +``` + +## Configuration Options + +### Custom Timeout + +```go +client := client.NewClientWithTimeout(apiURL, token, project, 60*time.Second) +``` + +### Error Handling + +```go +session, err := client.GetSession(ctx, sessionID) +if err != nil { + // Detailed error messages include HTTP status and API responses + log.Printf("Failed: %v", err) + // Example: "API error (404): session not found: session-xyz" +} +``` + +## Examples + +See the `examples/` directory for complete working examples: + +- **`main.go`** - Complete session lifecycle demonstration +- **`README.md`** - Detailed usage guide with troubleshooting + +## API Reference + +### Client Methods + +```go +// Client creation +func NewClient(baseURL, token, project string) *Client +func NewClientWithTimeout(baseURL, token, project string, timeout time.Duration) *Client + +// Session operations +func (c *Client) CreateSession(ctx context.Context, req *CreateSessionRequest) (*CreateSessionResponse, error) +func (c *Client) GetSession(ctx context.Context, sessionID string) (*SessionResponse, error) +func (c *Client) ListSessions(ctx context.Context) (*SessionListResponse, error) +func (c *Client) WaitForCompletion(ctx context.Context, sessionID string, pollInterval time.Duration) (*SessionResponse, error) +``` + +### Types + +```go +// Request types +type CreateSessionRequest struct { + Task string `json:"task"` + Model string `json:"model,omitempty"` + Repos []RepoHTTP `json:"repos,omitempty"` +} + +type RepoHTTP struct { + URL string `json:"url"` + Branch string `json:"branch,omitempty"` +} + +// Response types +type SessionResponse struct { + ID string `json:"id"` + Status string `json:"status"` + Task string `json:"task"` + Model string `json:"model,omitempty"` + CreatedAt string `json:"createdAt"` + CompletedAt string `json:"completedAt,omitempty"` + Result string `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +type SessionListResponse struct { + Items []SessionResponse `json:"items"` + Total int `json:"total"` +} + +type CreateSessionResponse struct { + ID string `json:"id"` + Message string `json:"message"` +} + +type ErrorResponse struct { + Error string `json:"error"` + Message string `json:"message,omitempty"` +} +``` + +## Architecture + +### Design Principles + +- **HTTP-First**: Pure REST API client with no Kubernetes dependencies +- **Minimal Dependencies**: Uses only Go standard library +- **Simple Integration**: Easy to embed in any Go application +- **Type Safety**: Strongly-typed requests and responses with compile-time validation +- **Clear Separation**: Public SDK vs internal platform implementation + +### HTTP vs Kubernetes + +This SDK provides a **simplified HTTP interface** to the Ambient Platform: + +| Aspect | HTTP SDK (This Package) | Internal Platform | +|--------|------------------------|-------------------| +| **API** | Simple REST endpoints (`/v1/sessions`) | Complex Kubernetes CRDs | +| **Auth** | Bearer token + project header | RBAC + service accounts | +| **Types** | Flat JSON structs | Full K8s metadata/spec/status | +| **Usage** | Any HTTP client, any environment | Kubernetes cluster access required | +| **Target** | External integrators, simple automation | Internal platform components | + +### Internal vs Public + +- **Backend Components**: Can use internal Kubernetes types for cluster operations +- **SDK Users**: Get simplified HTTP API without Kubernetes complexity +- **Type Definitions**: Shared between internal and public usage where appropriate + +## Migration from Kubernetes SDK + +If migrating from a previous Kubernetes-based version: + +### Before (Kubernetes) +```go +import "k8s.io/client-go/kubernetes" + +client, err := sdk.NewClientFromKubeconfig("") +session := &types.AgenticSession{/* complex K8s structure */} +created, err := client.Sessions.Create(ctx, session) +``` + +### After (HTTP) +```go +import "github.com/ambient/platform-sdk/client" + +client := client.NewClient(apiURL, token, project) +req := &types.CreateSessionRequest{Task: "...", Model: "..."} +resp, err := client.CreateSession(ctx, req) +``` + +## Troubleshooting + +### Authentication Issues +``` +❌ AMBIENT_TOKEN environment variable is required +``` +**Solution**: Set your Bearer token: `export AMBIENT_TOKEN="your-token"` + +### Project Header Missing +``` +API error (400): Project required. Set X-Ambient-Project header +``` +**Solution**: Set project name: `export AMBIENT_PROJECT="your-project"` + +### Connection Errors +``` +Failed to execute request: dial tcp: connection refused +``` +**Solution**: Verify API endpoint and network connectivity + +### Session Not Found +``` +API error (404): session not found: session-xyz +``` +**Solution**: Verify session ID and check if you have access to the project + +## Testing + +```bash +go test ./... +``` + +Run the complete example: +```bash +cd examples/ +export AMBIENT_TOKEN="your-token" +export AMBIENT_PROJECT="your-project" +go run main.go +``` + +## OpenAPI Specification + +This SDK is built to match the OpenAPI specification in `../openapi.yaml`. The specification defines: + +- **Endpoints**: `/v1/sessions` (create, list, get) +- **Authentication**: Bearer token + X-Ambient-Project header +- **Request/Response**: JSON types matching this SDK +- **Error Handling**: Structured error responses with HTTP status codes + +## Contributing + +1. **SDK Changes**: Modify code in `client/` or `types/` directories +2. **API Changes**: Update `../openapi.yaml` specification first +3. **Examples**: Add working examples to `examples/` directory +4. **Testing**: Ensure all changes work with real API endpoints + +For complete platform documentation, see the main [platform repository](https://github.com/ambient-code/platform). \ No newline at end of file diff --git a/components/ambient-sdk/go-sdk/client/client.go b/components/ambient-sdk/go-sdk/client/client.go new file mode 100644 index 000000000..6def266a0 --- /dev/null +++ b/components/ambient-sdk/go-sdk/client/client.go @@ -0,0 +1,293 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "strings" + "time" + + "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" +) + +// Client is a simple HTTP client for the Ambient Platform API +type Client struct { + baseURL string + token types.SecureToken + project string + httpClient *http.Client + logger *slog.Logger +} + +// NewClient creates a new HTTP client for the Ambient Platform +// Returns an error if token validation fails +func NewClient(baseURL, token, project string) (*Client, error) { + return NewClientWithTimeout(baseURL, token, project, 30*time.Second) +} + +// NewClientWithTimeout creates a new HTTP client with custom timeout +// Returns an error if token validation fails +func NewClientWithTimeout(baseURL, token, project string, timeout time.Duration) (*Client, error) { + secureToken := types.SecureToken(token) + if err := secureToken.IsValid(); err != nil { + return nil, fmt.Errorf("invalid token: %w", err) + } + + // Create logger with ReplaceAttr for additional sensitive data protection + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + ReplaceAttr: sanitizeLogAttrs, + })) + + client := &Client{ + baseURL: baseURL, + token: secureToken, + project: project, + httpClient: &http.Client{ + Timeout: timeout, + }, + logger: logger, + } + + // Log client creation (without any sensitive data) + client.logger.Info("Ambient client created", + "base_url", baseURL, + "project", project, + "timeout", timeout) + + return client, nil +} + +// CreateSession creates a new agentic session +func (c *Client) CreateSession(ctx context.Context, req *types.CreateSessionRequest) (*types.CreateSessionResponse, error) { + // Validate the request first + if err := req.Validate(); err != nil { + c.logger.Error("Session creation failed validation", "error", err) + return nil, fmt.Errorf("invalid request: %w", err) + } + + c.logger.Info("Creating session", + "task_length", len(req.Task), + "model", req.Model, + "repo_count", len(req.Repos)) + + jsonBody, err := json.Marshal(req) + if err != nil { + c.logger.Error("Failed to marshal request", "error", err) + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + url := c.baseURL + "/v1/sessions" + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(jsonBody)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+c.token.String()) + httpReq.Header.Set("X-Ambient-Project", c.project) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + c.logger.Error("HTTP request failed", "error", err, "url", url) + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + c.logger.Error("Failed to read response body", "error", err) + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode != http.StatusCreated { + c.logger.Error("Session creation failed", + "status_code", resp.StatusCode, + "response_body_length", len(body)) + + var errResp types.ErrorResponse + if json.Unmarshal(body, &errResp) == nil { + // Log full error details for debugging (sanitized by slog) + c.logger.Debug("API error details", "error", errResp.Error, "message", errResp.Message) + // Return generic error message to avoid exposing sensitive details + return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, errResp.Error) + } + // Don't expose raw response body - return generic error + return nil, fmt.Errorf("API error (%d): request failed", resp.StatusCode) + } + + var createResp types.CreateSessionResponse + if err := json.Unmarshal(body, &createResp); err != nil { + c.logger.Error("Failed to unmarshal response", "error", err) + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + c.logger.Info("Session created successfully", + "session_id", createResp.ID, + "status_code", resp.StatusCode) + + return &createResp, nil +} + +// GetSession retrieves a session by ID +func (c *Client) GetSession(ctx context.Context, sessionID string) (*types.SessionResponse, error) { + url := c.baseURL + "/v1/sessions/" + sessionID + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Authorization", "Bearer "+c.token.String()) + httpReq.Header.Set("X-Ambient-Project", c.project) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("session not found: %s", sessionID) + } + + if resp.StatusCode != http.StatusOK { + var errResp types.ErrorResponse + if json.Unmarshal(body, &errResp) == nil { + // Return generic error without exposing full details + return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, errResp.Error) + } + return nil, fmt.Errorf("API error (%d): request failed", resp.StatusCode) + } + + var session types.SessionResponse + if err := json.Unmarshal(body, &session); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &session, nil +} + +// ListSessions lists all sessions +func (c *Client) ListSessions(ctx context.Context) (*types.SessionListResponse, error) { + url := c.baseURL + "/v1/sessions" + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Authorization", "Bearer "+c.token.String()) + httpReq.Header.Set("X-Ambient-Project", c.project) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + var errResp types.ErrorResponse + if json.Unmarshal(body, &errResp) == nil { + // Return generic error without exposing full details + return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, errResp.Error) + } + return nil, fmt.Errorf("API error (%d): request failed", resp.StatusCode) + } + + var listResp types.SessionListResponse + if err := json.Unmarshal(body, &listResp); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &listResp, nil +} + +// WaitForCompletion polls a session until it reaches a terminal state +func (c *Client) WaitForCompletion(ctx context.Context, sessionID string, pollInterval time.Duration) (*types.SessionResponse, error) { + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + session, err := c.GetSession(ctx, sessionID) + if err != nil { + return nil, err + } + + switch session.Status { + case types.StatusCompleted, types.StatusFailed: + return session, nil + case types.StatusPending, types.StatusRunning: + // Continue polling + continue + default: + return nil, fmt.Errorf("unknown session status: %s", session.Status) + } + } + } +} + +// sanitizeLogAttrs implements the ReplaceAttr approach for slog +// This provides a global safety net for any sensitive data that might be logged +func sanitizeLogAttrs(_ []string, attr slog.Attr) slog.Attr { + key := attr.Key + value := attr.Value + + // Sanitize by exact key name (case-sensitive for precision) + switch key { + case "token", "Token", "TOKEN": + return slog.String(key, "[REDACTED]") + case "password", "Password", "PASSWORD": + return slog.String(key, "[REDACTED]") + case "secret", "Secret", "SECRET": + return slog.String(key, "[REDACTED]") + case "apikey", "api_key", "ApiKey", "API_KEY": + return slog.String(key, "[REDACTED]") + case "authorization", "Authorization", "AUTHORIZATION": + return slog.String(key, "[REDACTED]") + } + + // Sanitize by key patterns (case-insensitive) + keyLower := strings.ToLower(key) + if strings.HasSuffix(keyLower, "_token") || strings.HasSuffix(keyLower, "_password") || + strings.HasSuffix(keyLower, "_secret") || strings.HasSuffix(keyLower, "_key") { + return slog.String(key, "[REDACTED]") + } + + // Sanitize by value content ONLY for obvious token patterns + if value.Kind() == slog.KindString { + str := value.String() + // Only redact clear Bearer token patterns + if strings.HasPrefix(str, "Bearer ") || strings.HasPrefix(str, "bearer ") { + return slog.String(key, "[REDACTED_BEARER]") + } + // Only redact SHA256 tokens (common OpenShift token format) + if strings.HasPrefix(str, "sha256~") { + return slog.String(key, "[REDACTED_SHA256_TOKEN]") + } + // Only redact JWT patterns (starts with ey and contains dots) + if strings.HasPrefix(str, "ey") && strings.Count(str, ".") >= 2 && len(str) > 50 { + return slog.String(key, "[REDACTED_JWT]") + } + } + + return attr +} \ No newline at end of file diff --git a/components/ambient-sdk/go-sdk/examples/README.md b/components/ambient-sdk/go-sdk/examples/README.md new file mode 100644 index 000000000..26b37ac52 --- /dev/null +++ b/components/ambient-sdk/go-sdk/examples/README.md @@ -0,0 +1,255 @@ +# Ambient Platform Go SDK Examples + +This directory contains examples demonstrating how to use the Ambient Platform Go SDK to interact with the platform via HTTP API. + +## Overview + +The Ambient Platform SDK provides a simple HTTP client for creating and managing AI agent sessions through the platform's public REST API. No Kubernetes dependencies are required - just HTTP and Bearer token authentication. + +## Quick Start + +### Prerequisites + +1. **Ambient Platform**: Deployed and accessible via HTTP +2. **Bearer Token**: Valid authentication token for API access +3. **Go 1.21+**: For running the examples + +### Environment Setup + +Set the required environment variables: + +```bash +# Required: Your API authentication token +export AMBIENT_TOKEN="your-bearer-token-here" + +# Required: Project name (Kubernetes namespace) +export AMBIENT_PROJECT="your-project-name" + +# Optional: API endpoint (defaults to working public API URL) +export AMBIENT_API_URL="https://your-platform.example.com" + +# Optional: Monitor session completion (defaults to false) +export MONITOR_SESSION="true" +``` + +### Running the Examples + +```bash +# Run the HTTP client example (recommended) +go run main.go + +# Legacy Kubernetes example (deprecated - requires k8s access) +go run kubernetes_main.go +``` + +The HTTP example demonstrates: +- ✅ Creating a new agentic session +- ✅ Retrieving session details by ID +- ✅ Listing all accessible sessions +- ✅ Optional session completion monitoring + +## Example Output + +``` +🌐 Ambient Platform SDK - HTTP Client Example +============================================ +✓ Created client for API: https://public-api-route-mturansk.apps.rosa.xezue-pjejw-oy9.ag90.p3.openshiftapps.com +✓ Using project: mturansk + +📝 Creating new session... +✓ Created session: session-1771013589 + +🔍 Getting session details... + ID: session-1771013589 + Status: pending + Task: Analyze the repository structure and provide a brief summary... + Model: claude-3.5-sonnet + Created: 2026-02-13T20:13:09Z + +📋 Listing all sessions... +✓ Found 2 sessions (total: 2) + 1. session-1771013589 (pending) - Analyze the repository structure and... + 2. example-session (pending) - + +✅ HTTP Client demonstration complete! +``` + +## Key Features + +### Simple HTTP Client +- Pure REST API integration with no Kubernetes dependencies +- Bearer token authentication with project routing +- JSON request/response handling +- Proper error handling with detailed messages + +### Session Management +- **Create**: Submit new tasks to AI agents +- **Retrieve**: Get session details and status +- **List**: Browse all accessible sessions +- **Monitor**: Poll for session completion + +### Type Safety +- Strongly-typed request and response structures +- Compile-time validation of API payloads +- Clear error messages for debugging + +## API Reference + +### Client Creation + +```go +import "github.com/ambient/platform-sdk/client" + +// Basic client +client := client.NewClient(apiURL, token, project) + +// Client with custom timeout +client := client.NewClientWithTimeout(apiURL, token, project, 60*time.Second) +``` + +### Session Operations + +```go +// Create session +createReq := &types.CreateSessionRequest{ + Task: "Analyze this repository", + Model: "claude-3.5-sonnet", + Repos: []types.RepoHTTP{{URL: "https://github.com/user/repo"}}, +} +resp, err := client.CreateSession(ctx, createReq) + +// Get session +session, err := client.GetSession(ctx, sessionID) + +// List sessions +list, err := client.ListSessions(ctx) + +// Wait for completion +completed, err := client.WaitForCompletion(ctx, sessionID, 5*time.Second) +``` + +## Working Configuration (Tested) + +The following configuration has been tested and verified working: + +```bash +# Tested working configuration +export AMBIENT_API_URL="https://public-api-route-mturansk.apps.rosa.xezue-pjejw-oy9.ag90.p3.openshiftapps.com" +export AMBIENT_TOKEN="$(oc whoami -t)" # OpenShift token +export AMBIENT_PROJECT="mturansk" # Valid namespace +export MONITOR_SESSION="true" # Enable completion monitoring + +go run main.go +``` + +## Error Handling + +The SDK provides detailed error information: + +```go +session, err := client.GetSession(ctx, "invalid-id") +if err != nil { + // Errors include HTTP status codes and API error messages + log.Printf("Failed to get session: %v", err) + // Example: "API error (404): session not found: invalid-id" +} +``` + +## Troubleshooting + +### Authentication Issues +``` +❌ AMBIENT_TOKEN environment variable is required +``` +**Solution**: Set your Bearer token in the `AMBIENT_TOKEN` environment variable. + +### Project Header Missing +``` +API error (400): Project required. Set X-Ambient-Project header... +``` +**Solution**: Set the `AMBIENT_PROJECT` environment variable to a valid namespace. + +### Connection Errors +``` +Failed to execute request: dial tcp: connection refused +``` +**Solution**: Verify the API endpoint is correct and accessible. Check `AMBIENT_API_URL`. + +### API Errors +``` +API error (401): Unauthorized - Invalid or expired token +``` +**Solution**: Verify your Bearer token is valid and has appropriate permissions. + +### Session Not Found +``` +API error (404): session not found: session-xyz +``` +**Solution**: Verify the session ID exists and you have access to it. + +## Migration from Legacy Example + +If you're migrating from the previous Kubernetes-based example: + +### Old (Kubernetes) +```go +import "k8s.io/client-go/kubernetes" + +client, err := client.NewClientFromKubeconfig("") +session, err := client.Sessions.Create(ctx, agenticSession) +``` + +### New (HTTP) +```go +import "github.com/ambient/platform-sdk/client" + +client := client.NewClient(apiURL, token, project) +resp, err := client.CreateSession(ctx, sessionRequest) +``` + +## Architecture Notes + +### Design Principles +- **HTTP-First**: Pure REST API client with no Kubernetes dependencies +- **Minimal Dependencies**: Uses only Go standard library +- **Simple Integration**: Easy to embed in any Go application +- **Clear Separation**: Public SDK vs internal platform implementation + +### Internal vs Public +- **Backend**: Can continue using strongly-typed Kubernetes structs for internal operations +- **SDK**: Exposes only HTTP API functionality for external integrators +- **Types**: Shared type definitions support both internal and public usage + +## Session Monitoring + +The SDK supports three approaches to session completion monitoring: + +1. **Simple Polling** (Implemented): + ```go + session, err := client.WaitForCompletion(ctx, sessionID, 5*time.Second) + ``` + +2. **Manual Status Checking**: + ```go + for { + session, err := client.GetSession(ctx, sessionID) + if session.Status == types.StatusCompleted || session.Status == types.StatusFailed { + break + } + time.Sleep(5 * time.Second) + } + ``` + +3. **Future: WebSocket/SSE** (Planned): + - Real-time status updates + - Progress streaming + - Event notifications + +## Next Steps + +1. **Explore Advanced Features**: Session monitoring, batch operations +2. **Integration Testing**: Test with your actual platform deployment +3. **Error Handling**: Implement retry logic and circuit breakers +4. **Observability**: Add logging and metrics for production usage + +For complete API documentation, see the [public API reference](../../public-api/README.md). \ No newline at end of file diff --git a/components/ambient-sdk/go-sdk/examples/go.mod b/components/ambient-sdk/go-sdk/examples/go.mod new file mode 100644 index 000000000..bd0838b47 --- /dev/null +++ b/components/ambient-sdk/go-sdk/examples/go.mod @@ -0,0 +1,7 @@ +module github.com/ambient-code/platform/components/ambient-sdk/go-sdk/examples + +go 1.21 + +replace github.com/ambient-code/platform/components/ambient-sdk/go-sdk => ../ + +require github.com/ambient-code/platform/components/ambient-sdk/go-sdk v0.0.0-00010101000000-000000000000 diff --git a/components/ambient-sdk/go-sdk/examples/go.sum b/components/ambient-sdk/go-sdk/examples/go.sum new file mode 100644 index 000000000..e69de29bb diff --git a/components/ambient-sdk/go-sdk/examples/main.go b/components/ambient-sdk/go-sdk/examples/main.go new file mode 100644 index 000000000..4f4f2560c --- /dev/null +++ b/components/ambient-sdk/go-sdk/examples/main.go @@ -0,0 +1,155 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "time" + + "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/client" + "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" +) + +const ( + // Default API endpoint for local development + defaultAPIURL = "http://localhost:8080" + + // Example session configuration + exampleTask = "Analyze the repository structure and provide a brief summary of the codebase organization." + exampleModel = "claude-3.5-sonnet" +) + +func main() { + fmt.Println("🌐 Ambient Platform SDK - HTTP Client Example") + fmt.Println("============================================") + + // Get configuration from environment or use defaults + apiURL := getEnvOrDefault("AMBIENT_API_URL", defaultAPIURL) + token := getEnvOrDefault("AMBIENT_TOKEN", "") + project := getEnvOrDefault("AMBIENT_PROJECT", "mturansk") + + if token == "" { + log.Fatal("❌ AMBIENT_TOKEN environment variable is required") + } + + // Create HTTP client + client, err := client.NewClientWithTimeout(apiURL, token, project, 60*time.Second) + if err != nil { + log.Fatalf("Failed to create client: %v", err) + } + fmt.Printf("✓ Created client for API: %s\n", apiURL) + fmt.Printf("✓ Using project: %s\n", project) + + ctx := context.Background() + + // Example 1: Create a new session + fmt.Println("\n📝 Creating new session...") + createReq := &types.CreateSessionRequest{ + Task: exampleTask, + Model: exampleModel, + Repos: []types.RepoHTTP{ + { + URL: "https://github.com/ambient-code/platform", + Branch: "main", + }, + }, + } + + createResp, err := client.CreateSession(ctx, createReq) + if err != nil { + log.Fatalf("Failed to create session: %v", err) + } + + sessionID := createResp.ID + fmt.Printf("✓ Created session: %s\n", sessionID) + + // Example 2: Get session details + fmt.Println("\n🔍 Getting session details...") + session, err := client.GetSession(ctx, sessionID) + if err != nil { + log.Fatalf("Failed to get session: %v", err) + } + + printSessionDetails(session) + + // Example 3: List all sessions + fmt.Println("\n📋 Listing all sessions...") + listResp, err := client.ListSessions(ctx) + if err != nil { + log.Fatalf("Failed to list sessions: %v", err) + } + + fmt.Printf("✓ Found %d sessions (total: %d)\n", len(listResp.Items), listResp.Total) + for i, s := range listResp.Items { + if i < 3 { // Show first 3 sessions + fmt.Printf(" %d. %s (%s) - %s\n", i+1, s.ID, s.Status, truncateString(s.Task, 60)) + } + } + if len(listResp.Items) > 3 { + fmt.Printf(" ... and %d more\n", len(listResp.Items)-3) + } + + // Example 4: Monitor session (optional) + if shouldMonitorSession() { + fmt.Println("\n⏳ Monitoring session completion...") + fmt.Println(" Note: This may take time depending on the task complexity") + + completedSession, err := client.WaitForCompletion(ctx, sessionID, 5*time.Second) + if err != nil { + log.Printf("❌ Monitoring failed: %v", err) + } else { + fmt.Println("\n🎉 Session completed!") + printSessionDetails(completedSession) + } + } + + fmt.Println("\n✅ HTTP Client demonstration complete!") + fmt.Println("\n💡 Next steps:") + fmt.Println(" • Check session status periodically") + fmt.Println(" • Use the session ID to retrieve results") + fmt.Println(" • Create additional sessions as needed") +} + +// printSessionDetails displays detailed information about a session +func printSessionDetails(session *types.SessionResponse) { + fmt.Printf(" ID: %s\n", session.ID) + fmt.Printf(" Status: %s\n", session.Status) + fmt.Printf(" Task: %s\n", truncateString(session.Task, 80)) + fmt.Printf(" Model: %s\n", session.Model) + fmt.Printf(" Created: %s\n", session.CreatedAt) + + if session.CompletedAt != "" { + fmt.Printf(" Completed: %s\n", session.CompletedAt) + } + + if session.Result != "" { + fmt.Printf(" Result: %s\n", truncateString(session.Result, 100)) + } + + if session.Error != "" { + fmt.Printf(" Error: %s\n", session.Error) + } +} + +// getEnvOrDefault returns environment variable value or default if not set +func getEnvOrDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// shouldMonitorSession checks if user wants to monitor session completion +func shouldMonitorSession() bool { + monitor := getEnvOrDefault("MONITOR_SESSION", "false") + return monitor == "true" || monitor == "1" +} + +// truncateString truncates a string to specified length with ellipsis +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} \ No newline at end of file diff --git a/components/ambient-sdk/go-sdk/go.mod b/components/ambient-sdk/go-sdk/go.mod new file mode 100644 index 000000000..62b4459bd --- /dev/null +++ b/components/ambient-sdk/go-sdk/go.mod @@ -0,0 +1,3 @@ +module github.com/ambient-code/platform/components/ambient-sdk/go-sdk + +go 1.21 diff --git a/components/ambient-sdk/go-sdk/go.sum b/components/ambient-sdk/go-sdk/go.sum new file mode 100644 index 000000000..e69de29bb diff --git a/components/ambient-sdk/go-sdk/types/types.go b/components/ambient-sdk/go-sdk/types/types.go new file mode 100644 index 000000000..3cffbdf0d --- /dev/null +++ b/components/ambient-sdk/go-sdk/types/types.go @@ -0,0 +1,248 @@ +// Package types provides HTTP API types for the Ambient Platform Public API. +package types + +import ( + "fmt" + "log/slog" + "net/url" + "strings" +) + +// HTTP API Types (matching public-api/types/dto.go) + +// SessionResponse is the simplified session response from the public API +type SessionResponse struct { + ID string `json:"id"` + Status string `json:"status"` // "pending", "running", "completed", "failed" + Task string `json:"task"` + Model string `json:"model,omitempty"` + CreatedAt string `json:"createdAt"` + CompletedAt string `json:"completedAt,omitempty"` + Result string `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// SessionListResponse is the response for listing sessions +type SessionListResponse struct { + Items []SessionResponse `json:"items"` + Total int `json:"total"` +} + +// CreateSessionRequest is the request body for creating a session +type CreateSessionRequest struct { + Task string `json:"task"` + Model string `json:"model,omitempty"` + Repos []RepoHTTP `json:"repos,omitempty"` +} + +// CreateSessionResponse is the response from creating a session +type CreateSessionResponse struct { + ID string `json:"id"` + Message string `json:"message"` +} + +// RepoHTTP represents a repository configuration for HTTP API +type RepoHTTP struct { + URL string `json:"url"` + Branch string `json:"branch,omitempty"` +} + +// ErrorResponse is a standard error response +type ErrorResponse struct { + Error string `json:"error"` + Message string `json:"message,omitempty"` +} + +// Session status constants +const ( + StatusPending = "pending" + StatusRunning = "running" + StatusCompleted = "completed" + StatusFailed = "failed" +) + +// SecureToken is a type-safe wrapper for authentication tokens that implements +// slog.LogValuer for automatic sanitization in logs +type SecureToken string + +// LogValue implements slog.LogValuer to safely log tokens +func (t SecureToken) LogValue() slog.Value { + if len(t) == 0 { + return slog.StringValue("[EMPTY]") + } + if len(t) < 8 { + return slog.StringValue("[TOO_SHORT]") + } + // Show only first 6 characters + length for debugging + return slog.StringValue(fmt.Sprintf("%s***(%d_chars)", string(t)[:6], len(t))) +} + +// String returns the actual token value - use with care +func (t SecureToken) String() string { + return string(t) +} + +// IsValid performs comprehensive token validation +func (t SecureToken) IsValid() error { + if len(t) == 0 { + return fmt.Errorf("token cannot be empty") + } + + tokenStr := string(t) + + // Check for common placeholder values + placeholders := []string{ + "YOUR_TOKEN_HERE", "your-token-here", "token", "password", + "secret", "example", "test", "demo", "placeholder", "TODO", + } + for _, placeholder := range placeholders { + if strings.EqualFold(tokenStr, placeholder) { + return fmt.Errorf("token appears to be a placeholder value") + } + } + + // Check minimum length for security + if len(tokenStr) < 10 { + return fmt.Errorf("token is too short (minimum 10 characters required)") + } + + // Validate known token formats + if err := t.validateTokenFormat(tokenStr); err != nil { + return fmt.Errorf("invalid token format: %w", err) + } + + return nil +} + +// validateTokenFormat checks for known secure token formats +func (t SecureToken) validateTokenFormat(token string) error { + // OpenShift SHA256 tokens + if strings.HasPrefix(token, "sha256~") { + if len(token) < 20 { + return fmt.Errorf("OpenShift token too short") + } + return nil + } + + // JWT tokens (3 base64 parts separated by dots) + if strings.Count(token, ".") == 2 { + parts := strings.Split(token, ".") + if len(parts) == 3 { + // Basic JWT structure validation + for i, part := range parts { + if len(part) == 0 { + return fmt.Errorf("JWT part %d is empty", i+1) + } + // JWT parts should be base64-like (alphanumeric + _- characters) + for _, char := range part { + if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || + (char >= '0' && char <= '9') || char == '_' || char == '-') { + return fmt.Errorf("JWT contains invalid characters") + } + } + } + return nil + } + } + + // GitHub tokens + if strings.HasPrefix(token, "ghp_") || strings.HasPrefix(token, "gho_") || + strings.HasPrefix(token, "ghu_") || strings.HasPrefix(token, "ghs_") { + if len(token) < 40 { + return fmt.Errorf("GitHub token too short") + } + return nil + } + + // Generic validation for other tokens + if len(token) < 20 { + return fmt.Errorf("token appears too short for secure authentication") + } + + // Must contain alphanumeric characters + hasAlpha := false + hasNumeric := false + for _, char := range token { + if char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' { + hasAlpha = true + } + if char >= '0' && char <= '9' { + hasNumeric = true + } + } + + if !hasAlpha || !hasNumeric { + return fmt.Errorf("token must contain both alphabetic and numeric characters") + } + + return nil +} + +// Validate validates the CreateSessionRequest for common issues +func (r *CreateSessionRequest) Validate() error { + if strings.TrimSpace(r.Task) == "" { + return fmt.Errorf("task cannot be empty") + } + + if len(r.Task) > 10000 { + return fmt.Errorf("task exceeds maximum length of 10,000 characters") + } + + // Validate model if provided + if r.Model != "" && !isValidModel(r.Model) { + return fmt.Errorf("invalid model: %s", r.Model) + } + + // Validate repositories + for i, repo := range r.Repos { + if err := repo.Validate(); err != nil { + return fmt.Errorf("repository %d: %w", i, err) + } + } + + return nil +} + +// Validate validates the RepoHTTP for common issues +func (r *RepoHTTP) Validate() error { + if strings.TrimSpace(r.URL) == "" { + return fmt.Errorf("repository URL cannot be empty") + } + + // Parse and validate URL + parsedURL, err := url.Parse(r.URL) + if err != nil { + return fmt.Errorf("invalid URL format: %w", err) + } + + // Check for supported schemes + if parsedURL.Scheme != "https" && parsedURL.Scheme != "http" { + return fmt.Errorf("unsupported URL scheme: %s (must be http or https)", parsedURL.Scheme) + } + + // Check for common invalid URLs + if strings.Contains(r.URL, "example.com") || strings.Contains(r.URL, "localhost") { + return fmt.Errorf("URL appears to be a placeholder or localhost") + } + + return nil +} + +// isValidModel checks if the model name is in the expected format +func isValidModel(model string) bool { + validModels := []string{ + "claude-3.5-sonnet", + "claude-3.5-haiku", + "claude-3-opus", + "claude-3-sonnet", + "claude-3-haiku", + } + + for _, validModel := range validModels { + if model == validModel { + return true + } + } + + return false +} \ No newline at end of file diff --git a/components/ambient-sdk/openapi.yaml b/components/ambient-sdk/openapi.yaml new file mode 100644 index 000000000..eff31a463 --- /dev/null +++ b/components/ambient-sdk/openapi.yaml @@ -0,0 +1,295 @@ +openapi: 3.0.3 +info: + title: Ambient Platform Public API + description: | + Simple HTTP API for the Ambient Platform - AI-powered automation sessions. + + This API provides a streamlined interface for creating and managing AI agent sessions + without requiring Kubernetes knowledge. Focus is on AgenticSession lifecycle management + through clean REST endpoints. + version: "1.0.0" + contact: + name: Ambient Code Platform + url: https://github.com/ambient-code/platform + license: + name: Apache-2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + +servers: + - url: https://api.ambient-code.io + description: Production API + - url: https://staging.ambient-code.io + description: Staging API + - url: http://localhost:8080 + description: Local development + +tags: + - name: sessions + description: AI agent session management + +paths: + /v1/sessions: + get: + tags: [sessions] + summary: List AI agent sessions + description: Retrieve a list of all accessible AI agent sessions for the authenticated user and project + security: + - BearerAuth: [] + - ProjectHeader: [] + responses: + '200': + $ref: '#/components/responses/SessionList' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + post: + tags: [sessions] + summary: Create new AI agent session + description: Start a new AI automation session with the specified task and configuration + security: + - BearerAuth: [] + - ProjectHeader: [] + requestBody: + $ref: '#/components/requestBodies/CreateSession' + responses: + '201': + $ref: '#/components/responses/SessionCreated' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /v1/sessions/{sessionId}: + get: + tags: [sessions] + summary: Get session details + description: Retrieve detailed information about a specific AI agent session + security: + - BearerAuth: [] + - ProjectHeader: [] + parameters: + - $ref: '#/components/parameters/SessionId' + responses: + '200': + $ref: '#/components/responses/Session' + '404': + $ref: '#/components/responses/NotFound' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + +components: + parameters: + SessionId: + name: sessionId + in: path + required: true + schema: + type: string + pattern: '^session-[0-9]+$' + example: "session-1771013589" + description: Unique session identifier + + requestBodies: + CreateSession: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSessionRequest' + + responses: + Session: + description: AI agent session details + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + + SessionCreated: + description: Session created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSessionResponse' + + SessionList: + description: List of AI agent sessions + content: + application/json: + schema: + $ref: '#/components/schemas/SessionListResponse' + + BadRequest: + description: Invalid request parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + Unauthorized: + description: Authentication required or invalid token + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + Forbidden: + description: Insufficient permissions for this operation + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + NotFound: + description: Session not found or not accessible + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + schemas: + # Core session types + SessionResponse: + type: object + required: [id, status, task, createdAt] + properties: + id: + type: string + description: Unique session identifier + example: "session-1771013589" + status: + type: string + enum: [pending, running, completed, failed] + description: Current session status + example: "pending" + task: + type: string + description: AI task description provided when creating the session + example: "Analyze the repository structure and provide a brief summary" + model: + type: string + description: AI model used for this session + example: "claude-3.5-sonnet" + createdAt: + type: string + format: date-time + description: When the session was created (RFC3339 format) + example: "2026-02-13T20:13:09Z" + completedAt: + type: string + format: date-time + description: When the session completed (only present for completed sessions) + example: "2026-02-13T20:25:30Z" + result: + type: string + description: Session result (only present for completed sessions) + example: "Analysis complete: Found 15 Go files with REST API implementation..." + error: + type: string + description: Error details (only present for failed sessions) + example: "Timeout exceeded after 30 minutes" + + SessionListResponse: + type: object + required: [items, total] + properties: + items: + type: array + items: + $ref: '#/components/schemas/SessionResponse' + description: Array of session objects + total: + type: integer + minimum: 0 + description: Total number of accessible sessions + example: 25 + + CreateSessionRequest: + type: object + required: [task] + properties: + task: + type: string + minLength: 10 + maxLength: 50000 + description: Description of the task for the AI agent to perform + example: "Analyze the repository structure and provide a brief summary of the codebase organization" + model: + type: string + description: AI model to use (defaults to platform default if not specified) + example: "claude-3.5-sonnet" + repos: + type: array + items: + $ref: '#/components/schemas/RepoHTTP' + description: Git repositories for the agent to work with + example: + - url: "https://github.com/ambient-code/platform" + branch: "main" + + CreateSessionResponse: + type: object + required: [id, message] + properties: + id: + type: string + description: Unique identifier for the created session + example: "session-1771013589" + message: + type: string + description: Confirmation message + example: "Session created successfully" + + RepoHTTP: + type: object + required: [url] + properties: + url: + type: string + format: uri + description: Git repository URL + example: "https://github.com/ambient-code/platform" + branch: + type: string + description: Git branch to use (defaults to repository default branch) + example: "main" + + ErrorResponse: + type: object + required: [error] + properties: + error: + type: string + description: Error message + example: "Project required. Set X-Ambient-Project header" + message: + type: string + description: Additional error details + example: "The X-Ambient-Project header must be set to a valid project name" + + securitySchemes: + BearerAuth: + type: http + scheme: bearer + description: | + Bearer token authentication. Provide your authentication token in the Authorization header. + + Example: `Authorization: Bearer ` + + ProjectHeader: + type: apiKey + in: header + name: X-Ambient-Project + description: | + Project context header. Specifies which project/namespace the request applies to. + + Example: `X-Ambient-Project: my-project` + +security: + - BearerAuth: [] + ProjectHeader: [] \ No newline at end of file diff --git a/components/ambient-sdk/python-sdk/README.md b/components/ambient-sdk/python-sdk/README.md new file mode 100644 index 000000000..9f7690996 --- /dev/null +++ b/components/ambient-sdk/python-sdk/README.md @@ -0,0 +1,475 @@ +# Ambient Platform Python SDK + +Simple HTTP client library for the Ambient Code Platform - Create and manage AI agent sessions without Kubernetes complexity. + +## Installation + +```bash +pip install ambient-platform-sdk +``` + +## Quick Start + +```python +import os +from ambient_platform import AmbientClient, CreateSessionRequest, RepoHTTP + +# Create HTTP client +client = AmbientClient( + base_url="https://your-platform.example.com", + token=os.getenv("AMBIENT_TOKEN"), # Bearer token + project=os.getenv("AMBIENT_PROJECT"), # Project namespace +) + +# Create a session +request = CreateSessionRequest( + task="Analyze the repository structure and provide a summary", + model="claude-3.5-sonnet", + repos=[ + RepoHTTP( + url="https://github.com/ambient-code/platform", + branch="main" + ) + ] +) + +response = client.create_session(request) +print(f"Created session: {response.id}") + +# Get session details +session = client.get_session(response.id) +print(f"Status: {session.status}") + +# List all sessions +sessions = client.list_sessions() +print(f"Found {len(sessions.items)} sessions") + +# Close client when done +client.close() +``` + +## Authentication & Authorization + +The SDK uses Bearer token authentication with project-scoped authorization: + +### Token Requirements + +- **Bearer Token**: Must be a valid authentication token (OpenShift, JWT, or GitHub format) +- **Project Header**: `X-Ambient-Project` specifies the target Kubernetes namespace +- **RBAC**: User must have appropriate permissions in the target namespace + +### Supported Token Formats + +- **OpenShift**: `sha256~...` format tokens from `oc whoami -t` +- **JWT**: Standard JSON Web Tokens with 3 base64 parts +- **GitHub**: Tokens starting with `ghp_`, `gho_`, `ghu_`, or `ghs_` + +### Required Permissions + +Your user account must have these Kubernetes RBAC permissions in the target project/namespace: + +```yaml +# Minimum required permissions +- apiGroups: ["vteam.ambient-code"] + resources: ["agenticsessions"] + verbs: ["get", "list", "create"] + +- apiGroups: [""] + resources: ["namespaces"] + verbs: ["get"] +``` + +### Common Permission Errors + +**403 Forbidden**: +```bash +# Check your permissions +oc auth can-i create agenticsessions.vteam.ambient-code -n your-project +oc auth can-i list agenticsessions.vteam.ambient-code -n your-project +``` + +**401 Unauthorized**: +```bash +# Check token validity +oc whoami # Should return your username +oc whoami -t # Should return a token starting with sha256~ +``` + +**400 Bad Request - Project required**: +- Ensure `AMBIENT_PROJECT` environment variable is set +- Project must be a valid Kubernetes namespace name +- User must have access to the specified project + +```bash +# Set environment variables +export AMBIENT_TOKEN="your-bearer-token" # Required +export AMBIENT_PROJECT="your-project-name" # Required +export AMBIENT_API_URL="https://your-api.com" # Optional +``` + +**OpenShift Users:** +```bash +# Use your OpenShift token +export AMBIENT_TOKEN="$(oc whoami -t)" +export AMBIENT_PROJECT="$(oc project -q)" +``` + +## Core Operations + +### Create Session + +```python +from ambient_platform import CreateSessionRequest, RepoHTTP + +request = CreateSessionRequest( + task="Review this code for security issues", + model="claude-3.5-sonnet", # Optional, uses platform default if omitted + repos=[ + RepoHTTP(url="https://github.com/user/repo", branch="main") + ] +) + +response = client.create_session(request) +print(f"Session ID: {response.id}") +``` + +### Get Session Details + +```python +from ambient_platform import StatusCompleted + +session = client.get_session("session-1234567") +print(f"Status: {session.status}") + +if session.status == StatusCompleted: + print(f"Result: {session.result}") +``` + +### List Sessions + +```python +sessions = client.list_sessions() +for session in sessions.items: + print(f"- {session.id} ({session.status}): {session.task}") +``` + +### Monitor Session Completion + +```python +# Wait for session to complete (with timeout) +try: + completed = client.wait_for_completion( + session_id="session-1234567", + poll_interval=5.0, # Check every 5 seconds + timeout=300.0 # 5 minute timeout + ) + + if completed.status == StatusCompleted: + print(f"Success: {completed.result}") + else: + print(f"Failed: {completed.error}") + +except TimeoutError: + print("Session monitoring timed out") +``` + +## Session Status Values + +```python +from ambient_platform import StatusPending, StatusRunning, StatusCompleted, StatusFailed + +# Status constants +StatusPending = "pending" # Session created, waiting to start +StatusRunning = "running" # AI agent actively working +StatusCompleted = "completed" # Task finished successfully +StatusFailed = "failed" # Task failed with error +``` + +## Configuration Options + +### Environment Variables + +Create client from environment variables: + +```python +# Automatically reads AMBIENT_API_URL, AMBIENT_TOKEN, AMBIENT_PROJECT +client = AmbientClient.from_env() +``` + +### Context Manager + +Use client as context manager for automatic cleanup: + +```python +with AmbientClient.from_env() as client: + response = client.create_session(request) + session = client.get_session(response.id) + # Client automatically closed when exiting context +``` + +### Custom Timeout + +```python +client = AmbientClient( + base_url="https://api.example.com", + token="your-token", + project="your-project", + timeout=60.0 # 60 second timeout +) +``` + +## Error Handling + +```python +from ambient_platform.exceptions import ( + AmbientAPIError, + AuthenticationError, + SessionNotFoundError, + AmbientConnectionError, +) + +try: + session = client.get_session("invalid-id") +except SessionNotFoundError as e: + print(f"Session not found: {e}") +except AuthenticationError as e: + print(f"Auth failed: {e}") +except AmbientConnectionError as e: + print(f"Connection failed: {e}") +except AmbientAPIError as e: + print(f"API error: {e}") +``` + +## Examples + +See the `examples/` directory for complete working examples: + +- **`main.py`** - Complete session lifecycle demonstration + +Run the example: +```bash +cd examples/ +export AMBIENT_TOKEN="your-token" +export AMBIENT_PROJECT="your-project" +python main.py +``` + +## API Reference + +### AmbientClient + +```python +class AmbientClient: + def __init__(self, base_url: str, token: str, project: str, timeout: float = 30.0) + + def create_session(self, request: CreateSessionRequest) -> CreateSessionResponse + def get_session(self, session_id: str) -> SessionResponse + def list_sessions(self) -> SessionListResponse + def wait_for_completion(self, session_id: str, poll_interval: float = 5.0, timeout: Optional[float] = None) -> SessionResponse + + @classmethod + def from_env(cls, **kwargs) -> "AmbientClient" + + def close(self) # Close HTTP client +``` + +### Data Classes + +```python +@dataclass +class CreateSessionRequest: + task: str + model: Optional[str] = None + repos: Optional[List[RepoHTTP]] = None + +@dataclass +class RepoHTTP: + url: str + branch: Optional[str] = None + +@dataclass +class SessionResponse: + id: str + status: str # "pending", "running", "completed", "failed" + task: str + model: Optional[str] = None + created_at: Optional[str] = None + completed_at: Optional[str] = None + result: Optional[str] = None + error: Optional[str] = None + +@dataclass +class SessionListResponse: + items: List[SessionResponse] + total: int + +@dataclass +class CreateSessionResponse: + id: str + message: str + +@dataclass +class ErrorResponse: + error: str + message: Optional[str] = None +``` + +## Architecture + +### Design Principles + +- **HTTP-First**: Pure REST API client with no Kubernetes dependencies +- **Minimal Dependencies**: Uses only `httpx` for HTTP requests +- **Simple Integration**: Easy to embed in any Python application +- **Type Safety**: Dataclasses with type hints for all requests/responses +- **Clear Separation**: Public SDK vs internal platform implementation + +### HTTP vs Kubernetes + +This SDK provides a **simplified HTTP interface** to the Ambient Platform: + +| Aspect | HTTP SDK (This Package) | Internal Platform | +|--------|------------------------|-------------------| +| **API** | Simple REST endpoints (`/v1/sessions`) | Complex Kubernetes CRDs | +| **Auth** | Bearer token + project header | RBAC + service accounts | +| **Types** | Simple dataclasses | Full K8s metadata/spec/status | +| **Usage** | Any HTTP client, any environment | Kubernetes cluster access required | +| **Dependencies** | Only `httpx` | `kubernetes`, `pydantic`, etc. | +| **Target** | External integrators, simple automation | Internal platform components | + +## Troubleshooting + +### Authentication Issues +``` +❌ AMBIENT_TOKEN environment variable is required +``` +**Solution**: Set your Bearer token: `export AMBIENT_TOKEN="your-token"` + +### Project Header Missing +``` +API error (400): Project required. Set X-Ambient-Project header +``` +**Solution**: Set project name: `export AMBIENT_PROJECT="your-project"` + +### Connection Errors +``` +Failed to connect to API: [connection error details] +``` +**Solution**: Verify API endpoint and network connectivity + +### Session Not Found +``` +session not found: session-xyz +``` +**Solution**: Verify session ID and check if you have access to the project + +## Development + +### Setup Development Environment + +```bash +# Clone repository +git clone https://github.com/ambient-code/platform.git +cd platform/components/ambient-sdk/python-sdk + +# Create virtual environment +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate + +# Install in development mode +pip install -e ".[dev]" + +# Run tests +pytest + +# Format code +black ambient_platform examples +isort ambient_platform examples + +# Type checking +mypy ambient_platform +``` + +### Running Tests + +```bash +# Run all tests +pytest + +# Run integration tests (requires running API) +pytest -m integration + +# Run with coverage +pytest --cov=ambient_platform --cov-report=html +``` + +### Testing Against Real API + +```bash +# Set environment variables +export AMBIENT_TOKEN="your-token" +export AMBIENT_PROJECT="your-project" +export AMBIENT_API_URL="https://your-api.example.com" + +# Run example +python examples/main.py + +# Run integration tests +pytest -m integration +``` + +## OpenAPI Specification + +This SDK is built to match the OpenAPI specification in `../openapi.yaml`. The specification defines: + +- **Endpoints**: `/v1/sessions` (create, list, get) +- **Authentication**: Bearer token + X-Ambient-Project header +- **Request/Response**: JSON types matching this SDK +- **Error Handling**: Structured error responses with HTTP status codes + +## Terminal Usage Guide + +### Quick Setup + +```bash +# Navigate to python-sdk directory +cd /path/to/platform/components/ambient-sdk/python-sdk + +# Create and activate virtual environment +python -m venv venv +source venv/bin/activate + +# Install SDK +pip install -e . + +# Set environment variables +export AMBIENT_TOKEN="your-bearer-token" +export AMBIENT_PROJECT="your-project-name" +export AMBIENT_API_URL="https://your-api-endpoint.com" # Optional + +# Run example +python examples/main.py +``` + +### Interactive Python Session + +```bash +# Start Python REPL +python + +# Use the SDK interactively +>>> from ambient_platform import AmbientClient, CreateSessionRequest, RepoHTTP +>>> client = AmbientClient.from_env() +>>> request = CreateSessionRequest(task="Hello world", model="claude-3.5-sonnet") +>>> response = client.create_session(request) +>>> print(f"Session ID: {response.id}") +``` + +## Contributing + +1. **SDK Changes**: Modify code in `ambient_platform/` directory +2. **API Changes**: Update `../openapi.yaml` specification first +3. **Examples**: Add working examples to `examples/` directory +4. **Testing**: Ensure all changes work with real API endpoints + +For complete platform documentation, see the main [platform repository](https://github.com/ambient-code/platform). \ No newline at end of file diff --git a/components/ambient-sdk/python-sdk/ambient_platform/__init__.py b/components/ambient-sdk/python-sdk/ambient_platform/__init__.py new file mode 100644 index 000000000..899ed7176 --- /dev/null +++ b/components/ambient-sdk/python-sdk/ambient_platform/__init__.py @@ -0,0 +1,54 @@ +""" +Ambient Platform Python SDK + +Simple HTTP client for the Ambient Code Platform - Create and manage AI agent sessions without Kubernetes complexity. +""" + +from .client import AmbientClient +from .types import ( + SessionResponse, + SessionListResponse, + CreateSessionRequest, + CreateSessionResponse, + RepoHTTP, + ErrorResponse, + StatusPending, + StatusRunning, + StatusCompleted, + StatusFailed, +) +from .exceptions import ( + AmbientAPIError, + AmbientConnectionError, + SessionNotFoundError, + AuthenticationError, +) + +__version__ = "1.0.0" +__author__ = "Ambient Code Platform" +__email__ = "hello@ambient-code.io" + +__all__ = [ + # Client + "AmbientClient", + + # Types + "SessionResponse", + "SessionListResponse", + "CreateSessionRequest", + "CreateSessionResponse", + "RepoHTTP", + "ErrorResponse", + + # Status constants + "StatusPending", + "StatusRunning", + "StatusCompleted", + "StatusFailed", + + # Exceptions + "AmbientAPIError", + "AmbientConnectionError", + "SessionNotFoundError", + "AuthenticationError", +] \ No newline at end of file diff --git a/components/ambient-sdk/python-sdk/ambient_platform/client.py b/components/ambient-sdk/python-sdk/ambient_platform/client.py new file mode 100644 index 000000000..dd9a258a6 --- /dev/null +++ b/components/ambient-sdk/python-sdk/ambient_platform/client.py @@ -0,0 +1,303 @@ +""" +HTTP client for the Ambient Platform Public API. +""" + +import json +import os +import time +from typing import Optional +from urllib.parse import urlparse +import httpx + +from .types import ( + SessionResponse, + SessionListResponse, + CreateSessionRequest, + CreateSessionResponse, + ErrorResponse, + StatusCompleted, + StatusFailed, +) +from .exceptions import ( + AmbientAPIError, + AmbientConnectionError, + SessionNotFoundError, + AuthenticationError, +) + + +class AmbientClient: + """Simple HTTP client for the Ambient Platform API.""" + + def __init__( + self, + base_url: str, + token: str, + project: str, + timeout: float = 30.0, + ): + """ + Initialize the Ambient Platform client. + + Args: + base_url: API base URL (e.g., "https://api.ambient-code.io") + token: Bearer token for authentication + project: Project name (Kubernetes namespace) + timeout: HTTP request timeout in seconds + + Raises: + ValueError: If token or other parameters fail validation + """ + # Validate inputs + self._validate_token(token) + self._validate_project(project) + self._validate_base_url(base_url) + + self.base_url = base_url.rstrip("/") + self.token = token + self.project = project + self.timeout = timeout + + # Create HTTP client with headers + self.client = httpx.Client( + timeout=timeout, + headers={ + "Authorization": f"Bearer {token}", + "X-Ambient-Project": project, + "Content-Type": "application/json", + }, + ) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + def close(self): + """Close the HTTP client.""" + self.client.close() + + def _handle_response(self, response: httpx.Response) -> dict: + """Handle HTTP response and raise appropriate exceptions.""" + try: + data = response.json() + except (json.JSONDecodeError, ValueError): + data = {"error": f"Invalid JSON response: {response.text}"} + + if response.status_code == 401: + error_msg = data.get("error", "Unauthorized") + raise AuthenticationError(f"Authentication failed: {error_msg}") + elif response.status_code == 404: + error_msg = data.get("error", "Not found") + raise SessionNotFoundError(error_msg) + elif response.status_code >= 400: + error_msg = data.get("error", f"HTTP {response.status_code}") + message = data.get("message", "") + full_msg = f"{error_msg}" + (f" - {message}" if message else "") + raise AmbientAPIError(f"API error ({response.status_code}): {full_msg}") + + return data + + def create_session(self, request: CreateSessionRequest) -> CreateSessionResponse: + """ + Create a new agentic session. + + Args: + request: Session creation request + + Returns: + CreateSessionResponse with session ID and message + + Raises: + ValueError: If request validation fails + AmbientAPIError: If the API request fails + AuthenticationError: If authentication fails + AmbientConnectionError: If connection fails + """ + # Validate the request first + request.validate() + + url = f"{self.base_url}/v1/sessions" + + try: + response = self.client.post(url, json=request.to_dict()) + except httpx.RequestError as e: + raise AmbientConnectionError(f"Failed to connect to API: {e}") + + data = self._handle_response(response) + return CreateSessionResponse.from_dict(data) + + def get_session(self, session_id: str) -> SessionResponse: + """ + Retrieve a session by ID. + + Args: + session_id: Unique session identifier + + Returns: + SessionResponse with session details + + Raises: + SessionNotFoundError: If session doesn't exist + AmbientAPIError: If the API request fails + AuthenticationError: If authentication fails + AmbientConnectionError: If connection fails + """ + url = f"{self.base_url}/v1/sessions/{session_id}" + + try: + response = self.client.get(url) + except httpx.RequestError as e: + raise AmbientConnectionError(f"Failed to connect to API: {e}") + + data = self._handle_response(response) + return SessionResponse.from_dict(data) + + def list_sessions(self) -> SessionListResponse: + """ + List all accessible sessions. + + Returns: + SessionListResponse with session list and total count + + Raises: + AmbientAPIError: If the API request fails + AuthenticationError: If authentication fails + AmbientConnectionError: If connection fails + """ + url = f"{self.base_url}/v1/sessions" + + try: + response = self.client.get(url) + except httpx.RequestError as e: + raise AmbientConnectionError(f"Failed to connect to API: {e}") + + data = self._handle_response(response) + return SessionListResponse.from_dict(data) + + def wait_for_completion( + self, + session_id: str, + poll_interval: float = 5.0, + timeout: Optional[float] = None, + ) -> SessionResponse: + """ + Poll a session until it reaches a terminal state. + + Args: + session_id: Session ID to monitor + poll_interval: Time between polls in seconds + timeout: Maximum time to wait in seconds (None = no limit) + + Returns: + SessionResponse when session completes or fails + + Raises: + TimeoutError: If timeout is reached + SessionNotFoundError: If session doesn't exist + AmbientAPIError: If the API request fails + AmbientConnectionError: If connection fails + """ + start_time = time.time() + + while True: + session = self.get_session(session_id) + + # Check if session reached terminal state + if session.status in (StatusCompleted, StatusFailed): + return session + + # Check timeout + if timeout and (time.time() - start_time) > timeout: + raise TimeoutError( + f"Session monitoring timed out after {timeout} seconds" + ) + + # Wait before next poll + time.sleep(poll_interval) + + @classmethod + def from_env(cls, **kwargs) -> "AmbientClient": + """ + Create client from environment variables. + + Environment variables: + AMBIENT_API_URL: API base URL (default: http://localhost:8080) + AMBIENT_TOKEN: Bearer token (required) + AMBIENT_PROJECT: Project name (required) + + Args: + **kwargs: Additional arguments to override environment + + Returns: + Configured AmbientClient + + Raises: + ValueError: If required environment variables are missing + """ + import os + + base_url = kwargs.get("base_url") or os.getenv( + "AMBIENT_API_URL", "http://localhost:8080" + ) + token = kwargs.get("token") or os.getenv("AMBIENT_TOKEN") + project = kwargs.get("project") or os.getenv("AMBIENT_PROJECT") + + if not token: + raise ValueError("AMBIENT_TOKEN environment variable is required") + if not project: + raise ValueError("AMBIENT_PROJECT environment variable is required") + + return cls( + base_url=base_url, + token=token, + project=project, + **{k: v for k, v in kwargs.items() if k not in ("base_url", "token", "project")} + ) + + def _validate_token(self, token: str) -> None: + """Validate token format and security.""" + if not token: + raise ValueError("Token cannot be empty") + + if len(token) < 10: + raise ValueError("Token appears too short to be valid") + + # Check for common placeholder values + if token.lower() in ("your_token_here", "your-token-here", "token", "bearer"): + raise ValueError("Token appears to be a placeholder value") + + # Check for potential token leakage patterns + if "AMBIENT_TOKEN=" in token: + raise ValueError("Token contains 'AMBIENT_TOKEN=' prefix - potential format error") + + def _validate_project(self, project: str) -> None: + """Validate project name format.""" + if not project: + raise ValueError("Project cannot be empty") + + if not project.replace("-", "").replace("_", "").isalnum(): + raise ValueError("Project must contain only alphanumeric characters, hyphens, and underscores") + + if len(project) > 63: + raise ValueError("Project name cannot exceed 63 characters") + + def _validate_base_url(self, base_url: str) -> None: + """Validate base URL format.""" + if not base_url: + raise ValueError("Base URL cannot be empty") + + parsed = urlparse(base_url) + if not parsed.scheme or not parsed.netloc: + raise ValueError("Base URL must include scheme (http/https) and host") + + if parsed.scheme not in ("http", "https"): + raise ValueError("Base URL scheme must be http or https") + + # Check for common placeholder values + if "localhost" in parsed.netloc and not base_url.startswith("http://localhost"): + raise ValueError("Localhost URLs should use http://localhost format") + + if "example.com" in parsed.netloc: + raise ValueError("Base URL appears to contain placeholder domain") \ No newline at end of file diff --git a/components/ambient-sdk/python-sdk/ambient_platform/exceptions.py b/components/ambient-sdk/python-sdk/ambient_platform/exceptions.py new file mode 100644 index 000000000..f380674fe --- /dev/null +++ b/components/ambient-sdk/python-sdk/ambient_platform/exceptions.py @@ -0,0 +1,23 @@ +""" +Exceptions for the Ambient Platform Python SDK. +""" + + +class AmbientAPIError(Exception): + """Base exception for Ambient Platform API errors.""" + pass + + +class AmbientConnectionError(AmbientAPIError): + """Raised when connection to the API fails.""" + pass + + +class SessionNotFoundError(AmbientAPIError): + """Raised when a session is not found or not accessible.""" + pass + + +class AuthenticationError(AmbientAPIError): + """Raised when authentication fails.""" + pass \ No newline at end of file diff --git a/components/ambient-sdk/python-sdk/ambient_platform/types.py b/components/ambient-sdk/python-sdk/ambient_platform/types.py new file mode 100644 index 000000000..1f89baf62 --- /dev/null +++ b/components/ambient-sdk/python-sdk/ambient_platform/types.py @@ -0,0 +1,160 @@ +""" +HTTP API types for the Ambient Platform Public API. +""" + +from typing import List, Optional +from dataclasses import dataclass +from urllib.parse import urlparse + +# Session status constants +StatusPending = "pending" +StatusRunning = "running" +StatusCompleted = "completed" +StatusFailed = "failed" + + +@dataclass +class RepoHTTP: + """Repository configuration for HTTP API.""" + url: str + branch: Optional[str] = None + + def to_dict(self) -> dict: + result = {"url": self.url} + if self.branch: + result["branch"] = self.branch + return result + + def validate(self) -> None: + """Validate repository configuration.""" + if not self.url or not self.url.strip(): + raise ValueError("Repository URL cannot be empty") + + # Parse and validate URL + try: + parsed = urlparse(self.url) + except Exception as e: + raise ValueError(f"Invalid URL format: {e}") + + # Check for supported schemes + if parsed.scheme not in ("https", "http"): + raise ValueError(f"Unsupported URL scheme: {parsed.scheme} (must be http or https)") + + # Check for common invalid URLs + if "example.com" in self.url or "localhost" in self.url: + raise ValueError("URL appears to be a placeholder or localhost") + + +@dataclass +class CreateSessionRequest: + """Request body for creating a session.""" + task: str + model: Optional[str] = None + repos: Optional[List[RepoHTTP]] = None + + def to_dict(self) -> dict: + result = {"task": self.task} + if self.model: + result["model"] = self.model + if self.repos: + result["repos"] = [repo.to_dict() for repo in self.repos] + return result + + def validate(self) -> None: + """Validate the session creation request.""" + if not self.task or not self.task.strip(): + raise ValueError("Task cannot be empty") + + if len(self.task) > 10000: + raise ValueError("Task exceeds maximum length of 10,000 characters") + + # Validate model if provided + if self.model and not self._is_valid_model(self.model): + raise ValueError(f"Invalid model: {self.model}") + + # Validate repositories + if self.repos: + for i, repo in enumerate(self.repos): + try: + repo.validate() + except ValueError as e: + raise ValueError(f"Repository {i}: {e}") + + def _is_valid_model(self, model: str) -> bool: + """Check if the model name is valid.""" + valid_models = [ + "claude-3.5-sonnet", + "claude-3.5-haiku", + "claude-3-opus", + "claude-3-sonnet", + "claude-3-haiku", + ] + return model in valid_models + + +@dataclass +class CreateSessionResponse: + """Response from creating a session.""" + id: str + message: str + + @classmethod + def from_dict(cls, data: dict) -> "CreateSessionResponse": + return cls( + id=data["id"], + message=data["message"] + ) + + +@dataclass +class SessionResponse: + """Simplified session response from the public API.""" + id: str + status: str # "pending", "running", "completed", "failed" + task: str + model: Optional[str] = None + created_at: Optional[str] = None + completed_at: Optional[str] = None + result: Optional[str] = None + error: Optional[str] = None + + @classmethod + def from_dict(cls, data: dict) -> "SessionResponse": + return cls( + id=data["id"], + status=data["status"], + task=data["task"], + model=data.get("model"), + created_at=data.get("createdAt"), + completed_at=data.get("completedAt"), + result=data.get("result"), + error=data.get("error") + ) + + +@dataclass +class SessionListResponse: + """Response for listing sessions.""" + items: List[SessionResponse] + total: int + + @classmethod + def from_dict(cls, data: dict) -> "SessionListResponse": + return cls( + items=[SessionResponse.from_dict(item) for item in data["items"]], + total=data["total"] + ) + + +@dataclass +class ErrorResponse: + """Standard error response.""" + error: str + message: Optional[str] = None + + @classmethod + def from_dict(cls, data: dict) -> "ErrorResponse": + return cls( + error=data["error"], + message=data.get("message") + ) \ No newline at end of file diff --git a/components/ambient-sdk/python-sdk/examples/main.py b/components/ambient-sdk/python-sdk/examples/main.py new file mode 100644 index 000000000..cae89ae22 --- /dev/null +++ b/components/ambient-sdk/python-sdk/examples/main.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +""" +Ambient Platform SDK - HTTP Client Example + +This example demonstrates how to use the Ambient Platform Python SDK to interact +with the platform via HTTP API. +""" + +import os +import sys +from typing import Optional + +from ambient_platform import ( + AmbientClient, + CreateSessionRequest, + RepoHTTP, + StatusCompleted, + StatusFailed, +) +from ambient_platform.exceptions import ( + AmbientAPIError, + AmbientConnectionError, + SessionNotFoundError, + AuthenticationError, +) + +# Example session configuration +EXAMPLE_TASK = "Analyze the repository structure and provide a brief summary of the codebase organization." +EXAMPLE_MODEL = "claude-3.5-sonnet" +DEFAULT_API_URL = "http://localhost:8080" + + +def get_env_or_default(key: str, default: str = "") -> str: + """Get environment variable value or default if not set.""" + return os.getenv(key, default) + + +def should_monitor_session() -> bool: + """Check if user wants to monitor session completion.""" + monitor = get_env_or_default("MONITOR_SESSION", "false").lower() + return monitor in ("true", "1", "yes") + + +def truncate_string(s: str, max_len: int) -> str: + """Truncate a string to specified length with ellipsis.""" + if len(s) <= max_len: + return s + return s[:max_len-3] + "..." + + +def print_session_details(session): + """Print detailed information about a session.""" + print(f" ID: {session.id}") + print(f" Status: {session.status}") + print(f" Task: {truncate_string(session.task, 80)}") + print(f" Model: {session.model}") + print(f" Created: {session.created_at}") + + if session.completed_at: + print(f" Completed: {session.completed_at}") + + if session.result: + print(f" Result: {truncate_string(session.result, 100)}") + + if session.error: + print(f" Error: {session.error}") + + +def main(): + """Main example function.""" + print("🐍 Ambient Platform SDK - Python HTTP Client Example") + print("===================================================") + + # Get configuration from environment or use defaults + api_url = get_env_or_default("AMBIENT_API_URL", DEFAULT_API_URL) + token = get_env_or_default("AMBIENT_TOKEN") + project = get_env_or_default("AMBIENT_PROJECT", "") + + if not token: + print("❌ AMBIENT_TOKEN environment variable is required") + sys.exit(1) + + try: + # Create HTTP client + with AmbientClient(api_url, token, project, timeout=60.0) as client: + print(f"✓ Created client for API: {api_url}") + print(f"✓ Using project: {project}") + + # Example 1: Create a new session + print("\n📝 Creating new session...") + create_req = CreateSessionRequest( + task=EXAMPLE_TASK, + model=EXAMPLE_MODEL, + repos=[ + RepoHTTP( + url="https://github.com/ambient-code/platform", + branch="main", + ) + ], + ) + + create_resp = client.create_session(create_req) + session_id = create_resp.id + print(f"✓ Created session: {session_id}") + + # Example 2: Get session details + print("\n🔍 Getting session details...") + session = client.get_session(session_id) + print_session_details(session) + + # Example 3: List all sessions + print("\n📋 Listing all sessions...") + list_resp = client.list_sessions() + print(f"✓ Found {len(list_resp.items)} sessions (total: {list_resp.total})") + + for i, s in enumerate(list_resp.items[:3]): # Show first 3 sessions + print(f" {i+1}. {s.id} ({s.status}) - {truncate_string(s.task, 60)}") + + if len(list_resp.items) > 3: + print(f" ... and {len(list_resp.items) - 3} more") + + # Example 4: Monitor session (optional) + if should_monitor_session(): + print("\n⏳ Monitoring session completion...") + print(" Note: This may take time depending on the task complexity") + + try: + completed_session = client.wait_for_completion( + session_id, poll_interval=5.0, timeout=300.0 # 5 minutes max + ) + print("\n🎉 Session completed!") + print_session_details(completed_session) + except TimeoutError: + print("⏰ Monitoring timed out after 5 minutes") + except Exception as e: + print(f"❌ Monitoring failed: {e}") + + print("\n✅ Python HTTP Client demonstration complete!") + print("\n💡 Next steps:") + print(" • Check session status periodically") + print(" • Use the session ID to retrieve results") + print(" • Create additional sessions as needed") + + except AuthenticationError as e: + print(f"❌ Authentication failed: {e}") + print(" Check your AMBIENT_TOKEN environment variable") + sys.exit(1) + except AmbientConnectionError as e: + print(f"❌ Connection failed: {e}") + print(" Check your AMBIENT_API_URL and network connectivity") + sys.exit(1) + except SessionNotFoundError as e: + print(f"❌ Session not found: {e}") + sys.exit(1) + except AmbientAPIError as e: + print(f"❌ API error: {e}") + sys.exit(1) + except Exception as e: + print(f"❌ Unexpected error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/components/ambient-sdk/python-sdk/pyproject.toml b/components/ambient-sdk/python-sdk/pyproject.toml new file mode 100644 index 000000000..6fad4126f --- /dev/null +++ b/components/ambient-sdk/python-sdk/pyproject.toml @@ -0,0 +1,123 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "ambient-platform-sdk" +version = "1.0.0" +description = "Simple HTTP client library for the Ambient Code Platform - Create and manage AI agent sessions without Kubernetes complexity." +readme = "README.md" +license = {text = "Apache-2.0"} +authors = [ + {name = "Ambient Code Platform", email = "hello@ambient-code.io"} +] +maintainers = [ + {name = "Ambient Code Platform", email = "hello@ambient-code.io"} +] +keywords = [ + "ai", "automation", "http", "claude", "agents", "sdk", "api" +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Internet :: WWW/HTTP :: HTTP Servers", +] +requires-python = ">=3.8" +dependencies = [ + "httpx>=0.25.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "pytest-mock>=3.10.0", + "black>=23.0.0", + "isort>=5.12.0", + "flake8>=6.0.0", + "mypy>=1.5.0", + "pre-commit>=3.0.0", + "httpx[cli]>=0.25.0" +] +docs = [ + "mkdocs>=1.5.0", + "mkdocs-material>=9.0.0" +] + +[project.urls] +Homepage = "https://github.com/ambient-code/platform" +Documentation = "https://docs.ambient-code.io" +Repository = "https://github.com/ambient-code/platform" +Issues = "https://github.com/ambient-code/platform/issues" +Changelog = "https://github.com/ambient-code/platform/releases" + +[tool.setuptools.packages.find] +where = ["."] +include = ["ambient_platform*"] + +[tool.black] +line-length = 88 +target-version = ['py38', 'py39', 'py310', 'py311', 'py312'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | build + | dist +)/ +''' + +[tool.isort] +profile = "black" +multi_line_output = 3 +line_length = 88 +known_first_party = ["ambient_platform"] + +[tool.mypy] +python_version = "3.8" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +strict_equality = true + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +python_files = ["test_*.py", "*_test.py"] +addopts = [ + "-ra", + "--strict-markers", + "--strict-config" +] +markers = [ + "integration: marks tests as integration tests (deselect with '-m \"not integration\"')", + "slow: marks tests as slow (deselect with '-m \"not slow\"')" +] \ No newline at end of file diff --git a/components/ambient-sdk/python-sdk/test.sh b/components/ambient-sdk/python-sdk/test.sh new file mode 100755 index 000000000..9cdbd9aab --- /dev/null +++ b/components/ambient-sdk/python-sdk/test.sh @@ -0,0 +1,231 @@ +#!/bin/bash + +# Ambient Platform Python SDK Test Script +# This script sets up the environment and runs the Python SDK example + +set -e # Exit on any error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Print colored output +print_error() { + echo -e "${RED}❌ $1${NC}" >&2 +} + +print_success() { + echo -e "${GREEN}✓ $1${NC}" +} + +print_warning() { + echo -e "${YELLOW}⚠ $1${NC}" +} + +print_info() { + echo -e "${BLUE}ℹ $1${NC}" +} + +print_header() { + echo -e "${BLUE}🐍 Ambient Platform Python SDK Test${NC}" + echo -e "${BLUE}====================================${NC}" +} + +# Check required environment variables +check_environment() { + local missing_vars=() + + if [[ -z "${AMBIENT_TOKEN:-}" ]]; then + missing_vars+=("AMBIENT_TOKEN") + fi + + if [[ -z "${AMBIENT_PROJECT:-}" ]]; then + missing_vars+=("AMBIENT_PROJECT") + fi + + if [[ -z "${AMBIENT_API_URL:-}" ]]; then + missing_vars+=("AMBIENT_API_URL") + fi + + if [[ ${#missing_vars[@]} -gt 0 ]]; then + print_error "Missing required environment variables:" + echo + for var in "${missing_vars[@]}"; do + echo " - $var" + done + echo + print_info "Please set all required environment variables:" + echo + echo " export AMBIENT_TOKEN=\"your-bearer-token\"" + echo " export AMBIENT_PROJECT=\"your-project-name\"" + echo " export AMBIENT_API_URL=\"https://your-api-endpoint.com\"" + echo + print_info "Examples:" + echo + echo " # Using OpenShift token (recommended):" + echo " export AMBIENT_TOKEN=\"\$(oc whoami -t)\"" + echo " export AMBIENT_PROJECT=\"anynamespace\"" + echo " export AMBIENT_API_URL=\"https://public-api-route-yournamespace.apps.rosa.xezue-pjejw-oy9.ag90.p3.openshiftapps.com\"" + echo + echo " # Using manual token:" + echo " export AMBIENT_TOKEN=\"sha256~_3FClshuberfakepO_BGI_tZg_not_real_token_Jv72pRN-r5o\"" + echo " export AMBIENT_PROJECT=\"anynamespace\"" + echo " export AMBIENT_API_URL=\"https://public-api-route-yournamespace.apps.rosa.xezue-pjejw-oy9.ag90.p3.openshiftapps.com\"" + echo + print_warning "Then run this script again: ./test.sh" + exit 1 + fi + + print_success "All required environment variables are set" +} + +# Validate environment variables +validate_environment() { + print_info "Validating environment variables..." + + # Check token format (should not contain AMBIENT_TOKEN= prefix) + if [[ "${AMBIENT_TOKEN}" == *"AMBIENT_TOKEN="* ]]; then + print_error "Invalid token format detected" + echo + print_info "Your token contains 'AMBIENT_TOKEN=' which will cause API errors." + echo "Current token: ${AMBIENT_TOKEN}" + echo + print_info "Please fix your token by removing the duplicate prefix:" + echo "export AMBIENT_TOKEN=\"${AMBIENT_TOKEN#*AMBIENT_TOKEN=}\"" + exit 1 + fi + + # Check if URL is valid format + if [[ ! "${AMBIENT_API_URL}" =~ ^https?:// ]]; then + print_warning "API URL should start with http:// or https://" + print_info "Current URL: ${AMBIENT_API_URL}" + fi + + # Check project name format (basic validation) + if [[ ! "${AMBIENT_PROJECT}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then + print_warning "Project name should follow Kubernetes naming conventions (lowercase alphanumeric with hyphens)" + print_info "Current project: ${AMBIENT_PROJECT}" + fi + + print_success "Environment variables validated" + + # Display configuration + echo + print_info "Configuration:" + echo " API URL: ${AMBIENT_API_URL}" + echo " Project: ${AMBIENT_PROJECT}" + echo " Token length: ${#AMBIENT_TOKEN} characters" + echo " Token prefix: ${AMBIENT_TOKEN:0:12}..." +} + +# Check if we're in the right directory +check_directory() { + if [[ ! -f "pyproject.toml" ]] || [[ ! -d "ambient_platform" ]] || [[ ! -f "examples/main.py" ]]; then + print_error "This script must be run from the python-sdk directory" + echo + print_info "Expected directory structure:" + echo " python-sdk/" + echo " ├── pyproject.toml" + echo " ├── ambient_platform/" + echo " └── examples/main.py" + echo + print_info "Please navigate to the correct directory:" + echo " cd /path/to/platform/components/ambient-sdk/python-sdk" + echo " ./test.sh" + exit 1 + fi + + print_success "Running from correct directory: $(pwd)" +} + +# Setup Python virtual environment +setup_venv() { + print_info "Setting up Python virtual environment..." + + if [[ ! -d "venv" ]]; then + print_info "Creating virtual environment..." + python -m venv venv + print_success "Virtual environment created" + else + print_success "Virtual environment already exists" + fi +} + +# Install dependencies +install_dependencies() { + print_info "Installing dependencies..." + + # Activate virtual environment + source venv/bin/activate + + # Install SDK in development mode + pip install -e . > /dev/null 2>&1 + + print_success "Dependencies installed successfully" +} + +# Test SDK import +test_import() { + print_info "Testing SDK import..." + + # Activate virtual environment + source venv/bin/activate + + # Test basic import + python -c "import ambient_platform; print('Import successful')" > /dev/null + + # Test specific imports + python -c " +from ambient_platform import ( + AmbientClient, + CreateSessionRequest, + RepoHTTP, + StatusPending, + StatusCompleted +) +print('All imports successful') +" > /dev/null + + print_success "SDK imports working correctly" +} + +# Run the example +run_example() { + print_info "Running Python SDK example..." + echo + + # Activate virtual environment and run example + source venv/bin/activate + python examples/main.py +} + +# Main execution +main() { + print_header + echo + + # Run all checks and setup + check_directory + check_environment + validate_environment + echo + + setup_venv + install_dependencies + test_import + echo + + print_success "Setup complete! Running example..." + echo + + run_example + + echo + print_success "Python SDK test completed successfully!" +} + +# Run main function +main "$@" \ No newline at end of file diff --git a/components/manifests/overlays/production/kustomization.yaml b/components/manifests/overlays/production/kustomization.yaml index f7c68213b..af422084c 100644 --- a/components/manifests/overlays/production/kustomization.yaml +++ b/components/manifests/overlays/production/kustomization.yaml @@ -5,7 +5,7 @@ metadata: name: vteam-production # Namespace for all resources (can be overridden with kustomize edit set namespace) -namespace: ambient-code +namespace: myproject # Resources (base + production-specific) # github-app-secret.yaml - excluded from automated deployment to prevent overwriting existing secret values @@ -38,28 +38,25 @@ images: newName: quay.io/ambient_code/vteam_backend newTag: latest - name: quay.io/ambient_code/vteam_backend:latest - newName: quay.io/ambient_code/vteam_backend + newName: image-registry.openshift-image-registry.svc:5000/myproject/vteam_backend newTag: latest - name: quay.io/ambient_code/vteam_claude_runner newName: quay.io/ambient_code/vteam_claude_runner newTag: latest - name: quay.io/ambient_code/vteam_claude_runner:latest - newName: quay.io/ambient_code/vteam_claude_runner + newName: image-registry.openshift-image-registry.svc:5000/myproject/vteam_claude_runner newTag: latest - name: quay.io/ambient_code/vteam_frontend newName: quay.io/ambient_code/vteam_frontend newTag: latest - name: quay.io/ambient_code/vteam_frontend:latest - newName: quay.io/ambient_code/vteam_frontend + newName: image-registry.openshift-image-registry.svc:5000/myproject/vteam_frontend newTag: latest - name: quay.io/ambient_code/vteam_operator newName: quay.io/ambient_code/vteam_operator newTag: latest - name: quay.io/ambient_code/vteam_operator:latest - newName: quay.io/ambient_code/vteam_operator - newTag: latest -- name: quay.io/ambient_code/vteam_state_sync:latest - newName: quay.io/ambient_code/vteam_state_sync + newName: image-registry.openshift-image-registry.svc:5000/myproject/vteam_operator newTag: latest - name: quay.io/ambient_code/vteam_public_api newName: quay.io/ambient_code/vteam_public_api @@ -67,3 +64,6 @@ images: - name: quay.io/ambient_code/vteam_public_api:latest newName: quay.io/ambient_code/vteam_public_api newTag: latest +- name: quay.io/ambient_code/vteam_state_sync:latest + newName: image-registry.openshift-image-registry.svc:5000/myproject/vteam_state_sync + newTag: latest