Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ go/bin/
# SQLite in-memory WAL artifacts from tests
file::memory:*
.vscode/
.history/
# Local Kind/Azure smoke artifacts only (values.local.yaml, live reports,
# Substrate clone, helper binaries). Never commit API keys or cluster dumps.
.local/
.cursor/rules
*.omc
.DS_Store
Expand Down
5 changes: 3 additions & 2 deletions docs/architecture/crds-and-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,12 @@ ModelConfigSpec
│ ├── baseUrl, temperature, maxTokens, topP
│ ├── frequencyPenalty, presencePenalty
│ ├── seed, n, timeout
│ └── reasoningEffort: none | minimal | low | medium | high
│ ├── reasoningEffort: none | minimal | low | medium | high
│ └── apiFormat: chatCompletions | responses
├── anthropic: AnthropicConfig
│ └── baseUrl, maxTokens, temperature, topP, topK
├── azureOpenAI: AzureOpenAIConfig
│ └── azureEndpoint, apiVersion, azureDeployment, etc.
│ └── azureEndpoint, apiVersion, azureDeployment, apiFormat: chatCompletions | responses
├── ollama: OllamaConfig
│ └── host, options
├── gemini: GeminiConfig
Expand Down
30 changes: 30 additions & 0 deletions examples/modelconfig-azure-openai-responses.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Azure OpenAI ModelConfig using the Responses API.
#
# Use this for Azure-hosted models that no longer accept Chat Completions
# (for example newer GPT-5 deployments). The runtime posts to
# /openai/v1/responses with the deployment name as `model` and without
# the api-version query parameter.
apiVersion: v1
kind: Secret
metadata:
name: azure-openai-api-key
namespace: kagent
type: Opaque
stringData:
AZUREOPENAI_API_KEY: your-azure-api-key-here
---
apiVersion: kagent.dev/v1alpha3
kind: ModelConfig
metadata:
name: azure-openai-responses
namespace: kagent
spec:
provider: AzureOpenAI
model: gpt-5
apiKeySecret: azure-openai-api-key
apiKeySecretKey: AZUREOPENAI_API_KEY
azureOpenAI:
azureEndpoint: https://YOUR_RESOURCE.openai.azure.com
azureDeployment: gpt-5
apiVersion: "2024-06-01"
apiFormat: responses
1 change: 1 addition & 0 deletions go/adk/pkg/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ func CreateLLM(ctx context.Context, m adk.Model, log logr.Logger) (adkmodel.LLM,
Endpoint: m.Endpoint,
Deployment: m.Deployment,
APIVersion: m.APIVersion,
APIFormat: m.APIFormat,
}
return models.NewAzureOpenAIModelWithLogger(ctx, cfg, log)

Expand Down
25 changes: 18 additions & 7 deletions go/adk/pkg/internal/azureai/azureai.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,17 @@ type ClientConfig struct {
// HTTPClient is the transport used by the client. Defaults to
// http.DefaultClient when nil.
HTTPClient *http.Client
// Responses selects the Azure OpenAI v1 Responses API base URL instead of
// the deployments-based API used by chat completions and embeddings.
Responses bool
}

// NewOpenAIClient builds an openai-go client for the Azure providers'
// OpenAI-compatible surface (chat + embeddings), rooted at
// {endpoint}/openai/deployments/{deployment}/ with the api-version query and
// implicit auth: the Api-Key header when APIKey is set, otherwise an Azure AD
// bearer token from Credential.
// OpenAI-compatible surface. The default mode is rooted at
// {endpoint}/openai/deployments/{deployment}/ with the api-version query; the
// Responses mode is rooted at {endpoint}/openai/v1/. Both modes use implicit
// auth: the Api-Key header when APIKey is set, otherwise an Azure AD bearer
// token from Credential.
//
// A NewAnthropicClient for the Anthropic (Claude) surface is planned and will
// live alongside this constructor, reusing the same credential and token helpers.
Expand All @@ -139,12 +143,19 @@ func NewOpenAIClient(cfg ClientConfig) (openai.Client, error) {
httpClient = http.DefaultClient
}

baseURL := strings.TrimSuffix(cfg.Endpoint, "/") + "/openai/deployments/" + url.PathEscape(cfg.Deployment) + "/"
opts := []option.RequestOption{
option.WithBaseURL(baseURL),
option.WithQueryAdd("api-version", cfg.APIVersion),
option.WithHTTPClient(httpClient),
}
if cfg.Responses {
// Azure OpenAI v1 Responses API: {endpoint}/openai/v1/responses
// with the deployment name in the request body. No api-version query.
opts = append(opts, option.WithBaseURL(strings.TrimSuffix(cfg.Endpoint, "/")+"/openai/v1/"))
} else {
opts = append(opts,
option.WithBaseURL(strings.TrimSuffix(cfg.Endpoint, "/")+"/openai/deployments/"+url.PathEscape(cfg.Deployment)+"/"),
option.WithQueryAdd("api-version", cfg.APIVersion),
)
}
if cfg.APIKey != "" {
// Azure authenticates via the Api-Key header. openai-go otherwise derives
// an Authorization: Bearer header from the OPENAI_API_KEY environment
Expand Down
132 changes: 132 additions & 0 deletions go/adk/pkg/internal/azureai/azureai_live_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package azureai

import (
"context"
"net/http"
"os"
"strings"
"sync"
"testing"
"time"

"github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/responses"
"github.com/openai/openai-go/v3/shared"
)

type recordingTransport struct {
base http.RoundTripper
mu sync.Mutex
urls []string
}

func (t *recordingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
t.mu.Lock()
t.urls = append(t.urls, req.URL.String())
t.mu.Unlock()
return t.base.RoundTrip(req)
}

func (t *recordingTransport) urlsCopy() []string {
t.mu.Lock()
defer t.mu.Unlock()
out := make([]string, len(t.urls))
copy(out, t.urls)
return out
}

func skipUnlessLiveAzure(t *testing.T) (endpoint, deployment, apiKey string) {
t.Helper()
if os.Getenv("AZURE_LIVE") != "1" {
t.Skip("set AZURE_LIVE=1 to run Foundry live tests")
}
apiKey = os.Getenv("AZURE_OPENAI_API_KEY")
endpoint = os.Getenv("AZURE_OPENAI_ENDPOINT")
deployment = os.Getenv("AZURE_OPENAI_DEPLOYMENT")
if apiKey == "" || endpoint == "" {
t.Skip("AZURE_OPENAI_API_KEY and AZURE_OPENAI_ENDPOINT are required")
}
if deployment == "" {
deployment = "gpt-4.1"
}
return endpoint, deployment, apiKey
}

func TestLiveAzureFoundryChatCompletionsPath(t *testing.T) {
endpoint, deployment, apiKey := skipUnlessLiveAzure(t)
rec := &recordingTransport{base: http.DefaultTransport}
client, err := NewOpenAIClient(ClientConfig{
Endpoint: endpoint,
Deployment: deployment,
APIVersion: "2024-06-01",
APIKey: apiKey,
HTTPClient: &http.Client{Transport: rec, Timeout: 60 * time.Second},
})
if err != nil {
t.Fatalf("NewOpenAIClient: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
resp, err := client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{
Model: shared.ChatModel(deployment),
Messages: []openai.ChatCompletionMessageParamUnion{openai.UserMessage("Reply with exactly: pong")},
})
if err != nil {
t.Fatalf("Chat.Completions.New: %v", err)
}
if resp.Choices[0].Message.Content == "" {
t.Fatal("empty chat completion content")
}
if !strings.Contains(strings.ToLower(resp.Choices[0].Message.Content), "pong") {
t.Fatalf("content = %q, want pong", resp.Choices[0].Message.Content)
}
joined := strings.Join(rec.urlsCopy(), "\n")
t.Logf("chatCompletions URLs:\n%s", joined)
if !strings.Contains(joined, "/chat/completions") {
t.Fatalf("did not observe /chat/completions, urls=%q", joined)
}
if !strings.Contains(joined, "api-version=") {
t.Fatalf("chat completions URL missing api-version, urls=%q", joined)
}
}

func TestLiveAzureFoundryResponsesPath(t *testing.T) {
endpoint, deployment, apiKey := skipUnlessLiveAzure(t)
rec := &recordingTransport{base: http.DefaultTransport}
client, err := NewOpenAIClient(ClientConfig{
Endpoint: endpoint,
Deployment: deployment,
APIVersion: "2024-06-01",
APIKey: apiKey,
Responses: true,
HTTPClient: &http.Client{Transport: rec, Timeout: 60 * time.Second},
})
if err != nil {
t.Fatalf("NewOpenAIClient: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
resp, err := client.Responses.New(ctx, responses.ResponseNewParams{
Model: shared.ResponsesModel(deployment),
Input: responses.ResponseNewParamsInputUnion{OfString: openai.String("Reply with exactly: pong")},
})
if err != nil {
t.Fatalf("Responses.New: %v", err)
}
text := strings.TrimSpace(resp.OutputText())
t.Logf("responses text=%q", text)
if text == "" {
t.Fatal("empty responses output")
}
if !strings.Contains(strings.ToLower(text), "pong") {
t.Fatalf("content = %q, want pong", text)
}
joined := strings.Join(rec.urlsCopy(), "\n")
t.Logf("responses URLs:\n%s", joined)
if !strings.Contains(joined, "/openai/v1/responses") {
t.Fatalf("did not observe /openai/v1/responses, urls=%q", joined)
}
if strings.Contains(joined, "api-version=") {
t.Fatalf("responses URL must not include api-version, urls=%q", joined)
}
}
51 changes: 51 additions & 0 deletions go/adk/pkg/internal/azureai/azureai_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package azureai

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
Expand All @@ -11,6 +12,8 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/responses"
"github.com/openai/openai-go/v3/shared"
)

type fakeCredential struct {
Expand Down Expand Up @@ -114,6 +117,54 @@ func TestNewOpenAIClientAPIKey(t *testing.T) {
}
}

func TestNewOpenAIClientResponses(t *testing.T) {
var gotPath, gotAPIVersion, gotAPIKey, gotAuth string
var gotBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAPIVersion = r.URL.Query().Get("api-version")
gotAPIKey = r.Header.Get("Api-Key")
gotAuth = r.Header.Get("Authorization")
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"id":"resp-test","object":"response","created_at":0,"status":"completed","model":"gpt-4o-deploy","output":[{"type":"message","id":"msg-1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok","annotations":[]}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2,"input_tokens_details":{"cached_tokens":0},"output_tokens_details":{"reasoning_tokens":0}}}`)
}))
defer server.Close()

client, err := NewOpenAIClient(ClientConfig{
Endpoint: server.URL,
Deployment: "gpt-4o-deploy",
APIVersion: "2024-06-01",
APIKey: "secret",
Responses: true,
})
if err != nil {
t.Fatalf("NewOpenAIClient() error = %v", err)
}
_, err = client.Responses.New(context.Background(), responses.ResponseNewParams{
Model: shared.ResponsesModel("gpt-4o-deploy"),
Input: responses.ResponseNewParamsInputUnion{OfString: openai.String("hello")},
})
if err != nil {
t.Fatalf("responses request error = %v", err)
}
if gotPath != "/openai/v1/responses" {
t.Fatalf("path = %q, want /openai/v1/responses", gotPath)
}
if gotAPIVersion != "" {
t.Fatalf("api-version = %q, want empty", gotAPIVersion)
}
if gotAPIKey != "secret" {
t.Fatalf("Api-Key = %q", gotAPIKey)
}
if gotAuth != "" {
t.Fatalf("Authorization = %q, want empty", gotAuth)
}
if gotBody["model"] != "gpt-4o-deploy" {
t.Fatalf("body model = %#v, want gpt-4o-deploy", gotBody["model"])
}
}

func TestNewOpenAIClientWorkloadIdentity(t *testing.T) {
var gotAuth, gotAPIKey string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
Loading
Loading