diff --git a/.gitea/actions/review/action.yml b/.gitea/actions/review/action.yml index 78f1005..655515c 100644 --- a/.gitea/actions/review/action.yml +++ b/.gitea/actions/review/action.yml @@ -26,18 +26,40 @@ inputs: required: false default: '' llm-base-url: - description: 'OpenAI-compatible LLM API base URL' - required: true + description: 'OpenAI-compatible LLM API base URL (not required for aicore provider)' + required: false + default: '' llm-api-key: - description: 'LLM API key' - required: true + description: 'LLM API key (not required for aicore provider)' + required: false + default: '' llm-model: description: 'LLM model name' required: true llm-provider: - description: 'LLM API provider: openai or anthropic (default openai)' + description: 'LLM API provider: openai, anthropic, or aicore (default openai)' required: false - default: 'openai' + default: 'openai' + aicore-client-id: + description: 'SAP AI Core client ID (required for aicore provider)' + required: false + default: '' + aicore-client-secret: + description: 'SAP AI Core client secret (required for aicore provider)' + required: false + default: '' + aicore-auth-url: + description: 'SAP AI Core authentication URL (required for aicore provider)' + required: false + default: '' + aicore-api-url: + description: 'SAP AI Core API URL (required for aicore provider)' + required: false + default: '' + aicore-resource-group: + description: 'SAP AI Core resource group (default: default)' + required: false + default: 'default' conventions-file: description: 'Path to conventions file in the repo (e.g. CLAUDE.md)' required: false @@ -155,6 +177,11 @@ runs: LLM_PROVIDER: ${{ inputs.llm-provider }} UPDATE_EXISTING: ${{ inputs.update-existing }} SYSTEM_PROMPT_FILE: ${{ inputs.system-prompt-file }} + AICORE_CLIENT_ID: ${{ inputs.aicore-client-id }} + AICORE_CLIENT_SECRET: ${{ inputs.aicore-client-secret }} + AICORE_AUTH_URL: ${{ inputs.aicore-auth-url }} + AICORE_API_URL: ${{ inputs.aicore-api-url }} + AICORE_RESOURCE_GROUP: ${{ inputs.aicore-resource-group }} run: | ARGS="" if [ "${{ inputs.dry-run }}" = "true" ]; then diff --git a/README.md b/README.md index 0060da8..97513f2 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ AI-powered code review bot for Gitea pull requests. Fetches diff + context, send ## Features -- **Multi-provider**: OpenAI-compatible and Anthropic Messages API +- **Multi-provider**: OpenAI-compatible, Anthropic Messages API, and SAP AI Core - **Context-aware**: Fetches full file content, conventions, language patterns, CI status - **Smart budget**: Automatically trims context to fit model token limits - **Idempotent reviews**: Posts new review, then cleans up stale ones (one review per bot) @@ -168,16 +168,41 @@ Prints the review to CI logs without posting to the PR. Useful for testing promp llm-provider: anthropic ``` +### Using SAP AI Core + +For SAP environments with AI Core deployments, use the `aicore` provider for native authentication: + +```yaml +- uses: https://gitea.weiker.me/rodin/review-bot/.gitea/actions/review@v0.1.0 + with: + reviewer-token: ${{ secrets.REVIEW_TOKEN }} + reviewer-name: aicore-review + llm-model: anthropic--claude-4.6-sonnet # or gpt-5 + llm-provider: aicore + aicore-client-id: ${{ secrets.AICORE_CLIENT_ID }} + aicore-client-secret: ${{ secrets.AICORE_CLIENT_SECRET }} + aicore-auth-url: ${{ secrets.AICORE_AUTH_URL }} + aicore-api-url: ${{ secrets.AICORE_API_URL }} + aicore-resource-group: default +``` + +AI Core handles OAuth token management and deployment discovery automatically. Model names must match the deployment name in AI Core (e.g. `anthropic--claude-4.6-sonnet`, `gpt-5`). + ## Action Inputs | Input | Required | Default | Description | |-------|----------|---------|-------------| | `reviewer-token` | Yes | — | Gitea token for posting reviews (needs `write:issue`, `write:repository`) | | `reviewer-name` | No | `""` | Logical identity for this reviewer. Used as sentinel for idempotent cleanup. Set this when running multiple review bots on the same PR. | -| `llm-base-url` | Yes | — | LLM API base URL | -| `llm-api-key` | Yes | — | LLM API key | +| `llm-base-url` | No* | `""` | LLM API base URL (required unless using aicore provider) | +| `llm-api-key` | No* | `""` | LLM API key (required unless using aicore provider) | | `llm-model` | Yes | — | Model name | -| `llm-provider` | No | `openai` | API provider: `openai` or `anthropic` | +| `llm-provider` | No | `openai` | API provider: `openai`, `anthropic`, or `aicore` | +| `aicore-client-id` | No** | `""` | SAP AI Core client ID | +| `aicore-client-secret` | No** | `""` | SAP AI Core client secret | +| `aicore-auth-url` | No** | `""` | SAP AI Core authentication URL | +| `aicore-api-url` | No** | `""` | SAP AI Core API URL | +| `aicore-resource-group` | No | `default` | SAP AI Core resource group | | `conventions-file` | No | `""` | Path to coding conventions file in the repo | | `patterns-repo` | No | `""` | Comma-separated repos with language patterns (e.g. `rodin/go-patterns`) | | `patterns-files` | No | `README.md` | Files/directories to fetch from pattern repos | @@ -188,6 +213,9 @@ Prints the review to CI logs without posting to the PR. Useful for testing promp | `update-existing` | No | `true` | Delete previous review from same bot before posting. Accepts: true/1/yes or false/0/no | | `version` | No | `latest` | review-bot version to install | +*Required for `openai` and `anthropic` providers, not for `aicore`. +**Required only for `aicore` provider. + ## Runner Requirements The composite action requires these tools on the runner: diff --git a/cmd/review-bot/main.go b/cmd/review-bot/main.go index 021ccc3..e26735c 100644 --- a/cmd/review-bot/main.go +++ b/cmd/review-bot/main.go @@ -69,7 +69,13 @@ func main() { dryRun := flag.Bool("dry-run", false, "Print review to stdout instead of posting") llmTemp := flag.Float64("llm-temperature", envOrDefaultFloat("LLM_TEMPERATURE", 0), "LLM temperature (0 = server default)") llmTimeout := flag.Int("llm-timeout", envOrDefaultInt("LLM_TIMEOUT", 300), "LLM request timeout in seconds (default 300)") - llmProvider := flag.String("llm-provider", envOrDefault("LLM_PROVIDER", "openai"), "LLM API provider: openai or anthropic") + llmProvider := flag.String("llm-provider", envOrDefault("LLM_PROVIDER", "openai"), "LLM API provider: openai, anthropic, or aicore") + // AI Core specific flags (only used when provider=aicore) + aicoreClientID := flag.String("aicore-client-id", envOrDefault("AICORE_CLIENT_ID", ""), "SAP AI Core client ID (for provider=aicore)") + aicoreClientSecret := flag.String("aicore-client-secret", envOrDefault("AICORE_CLIENT_SECRET", ""), "SAP AI Core client secret (for provider=aicore)") + aicoreAuthURL := flag.String("aicore-auth-url", envOrDefault("AICORE_AUTH_URL", ""), "SAP AI Core auth URL (for provider=aicore)") + aicoreAPIURL := flag.String("aicore-api-url", envOrDefault("AICORE_API_URL", ""), "SAP AI Core API URL (for provider=aicore)") + aicoreResourceGroup := flag.String("aicore-resource-group", envOrDefault("AICORE_RESOURCE_GROUP", "default"), "SAP AI Core resource group (for provider=aicore)") flag.Parse() @@ -84,10 +90,20 @@ func main() { slog.Info("review-bot starting", "version", version) // Validate required fields - if *giteaURL == "" || *repo == "" || *prNum == "" || *reviewerToken == "" || - *llmBaseURL == "" || *llmAPIKey == "" || *llmModel == "" { + // For aicore provider, llm-base-url and llm-api-key are not required + isAICore := llm.Provider(*llmProvider) == llm.ProviderAICore + if *giteaURL == "" || *repo == "" || *prNum == "" || *reviewerToken == "" || *llmModel == "" { fmt.Fprintf(os.Stderr, "Error: missing required flags or environment variables\n\n") - fmt.Fprintf(os.Stderr, "Required: --gitea-url, --repo, --pr, --reviewer-token, --llm-base-url, --llm-api-key, --llm-model\n") + fmt.Fprintf(os.Stderr, "Required: --gitea-url, --repo, --pr, --reviewer-token, --llm-model\n") + os.Exit(1) + } + if !isAICore && (*llmBaseURL == "" || *llmAPIKey == "") { + fmt.Fprintf(os.Stderr, "Error: --llm-base-url and --llm-api-key are required for provider=%s\n", *llmProvider) + os.Exit(1) + } + if isAICore && (*aicoreClientID == "" || *aicoreClientSecret == "" || *aicoreAuthURL == "" || *aicoreAPIURL == "") { + fmt.Fprintf(os.Stderr, "Error: AI Core credentials required for provider=aicore\n\n") + fmt.Fprintf(os.Stderr, "Required: --aicore-client-id, --aicore-client-secret, --aicore-auth-url, --aicore-api-url\n") os.Exit(1) } @@ -125,8 +141,17 @@ func main() { switch llm.Provider(*llmProvider) { case llm.ProviderOpenAI, llm.ProviderAnthropic: llmClient.WithProvider(llm.Provider(*llmProvider)) + case llm.ProviderAICore: + llmClient.WithAICore(llm.AICoreConfig{ + ClientID: *aicoreClientID, + ClientSecret: *aicoreClientSecret, + AuthURL: *aicoreAuthURL, + APIURL: *aicoreAPIURL, + ResourceGroup: *aicoreResourceGroup, + }) + slog.Info("using SAP AI Core provider", "resource_group", *aicoreResourceGroup) default: - slog.Error("invalid LLM provider", "provider", *llmProvider, "valid", "openai, anthropic") + slog.Error("invalid LLM provider", "provider", *llmProvider, "valid", "openai, anthropic, aicore") os.Exit(1) } if *llmTimeout > 0 { diff --git a/llm/aicore.go b/llm/aicore.go new file mode 100644 index 0000000..4bcfa43 --- /dev/null +++ b/llm/aicore.go @@ -0,0 +1,368 @@ +package llm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// AICoreConfig holds SAP AI Core authentication and connection settings. +type AICoreConfig struct { + ClientID string + ClientSecret string + AuthURL string + APIURL string + ResourceGroup string +} + +// AICoreClient wraps AI Core authentication and deployment discovery. +// Thread-safe for concurrent use after construction. +type AICoreClient struct { + config AICoreConfig + http *http.Client + + mu sync.RWMutex + token string + tokenExpiry time.Time + deployments map[string]deployment // model name -> deployment info +} + +type deployment struct { + ID string + URL string +} + +// NewAICoreClient creates a new AI Core client with the given configuration. +func NewAICoreClient(cfg AICoreConfig) *AICoreClient { + return &AICoreClient{ + config: cfg, + http: &http.Client{Timeout: 30 * time.Second}, + deployments: make(map[string]deployment), + } +} + +// getToken returns a valid OAuth token, refreshing if necessary. +func (c *AICoreClient) getToken(ctx context.Context) (string, error) { + c.mu.RLock() + if c.token != "" && time.Now().Add(5*time.Minute).Before(c.tokenExpiry) { + token := c.token + c.mu.RUnlock() + return token, nil + } + c.mu.RUnlock() + + c.mu.Lock() + defer c.mu.Unlock() + + // Double-check after acquiring write lock + if c.token != "" && time.Now().Add(5*time.Minute).Before(c.tokenExpiry) { + return c.token, nil + } + + token, expiry, err := c.fetchToken(ctx) + if err != nil { + return "", err + } + c.token = token + c.tokenExpiry = expiry + return token, nil +} + +func (c *AICoreClient) fetchToken(ctx context.Context) (string, time.Time, error) { + tokenURL := strings.TrimRight(c.config.AuthURL, "/") + "/oauth/token" + + data := url.Values{} + data.Set("grant_type", "client_credentials") + data.Set("client_id", c.config.ClientID) + data.Set("client_secret", c.config.ClientSecret) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(data.Encode())) + if err != nil { + return "", time.Time{}, fmt.Errorf("create token request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.http.Do(req) + if err != nil { + return "", time.Time{}, fmt.Errorf("token request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", time.Time{}, fmt.Errorf("read token response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", time.Time{}, fmt.Errorf("token request failed (status %d): %s", resp.StatusCode, string(body)) + } + + var tokenResp struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + } + if err := json.Unmarshal(body, &tokenResp); err != nil { + return "", time.Time{}, fmt.Errorf("parse token response: %w", err) + } + + if tokenResp.AccessToken == "" { + return "", time.Time{}, fmt.Errorf("empty access token in response") + } + + expiry := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) + return tokenResp.AccessToken, expiry, nil +} + +// getDeploymentURL returns the deployment URL for a model, fetching deployments if needed. +func (c *AICoreClient) getDeploymentURL(ctx context.Context, model string) (string, error) { + c.mu.RLock() + if d, ok := c.deployments[model]; ok { + c.mu.RUnlock() + return d.URL, nil + } + c.mu.RUnlock() + + // Fetch token first (before acquiring write lock to avoid deadlock) + token, err := c.getToken(ctx) + if err != nil { + return "", fmt.Errorf("get token for deployments: %w", err) + } + + c.mu.Lock() + defer c.mu.Unlock() + + // Double-check after acquiring write lock + if d, ok := c.deployments[model]; ok { + return d.URL, nil + } + + if err := c.fetchDeployments(ctx, token); err != nil { + return "", err + } + + if d, ok := c.deployments[model]; ok { + return d.URL, nil + } + return "", fmt.Errorf("no deployment found for model %q", model) +} + +func (c *AICoreClient) fetchDeployments(ctx context.Context, token string) error { + + deployURL := strings.TrimRight(c.config.APIURL, "/") + "/v2/lm/deployments" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, deployURL, nil) + if err != nil { + return fmt.Errorf("create deployments request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("AI-Resource-Group", c.config.ResourceGroup) + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("deployments request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read deployments response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("deployments request failed (status %d): %s", resp.StatusCode, string(body)) + } + + var deployResp struct { + Resources []struct { + ID string `json:"id"` + DeploymentURL string `json:"deploymentUrl"` + Status string `json:"status"` + Details struct { + Resources struct { + BackendDetails struct { + Model struct { + Name string `json:"name"` + } `json:"model"` + } `json:"backend_details"` + } `json:"resources"` + } `json:"details"` + } `json:"resources"` + } + if err := json.Unmarshal(body, &deployResp); err != nil { + return fmt.Errorf("parse deployments response: %w", err) + } + + for _, r := range deployResp.Resources { + if r.Status != "RUNNING" { + continue + } + modelName := r.Details.Resources.BackendDetails.Model.Name + if modelName == "" { + continue + } + c.deployments[modelName] = deployment{ + ID: r.ID, + URL: r.DeploymentURL, + } + } + + return nil +} + +// CompleteAnthropic sends a request to an Anthropic model via AI Core. +func (c *AICoreClient) CompleteAnthropic(ctx context.Context, model string, messages []Message, maxTokens int, temperature float64) (string, error) { + deployURL, err := c.getDeploymentURL(ctx, model) + if err != nil { + return "", err + } + + token, err := c.getToken(ctx) + if err != nil { + return "", err + } + + // Extract system message + var system string + var userMessages []anthropicMsg + for _, m := range messages { + if m.Role == "system" { + system = m.Content + } else { + userMessages = append(userMessages, anthropicMsg{ + Role: m.Role, + Content: m.Content, + }) + } + } + + reqBody := anthropicRequest{ + Model: model, + MaxTokens: maxTokens, + System: system, + Messages: userMessages, + } + if temperature > 0 { + reqBody.Temperature = temperature + } + + data, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("marshal request: %w", err) + } + + // AI Core uses /invoke for Anthropic models + invokeURL := strings.TrimRight(deployURL, "/") + "/invoke" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, invokeURL, bytes.NewReader(data)) + if err != nil { + return "", fmt.Errorf("create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("AI-Resource-Group", c.config.ResourceGroup) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return "", fmt.Errorf("AI Core request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("AI Core API error (status %d): %s", resp.StatusCode, string(body)) + } + + var anthropicResp anthropicResponse + if err := json.Unmarshal(body, &anthropicResp); err != nil { + return "", fmt.Errorf("parse response: %w", err) + } + + if len(anthropicResp.Content) == 0 { + return "", fmt.Errorf("no content in response") + } + + var sb strings.Builder + for _, block := range anthropicResp.Content { + if block.Type == "text" { + sb.WriteString(block.Text) + } + } + result := sb.String() + if result == "" { + return "", fmt.Errorf("no text content in response") + } + return result, nil +} + +// CompleteOpenAI sends a request to an OpenAI model via AI Core. +func (c *AICoreClient) CompleteOpenAI(ctx context.Context, model string, messages []Message, temperature float64) (string, error) { + deployURL, err := c.getDeploymentURL(ctx, model) + if err != nil { + return "", err + } + + token, err := c.getToken(ctx) + if err != nil { + return "", err + } + + reqBody := ChatRequest{ + Model: model, + Temperature: temperature, + Messages: messages, + } + + data, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("marshal request: %w", err) + } + + // AI Core uses /chat/completions?api-version=2024-12-01-preview for OpenAI models + chatURL := strings.TrimRight(deployURL, "/") + "/chat/completions?api-version=2024-12-01-preview" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, chatURL, bytes.NewReader(data)) + if err != nil { + return "", fmt.Errorf("create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("AI-Resource-Group", c.config.ResourceGroup) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return "", fmt.Errorf("AI Core request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("AI Core API error (status %d): %s", resp.StatusCode, string(body)) + } + + var openaiResp ChatResponse + if err := json.Unmarshal(body, &openaiResp); err != nil { + return "", fmt.Errorf("parse response: %w", err) + } + + if len(openaiResp.Choices) == 0 { + return "", fmt.Errorf("no choices in response") + } + return openaiResp.Choices[0].Message.Content, nil +} + +// IsAnthropicModel returns true if the model name indicates an Anthropic model. +func IsAnthropicModel(model string) bool { + return strings.HasPrefix(model, "anthropic--") || strings.Contains(model, "claude") +} diff --git a/llm/aicore_test.go b/llm/aicore_test.go new file mode 100644 index 0000000..e137982 --- /dev/null +++ b/llm/aicore_test.go @@ -0,0 +1,477 @@ +package llm + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestAICoreClient_TokenFetch(t *testing.T) { + tokenCalls := int32(0) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/oauth/token" { + atomic.AddInt32(&tokenCalls, 1) + if r.Method != http.MethodPost { + t.Errorf("expected POST for token, got %s", r.Method) + } + if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { + t.Errorf("expected form content type") + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "test-token-123", + "expires_in": 3600, + }) + return + } + t.Errorf("unexpected path: %s", r.URL.Path) + })) + defer server.Close() + + client := NewAICoreClient(AICoreConfig{ + ClientID: "test-id", + ClientSecret: "test-secret", + AuthURL: server.URL, + APIURL: server.URL, + ResourceGroup: "default", + }) + + token, err := client.getToken(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token != "test-token-123" { + t.Errorf("expected token 'test-token-123', got %q", token) + } + + // Second call should use cached token + token2, err := client.getToken(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token2 != "test-token-123" { + t.Errorf("expected cached token") + } + if atomic.LoadInt32(&tokenCalls) != 1 { + t.Errorf("expected 1 token call (cached), got %d", tokenCalls) + } +} + +func TestAICoreClient_DeploymentFetch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/oauth/token" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "test-token", + "expires_in": 3600, + }) + return + } + if r.URL.Path == "/v2/lm/deployments" { + if r.Header.Get("Authorization") != "Bearer test-token" { + t.Errorf("expected Bearer auth") + } + if r.Header.Get("AI-Resource-Group") != "default" { + t.Errorf("expected resource group header") + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "resources": []map[string]interface{}{ + { + "id": "deploy-123", + "deploymentUrl": "https://example.com/v2/inference/deployments/deploy-123", + "status": "RUNNING", + "details": map[string]interface{}{ + "resources": map[string]interface{}{ + "backend_details": map[string]interface{}{ + "model": map[string]interface{}{ + "name": "anthropic--claude-4.6-sonnet", + }, + }, + }, + }, + }, + { + "id": "deploy-456", + "deploymentUrl": "https://example.com/v2/inference/deployments/deploy-456", + "status": "STOPPED", + "details": map[string]interface{}{ + "resources": map[string]interface{}{ + "backend_details": map[string]interface{}{ + "model": map[string]interface{}{ + "name": "gpt-5", + }, + }, + }, + }, + }, + { + "id": "deploy-789", + "deploymentUrl": "https://example.com/v2/inference/deployments/deploy-789", + "status": "RUNNING", + "details": map[string]interface{}{ + "resources": map[string]interface{}{ + "backend_details": map[string]interface{}{ + "model": map[string]interface{}{ + "name": "gpt-5", + }, + }, + }, + }, + }, + }, + }) + return + } + t.Errorf("unexpected path: %s", r.URL.Path) + })) + defer server.Close() + + client := NewAICoreClient(AICoreConfig{ + ClientID: "test-id", + ClientSecret: "test-secret", + AuthURL: server.URL, + APIURL: server.URL, + ResourceGroup: "default", + }) + + // Should find running deployment + url, err := client.getDeploymentURL(context.Background(), "anthropic--claude-4.6-sonnet") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if url != "https://example.com/v2/inference/deployments/deploy-123" { + t.Errorf("unexpected URL: %s", url) + } + + // Should find running gpt-5, not stopped one + url, err = client.getDeploymentURL(context.Background(), "gpt-5") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if url != "https://example.com/v2/inference/deployments/deploy-789" { + t.Errorf("unexpected URL: %s", url) + } + + // Should error on unknown model + _, err = client.getDeploymentURL(context.Background(), "unknown-model") + if err == nil { + t.Error("expected error for unknown model") + } +} + +func TestAICoreClient_CompleteAnthropic(t *testing.T) { + // Use a pointer to capture the server URL for use in the handler + var baseURL string + mux := http.NewServeMux() + mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "test-token", + "expires_in": 3600, + }) + }) + mux.HandleFunc("/v2/lm/deployments", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "resources": []map[string]interface{}{ + { + "id": "deploy-anthropic", + "deploymentUrl": baseURL + "/deployments/anthropic", + "status": "RUNNING", + "details": map[string]interface{}{ + "resources": map[string]interface{}{ + "backend_details": map[string]interface{}{ + "model": map[string]interface{}{ + "name": "anthropic--claude-4.6-sonnet", + }, + }, + }, + }, + }, + }, + }) + }) + mux.HandleFunc("/deployments/anthropic/invoke", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test-token" { + t.Errorf("expected Bearer auth on invoke") + } + var req anthropicRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + if req.Model != "anthropic--claude-4.6-sonnet" { + t.Errorf("expected model name in request") + } + if req.System != "You are helpful" { + t.Errorf("expected system prompt: %q", req.System) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "content": []map[string]interface{}{ + {"type": "text", "text": "Hello from AI Core!"}, + }, + }) + }) + + server := httptest.NewServer(mux) + baseURL = server.URL + defer server.Close() + + client := NewAICoreClient(AICoreConfig{ + ClientID: "test-id", + ClientSecret: "test-secret", + AuthURL: server.URL, + APIURL: server.URL, + ResourceGroup: "default", + }) + + result, err := client.CompleteAnthropic(context.Background(), "anthropic--claude-4.6-sonnet", []Message{ + {Role: "system", Content: "You are helpful"}, + {Role: "user", Content: "Hello"}, + }, 8192, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "Hello from AI Core!" { + t.Errorf("expected 'Hello from AI Core!', got %q", result) + } +} + +func TestAICoreClient_CompleteOpenAI(t *testing.T) { + var baseURL string + mux := http.NewServeMux() + mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "test-token", + "expires_in": 3600, + }) + }) + mux.HandleFunc("/v2/lm/deployments", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "resources": []map[string]interface{}{ + { + "id": "deploy-openai", + "deploymentUrl": baseURL + "/deployments/openai", + "status": "RUNNING", + "details": map[string]interface{}{ + "resources": map[string]interface{}{ + "backend_details": map[string]interface{}{ + "model": map[string]interface{}{ + "name": "gpt-5", + }, + }, + }, + }, + }, + }, + }) + }) + mux.HandleFunc("/deployments/openai/chat/completions", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("api-version") != "2024-12-01-preview" { + t.Errorf("expected api-version query param") + } + var req ChatRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + if req.Model != "gpt-5" { + t.Errorf("expected model gpt-5, got %s", req.Model) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(ChatResponse{ + Choices: []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + }{ + {Message: struct { + Content string `json:"content"` + }{Content: "Hello from GPT-5!"}}, + }, + }) + }) + + server := httptest.NewServer(mux) + baseURL = server.URL + defer server.Close() + + client := NewAICoreClient(AICoreConfig{ + ClientID: "test-id", + ClientSecret: "test-secret", + AuthURL: server.URL, + APIURL: server.URL, + ResourceGroup: "default", + }) + + result, err := client.CompleteOpenAI(context.Background(), "gpt-5", []Message{ + {Role: "user", Content: "Hello"}, + }, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "Hello from GPT-5!" { + t.Errorf("expected 'Hello from GPT-5!', got %q", result) + } +} + +func TestIsAnthropicModel(t *testing.T) { + tests := []struct { + model string + expected bool + }{ + {"anthropic--claude-4.6-sonnet", true}, + {"anthropic--claude-4.6-opus", true}, + {"claude-sonnet-4", true}, + {"gpt-5", false}, + {"gpt-4.1", false}, + {"llama-3", false}, + } + + for _, tt := range tests { + got := IsAnthropicModel(tt.model) + if got != tt.expected { + t.Errorf("IsAnthropicModel(%q) = %v, want %v", tt.model, got, tt.expected) + } + } +} + +func TestAICoreClient_TokenExpiry(t *testing.T) { + tokenCalls := int32(0) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/oauth/token" { + call := atomic.AddInt32(&tokenCalls, 1) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "token-" + string(rune('0'+call)), + "expires_in": 1, // 1 second expiry + }) + return + } + })) + defer server.Close() + + client := NewAICoreClient(AICoreConfig{ + ClientID: "test-id", + ClientSecret: "test-secret", + AuthURL: server.URL, + APIURL: server.URL, + ResourceGroup: "default", + }) + + // First call + token1, _ := client.getToken(context.Background()) + + // Force token expiry by manipulating expiry time + client.mu.Lock() + client.tokenExpiry = time.Now().Add(-time.Hour) + client.mu.Unlock() + + // Should fetch new token + token2, _ := client.getToken(context.Background()) + + if token1 == token2 { + t.Error("expected different tokens after expiry") + } + if atomic.LoadInt32(&tokenCalls) != 2 { + t.Errorf("expected 2 token calls, got %d", tokenCalls) + } +} + +func TestClient_WithAICore(t *testing.T) { + client := NewClient("http://example.com", "key", "model") + if client.provider != ProviderOpenAI { + t.Errorf("expected default provider openai, got %s", client.provider) + } + + client.WithAICore(AICoreConfig{ + ClientID: "id", + ClientSecret: "secret", + AuthURL: "https://auth.example.com", + APIURL: "https://api.example.com", + ResourceGroup: "default", + }) + + if client.provider != ProviderAICore { + t.Errorf("expected provider aicore, got %s", client.provider) + } + if client.aicore == nil { + t.Error("expected aicore client to be set") + } +} + +func TestClient_CompleteAICore(t *testing.T) { + var baseURL string + mux := http.NewServeMux() + mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "test-token", + "expires_in": 3600, + }) + }) + mux.HandleFunc("/v2/lm/deployments", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "resources": []map[string]interface{}{ + { + "id": "deploy-test", + "deploymentUrl": baseURL + "/deployments/test", + "status": "RUNNING", + "details": map[string]interface{}{ + "resources": map[string]interface{}{ + "backend_details": map[string]interface{}{ + "model": map[string]interface{}{ + "name": "gpt-5", + }, + }, + }, + }, + }, + }, + }) + }) + mux.HandleFunc("/deployments/test/chat/completions", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(ChatResponse{ + Choices: []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + }{ + {Message: struct { + Content string `json:"content"` + }{Content: "AI Core via Client works!"}}, + }, + }) + }) + + server := httptest.NewServer(mux) + baseURL = server.URL + defer server.Close() + + client := NewClient("", "", "gpt-5").WithAICore(AICoreConfig{ + ClientID: "test-id", + ClientSecret: "test-secret", + AuthURL: server.URL, + APIURL: server.URL, + ResourceGroup: "default", + }) + + result, err := client.Complete(context.Background(), []Message{ + {Role: "user", Content: "Hello"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, "AI Core via Client works!") { + t.Errorf("unexpected result: %s", result) + } +} diff --git a/llm/client.go b/llm/client.go index 4fc0485..aac3541 100644 --- a/llm/client.go +++ b/llm/client.go @@ -1,6 +1,6 @@ // Package llm provides clients for LLM chat completion APIs. // -// Supports OpenAI-compatible (default) and Anthropic Messages API providers. +// Supports OpenAI-compatible (default), Anthropic Messages API, and SAP AI Core providers. package llm import ( @@ -22,6 +22,8 @@ const ( ProviderOpenAI Provider = "openai" // ProviderAnthropic uses the Anthropic Messages API endpoint. ProviderAnthropic Provider = "anthropic" + // ProviderAICore uses SAP AI Core with OAuth authentication. + ProviderAICore Provider = "aicore" ) // Client calls an LLM chat completion API. @@ -35,6 +37,7 @@ type Client struct { temperature float64 provider Provider http *http.Client + aicore *AICoreClient // Only set when provider is aicore } // NewClient creates a new LLM client. Default provider is OpenAI-compatible. @@ -60,12 +63,20 @@ func (c *Client) WithTemperature(t float64) *Client { return c } -// WithProvider sets the API provider format (openai or anthropic). +// WithProvider sets the API provider format (openai, anthropic, or aicore). func (c *Client) WithProvider(p Provider) *Client { c.provider = p return c } +// WithAICore configures the client to use SAP AI Core for authentication. +// This sets the provider to aicore automatically. +func (c *Client) WithAICore(cfg AICoreConfig) *Client { + c.provider = ProviderAICore + c.aicore = NewAICoreClient(cfg) + return c +} + // Message represents a chat message. type Message struct { Role string `json:"role"` @@ -82,6 +93,8 @@ func (c *Client) Complete(ctx context.Context, messages []Message) (string, erro switch c.provider { case ProviderAnthropic: result, err = c.completeAnthropic(ctx, messages) + case ProviderAICore: + result, err = c.completeAICore(ctx, messages) default: result, err = c.completeOpenAI(ctx, messages) } @@ -106,6 +119,18 @@ func (c *Client) Complete(ctx context.Context, messages []Message) (string, erro return "", err } +// completeAICore routes to AI Core using the appropriate endpoint based on model type. +func (c *Client) completeAICore(ctx context.Context, messages []Message) (string, error) { + if c.aicore == nil { + return "", fmt.Errorf("AI Core client not configured") + } + + if IsAnthropicModel(c.model) { + return c.aicore.CompleteAnthropic(ctx, c.model, messages, 8192, c.temperature) + } + return c.aicore.CompleteOpenAI(ctx, c.model, messages, c.temperature) +} + // isRetryableError returns true for transient errors worth retrying. func isRetryableError(err error) bool { if err == nil {