Skip to content

Commit 71e796a

Browse files
author
Huimin Tai
committed
feat: add SAP AI Core support to Go ADK runtime
Implement SAPAICoreModel in the Go ADK runtime, enabling the golang-adk image to use SAP AI Core models via the Orchestration Service. - Add SAPAICoreModel with OAuth2 token management (thread-safe cache) - Add automatic orchestration deployment URL discovery (1h TTL) - Implement model.LLM interface (GenerateContent) with Orchestration protocol: modules format request, orchestration_result/final_result SSE stream parsing - Add retry logic on 401/403/404/502/503/504 with cache invalidation - Add *adk.SAPAICore case in CreateLLM() Verified with go vet, existing unit tests, and oneshot E2E against live SAP AI Core (both streaming and non-streaming). Signed-off-by: Huimin Tai <huimin.tai@sap.com>
1 parent 1ad54cd commit 71e796a

3 files changed

Lines changed: 718 additions & 0 deletions

File tree

go/adk/pkg/agent/agent.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,16 @@ func CreateLLM(ctx context.Context, m adk.Model, log logr.Logger) (adkmodel.LLM,
289289
}
290290
return models.NewAnthropicVertexAIModelWithLogger(ctx, cfg, region, project, log)
291291

292+
case *adk.SAPAICore:
293+
cfg := models.SAPAICoreConfig{
294+
Model: m.Model,
295+
BaseUrl: m.BaseUrl,
296+
ResourceGroup: m.ResourceGroup,
297+
AuthUrl: m.AuthUrl,
298+
Headers: extractHeaders(m.Headers),
299+
}
300+
return models.NewSAPAICoreModelWithLogger(cfg, log)
301+
292302
default:
293303
return nil, fmt.Errorf("unsupported model type: %s", m.GetType())
294304
}

go/adk/pkg/models/sapaicore.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
package models
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"net/url"
8+
"os"
9+
"strings"
10+
"sync"
11+
"time"
12+
13+
"github.com/go-logr/logr"
14+
)
15+
16+
type SAPAICoreConfig struct {
17+
Model string
18+
BaseUrl string
19+
ResourceGroup string
20+
AuthUrl string
21+
Headers map[string]string
22+
}
23+
24+
type SAPAICoreModel struct {
25+
Config SAPAICoreConfig
26+
Logger logr.Logger
27+
28+
mu sync.Mutex
29+
token string
30+
tokenExpiresAt time.Time
31+
deploymentURL string
32+
deploymentURLAt time.Time
33+
httpClient *http.Client
34+
}
35+
36+
func NewSAPAICoreModelWithLogger(config SAPAICoreConfig, logger logr.Logger) (*SAPAICoreModel, error) {
37+
if config.BaseUrl == "" {
38+
return nil, fmt.Errorf("SAP AI Core requires base_url")
39+
}
40+
if config.ResourceGroup == "" {
41+
config.ResourceGroup = "default"
42+
}
43+
return &SAPAICoreModel{
44+
Config: config,
45+
Logger: logger,
46+
httpClient: &http.Client{Timeout: 5 * time.Minute},
47+
}, nil
48+
}
49+
50+
func (m *SAPAICoreModel) ensureToken() (string, error) {
51+
m.mu.Lock()
52+
defer m.mu.Unlock()
53+
54+
if m.token != "" && time.Now().Before(m.tokenExpiresAt.Add(-2*time.Minute)) {
55+
return m.token, nil
56+
}
57+
58+
clientID := os.Getenv("SAP_AI_CORE_CLIENT_ID")
59+
clientSecret := os.Getenv("SAP_AI_CORE_CLIENT_SECRET")
60+
if m.Config.AuthUrl == "" || clientID == "" || clientSecret == "" {
61+
return "", fmt.Errorf("SAP AI Core requires auth_url + SAP_AI_CORE_CLIENT_ID/SECRET env vars")
62+
}
63+
64+
tokenURL := strings.TrimRight(m.Config.AuthUrl, "/")
65+
if !strings.HasSuffix(tokenURL, "/oauth/token") {
66+
tokenURL += "/oauth/token"
67+
}
68+
69+
resp, err := m.httpClient.PostForm(tokenURL, url.Values{
70+
"grant_type": {"client_credentials"},
71+
"client_id": {clientID},
72+
"client_secret": {clientSecret},
73+
})
74+
if err != nil {
75+
return "", fmt.Errorf("OAuth2 token request failed: %w", err)
76+
}
77+
defer resp.Body.Close()
78+
79+
if resp.StatusCode != http.StatusOK {
80+
return "", fmt.Errorf("OAuth2 token request returned %d", resp.StatusCode)
81+
}
82+
83+
var tokenResp struct {
84+
AccessToken string `json:"access_token"`
85+
ExpiresIn int `json:"expires_in"`
86+
}
87+
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
88+
return "", fmt.Errorf("failed to decode OAuth2 token response: %w", err)
89+
}
90+
91+
m.token = tokenResp.AccessToken
92+
if tokenResp.ExpiresIn > 0 {
93+
m.tokenExpiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
94+
} else {
95+
m.tokenExpiresAt = time.Now().Add(12 * time.Hour)
96+
}
97+
return m.token, nil
98+
}
99+
100+
func (m *SAPAICoreModel) invalidateToken() {
101+
m.mu.Lock()
102+
defer m.mu.Unlock()
103+
m.token = ""
104+
m.tokenExpiresAt = time.Time{}
105+
}
106+
107+
func (m *SAPAICoreModel) resolveDeploymentURL() (string, error) {
108+
m.mu.Lock()
109+
if m.deploymentURL != "" && time.Now().Before(m.deploymentURLAt.Add(time.Hour)) {
110+
u := m.deploymentURL
111+
m.mu.Unlock()
112+
return u, nil
113+
}
114+
m.mu.Unlock()
115+
116+
token, err := m.ensureToken()
117+
if err != nil {
118+
return "", err
119+
}
120+
121+
reqURL := fmt.Sprintf("%s/v2/lm/deployments", m.Config.BaseUrl)
122+
req, err := http.NewRequest("GET", reqURL, nil)
123+
if err != nil {
124+
return "", err
125+
}
126+
req.Header.Set("Authorization", "Bearer "+token)
127+
req.Header.Set("AI-Resource-Group", m.Config.ResourceGroup)
128+
129+
resp, err := m.httpClient.Do(req)
130+
if err != nil {
131+
return "", fmt.Errorf("failed to list deployments: %w", err)
132+
}
133+
defer resp.Body.Close()
134+
135+
if resp.StatusCode != http.StatusOK {
136+
return "", fmt.Errorf("list deployments returned %d", resp.StatusCode)
137+
}
138+
139+
var result struct {
140+
Resources []struct {
141+
ID string `json:"id"`
142+
ScenarioID string `json:"scenarioId"`
143+
Status string `json:"status"`
144+
DeploymentURL string `json:"deploymentUrl"`
145+
CreatedAt string `json:"createdAt"`
146+
} `json:"resources"`
147+
}
148+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
149+
return "", fmt.Errorf("failed to decode deployments: %w", err)
150+
}
151+
152+
var best string
153+
var bestCreated string
154+
for _, d := range result.Resources {
155+
if d.ScenarioID == "orchestration" && d.Status == "RUNNING" && d.DeploymentURL != "" {
156+
if d.CreatedAt > bestCreated {
157+
best = d.DeploymentURL
158+
bestCreated = d.CreatedAt
159+
}
160+
}
161+
}
162+
if best == "" {
163+
return "", fmt.Errorf("no running orchestration deployment found in SAP AI Core")
164+
}
165+
166+
m.mu.Lock()
167+
m.deploymentURL = best
168+
m.deploymentURLAt = time.Now()
169+
m.mu.Unlock()
170+
171+
m.Logger.Info("Resolved SAP AI Core orchestration deployment", "url", best)
172+
return best, nil
173+
}
174+
175+
func (m *SAPAICoreModel) invalidateDeploymentURL() {
176+
m.mu.Lock()
177+
defer m.mu.Unlock()
178+
m.deploymentURL = ""
179+
m.deploymentURLAt = time.Time{}
180+
}

0 commit comments

Comments
 (0)