feat: native SAP AI Core support
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 14s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 35s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m30s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 2m0s
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 14s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 35s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m30s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 2m0s
Add native SAP AI Core provider that handles OAuth token management and deployment discovery automatically. This eliminates the need for the external LLM proxy when running in SAP environments. Changes: - Add AICoreClient with OAuth token caching and deployment URL discovery - Support both Anthropic and OpenAI models via AI Core deployments - Update CI to use native AI Core provider - Update action inputs to accept AI Core credentials - Update README with AI Core configuration examples Model names must match AI Core deployment names (e.g. anthropic--claude-4.6-sonnet, gpt-5).
This commit is contained in:
@@ -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
|
||||
@@ -165,6 +187,11 @@ runs:
|
||||
SYSTEM_PROMPT_FILE: ${{ inputs.system-prompt-file }}
|
||||
PERSONA: ${{ inputs.persona }}
|
||||
PERSONA_FILE: ${{ inputs.persona-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
|
||||
|
||||
+10
-11
@@ -18,8 +18,10 @@ jobs:
|
||||
- run: go vet ./...
|
||||
- run: go build -o review-bot ./cmd/review-bot
|
||||
|
||||
# Self-review: builds from source since we're pre-release
|
||||
# Models configured to match SAP AI Core deployments
|
||||
# Self-review using native SAP AI Core provider
|
||||
# Models must match SAP AI Core deployments
|
||||
# Available models: gpt-5, anthropic--claude-4.6-sonnet, anthropic--claude-4.6-opus
|
||||
# Removed gpt-4.1, gpt-5-mini, gpt-4.1-mini - not deployed on AI Core
|
||||
review:
|
||||
runs-on: ubuntu-24.04
|
||||
if: github.event_name == 'pull_request'
|
||||
@@ -29,18 +31,12 @@ jobs:
|
||||
include:
|
||||
- name: sonnet
|
||||
token_secret: SONNET_REVIEW_TOKEN
|
||||
provider: anthropic
|
||||
llm_path: /anthropic/v1
|
||||
model: anthropic--claude-4.6-sonnet
|
||||
- name: gpt
|
||||
token_secret: GPT_REVIEW_TOKEN
|
||||
provider: openai
|
||||
llm_path: /openai/v1
|
||||
model: gpt-5
|
||||
- name: security
|
||||
token_secret: SECURITY_REVIEW_TOKEN
|
||||
provider: openai
|
||||
llm_path: /openai/v1
|
||||
model: gpt-5
|
||||
system_prompt_file: SECURITY_REVIEW.md
|
||||
steps:
|
||||
@@ -56,10 +52,13 @@ jobs:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REVIEWER_TOKEN: ${{ secrets[matrix.token_secret] }}
|
||||
REVIEWER_NAME: ${{ matrix.name }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}${{ matrix.llm_path }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
LLM_PROVIDER: aicore
|
||||
LLM_MODEL: ${{ matrix.model }}
|
||||
LLM_PROVIDER: ${{ matrix.provider }}
|
||||
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: ${{ secrets.AICORE_RESOURCE_GROUP }}
|
||||
CONVENTIONS_FILE: "CONVENTIONS.md"
|
||||
PATTERNS_REPO: "rodin/go-patterns"
|
||||
PATTERNS_FILES: "README.md,patterns/"
|
||||
|
||||
@@ -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 |
|
||||
@@ -190,6 +215,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:
|
||||
|
||||
+31
-5
@@ -69,10 +69,17 @@ 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")
|
||||
personaName := flag.String("persona", envOrDefault("PERSONA", ""), "Built-in persona name (security, architect, docs)")
|
||||
personaFile := flag.String("persona-file", envOrDefault("PERSONA_FILE", ""), "Path to persona JSON file")
|
||||
// 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()
|
||||
flag.Parse()
|
||||
|
||||
if *versionFlag {
|
||||
@@ -86,10 +93,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)
|
||||
}
|
||||
|
||||
@@ -157,8 +174,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 {
|
||||
|
||||
+391
@@ -0,0 +1,391 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AICoreOpenAIAPIVersion is the API version used for OpenAI models through AI Core.
|
||||
// Update this when SAP AI Core releases a new stable version.
|
||||
const AICoreOpenAIAPIVersion = "2024-12-01-preview"
|
||||
|
||||
// maxErrorBodyLen limits the length of response bodies included in error messages
|
||||
// to prevent leaking potentially sensitive upstream details in logs.
|
||||
const maxErrorBodyLen = 200
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Design: The deployment cache is populated once and never invalidated. This is
|
||||
// acceptable for short-lived CI runner processes, but longer-lived deployments
|
||||
// may want to add a TTL or re-fetch on errors.
|
||||
type AICoreClient struct {
|
||||
config AICoreConfig
|
||||
http *http.Client
|
||||
|
||||
mu sync.RWMutex
|
||||
token string
|
||||
tokenExpiry time.Time
|
||||
deployments map[string]string // model name -> deployment URL
|
||||
}
|
||||
|
||||
// NewAICoreClient creates a new AI Core client with the given configuration.
|
||||
// The client uses a default 5-minute timeout; use WithTimeout to customize.
|
||||
func NewAICoreClient(cfg AICoreConfig) *AICoreClient {
|
||||
return &AICoreClient{
|
||||
config: cfg,
|
||||
http: &http.Client{Timeout: 5 * time.Minute},
|
||||
deployments: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// WithTimeout sets the HTTP request timeout for AI Core calls.
|
||||
// This should be called during construction, before concurrent use.
|
||||
func (c *AICoreClient) WithTimeout(d time.Duration) *AICoreClient {
|
||||
c.http.Timeout = d
|
||||
return c
|
||||
}
|
||||
|
||||
// truncateBody truncates a response body for inclusion in error messages.
|
||||
// This prevents leaking potentially sensitive upstream response details in logs.
|
||||
func truncateBody(body []byte) string {
|
||||
if len(body) <= maxErrorBodyLen {
|
||||
return string(body)
|
||||
}
|
||||
return string(body[:maxErrorBodyLen]) + "..."
|
||||
}
|
||||
|
||||
// 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, truncateBody(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.
|
||||
// getDeploymentURL returns the deployment URL for a model, fetching deployments if needed.
|
||||
// Also returns a valid token for use by the caller, avoiding redundant getToken calls.
|
||||
//
|
||||
// Note: The token is fetched before acquiring the write lock to avoid holding the lock
|
||||
// during network I/O. In rare cases where multiple goroutines race and one waits a long
|
||||
// time for the write lock, the token could theoretically expire. The 5-minute refresh
|
||||
// buffer in getToken makes this extremely unlikely in practice.
|
||||
func (c *AICoreClient) getDeploymentURL(ctx context.Context, model string) (deployURL, token string, err error) {
|
||||
c.mu.RLock()
|
||||
if u, ok := c.deployments[model]; ok {
|
||||
c.mu.RUnlock()
|
||||
// Still need a token for the caller
|
||||
token, err = c.getToken(ctx)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("get token: %w", err)
|
||||
}
|
||||
return u, token, nil
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
// Fetch token first (before acquiring write lock to avoid holding lock during I/O)
|
||||
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 u, ok := c.deployments[model]; ok {
|
||||
return u, token, nil
|
||||
}
|
||||
|
||||
if err := c.fetchDeployments(ctx, token); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if u, ok := c.deployments[model]; ok {
|
||||
return u, token, 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, truncateBody(body))
|
||||
}
|
||||
|
||||
var deployResp struct {
|
||||
Resources []struct {
|
||||
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] = 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, token, err := c.getDeploymentURL(ctx, model)
|
||||
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{
|
||||
AnthropicVersion: "bedrock-2023-05-31", // SAP AI Core uses Bedrock format
|
||||
// Model omitted - AI Core deployment already specifies 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, truncateBody(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, token, err := c.getDeploymentURL(ctx, model)
|
||||
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=<version> for OpenAI models
|
||||
chatURL := strings.TrimRight(deployURL, "/") + "/chat/completions?api-version=" + AICoreOpenAIAPIVersion
|
||||
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, truncateBody(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.
|
||||
// SAP AI Core uses "anthropic--" prefix for Anthropic models (e.g., "anthropic--claude-3-5-sonnet").
|
||||
func IsAnthropicModel(model string) bool {
|
||||
return strings.HasPrefix(model, "anthropic--")
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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) {
|
||||
// baseURL is set after server creation; captured by closure in handlers
|
||||
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.AnthropicVersion != "bedrock-2023-05-31" {
|
||||
t.Errorf("expected bedrock anthropic_version 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") != AICoreOpenAIAPIVersion {
|
||||
t.Errorf("expected api-version %s, got %s", AICoreOpenAIAPIVersion, r.URL.Query().Get("api-version"))
|
||||
}
|
||||
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
|
||||
}{
|
||||
// SAP AI Core uses "anthropic--" prefix for Anthropic models
|
||||
{"anthropic--claude-4.6-sonnet", true},
|
||||
{"anthropic--claude-4.6-opus", true},
|
||||
{"anthropic--claude-3-5-sonnet", true},
|
||||
// Non-prefixed model names are not detected as Anthropic
|
||||
// (SAP AI Core always uses the prefix for Anthropic models)
|
||||
{"claude-sonnet-4", false},
|
||||
{"gpt-5", false},
|
||||
{"gpt-4.1", false},
|
||||
{"llama-3", false},
|
||||
{"my-claude-model", false}, // Avoid false positives on "claude" substring
|
||||
}
|
||||
|
||||
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": fmt.Sprintf("token-%d", 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, err := client.getToken(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("first getToken: %v", err)
|
||||
}
|
||||
|
||||
// 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, err := client.getToken(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("second getToken: %v", err)
|
||||
}
|
||||
|
||||
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 TestAICoreClient_WithTimeout(t *testing.T) {
|
||||
client := NewAICoreClient(AICoreConfig{
|
||||
ClientID: "test-id",
|
||||
ClientSecret: "test-secret",
|
||||
AuthURL: "https://auth.example.com",
|
||||
APIURL: "https://api.example.com",
|
||||
ResourceGroup: "default",
|
||||
})
|
||||
|
||||
// Default timeout is 5 minutes
|
||||
if client.http.Timeout != 5*time.Minute {
|
||||
t.Errorf("expected default timeout 5m, got %v", client.http.Timeout)
|
||||
}
|
||||
|
||||
// WithTimeout should update the timeout
|
||||
client.WithTimeout(10 * time.Minute)
|
||||
if client.http.Timeout != 10*time.Minute {
|
||||
t.Errorf("expected timeout 10m, got %v", client.http.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
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_WithTimeout_PropagatestoAICore(t *testing.T) {
|
||||
client := NewClient("http://example.com", "key", "model").
|
||||
WithAICore(AICoreConfig{
|
||||
ClientID: "id",
|
||||
ClientSecret: "secret",
|
||||
AuthURL: "https://auth.example.com",
|
||||
APIURL: "https://api.example.com",
|
||||
ResourceGroup: "default",
|
||||
})
|
||||
|
||||
// Default should be 5 minutes (inherited from parent client)
|
||||
if client.aicore.http.Timeout != 5*time.Minute {
|
||||
t.Errorf("expected aicore default timeout 5m, got %v", client.aicore.http.Timeout)
|
||||
}
|
||||
|
||||
// WithTimeout should propagate to AI Core client
|
||||
client.WithTimeout(15 * time.Minute)
|
||||
if client.http.Timeout != 15*time.Minute {
|
||||
t.Errorf("expected parent timeout 15m, got %v", client.http.Timeout)
|
||||
}
|
||||
if client.aicore.http.Timeout != 15*time.Minute {
|
||||
t.Errorf("expected aicore timeout 15m, got %v", client.aicore.http.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
+38
-7
@@ -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.
|
||||
@@ -49,8 +52,12 @@ func NewClient(baseURL, apiKey, model string) *Client {
|
||||
}
|
||||
|
||||
// WithTimeout sets the HTTP request timeout for LLM calls (default 5 minutes).
|
||||
// When using AI Core, this also sets the timeout on the AI Core client.
|
||||
func (c *Client) WithTimeout(d time.Duration) *Client {
|
||||
c.http.Timeout = d
|
||||
if c.aicore != nil {
|
||||
c.aicore.WithTimeout(d)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -60,12 +67,21 @@ 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.
|
||||
// The AI Core client inherits the current HTTP timeout from this client.
|
||||
func (c *Client) WithAICore(cfg AICoreConfig) *Client {
|
||||
c.provider = ProviderAICore
|
||||
c.aicore = NewAICoreClient(cfg).WithTimeout(c.http.Timeout)
|
||||
return c
|
||||
}
|
||||
|
||||
// Message represents a chat message.
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
@@ -82,6 +98,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 +124,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 {
|
||||
@@ -176,11 +206,12 @@ func (c *Client) completeOpenAI(ctx context.Context, messages []Message) (string
|
||||
// --- Anthropic Messages API implementation ---
|
||||
|
||||
type anthropicRequest struct {
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
System string `json:"system,omitempty"`
|
||||
Messages []anthropicMsg `json:"messages"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
AnthropicVersion string `json:"anthropic_version,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
System string `json:"system,omitempty"`
|
||||
Messages []anthropicMsg `json:"messages"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
}
|
||||
|
||||
type anthropicMsg struct {
|
||||
|
||||
Reference in New Issue
Block a user