diff --git a/.gitignore b/.gitignore index fae65ddeae..d1b7004f32 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/docs/architecture/crds-and-types.md b/docs/architecture/crds-and-types.md index 7855bca8b2..da3ac8a32c 100644 --- a/docs/architecture/crds-and-types.md +++ b/docs/architecture/crds-and-types.md @@ -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 diff --git a/examples/modelconfig-azure-openai-responses.yaml b/examples/modelconfig-azure-openai-responses.yaml new file mode 100644 index 0000000000..0bba0b906d --- /dev/null +++ b/examples/modelconfig-azure-openai-responses.yaml @@ -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 diff --git a/go/adk/pkg/agent/agent.go b/go/adk/pkg/agent/agent.go index 089abe1c30..ba90438c27 100644 --- a/go/adk/pkg/agent/agent.go +++ b/go/adk/pkg/agent/agent.go @@ -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) diff --git a/go/adk/pkg/internal/azureai/azureai.go b/go/adk/pkg/internal/azureai/azureai.go index 5353475ecd..30e5865c43 100644 --- a/go/adk/pkg/internal/azureai/azureai.go +++ b/go/adk/pkg/internal/azureai/azureai.go @@ -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. @@ -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 diff --git a/go/adk/pkg/internal/azureai/azureai_live_test.go b/go/adk/pkg/internal/azureai/azureai_live_test.go new file mode 100644 index 0000000000..2e1ec63145 --- /dev/null +++ b/go/adk/pkg/internal/azureai/azureai_live_test.go @@ -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) + } +} diff --git a/go/adk/pkg/internal/azureai/azureai_test.go b/go/adk/pkg/internal/azureai/azureai_test.go index c3974f2939..1ba1bf9d59 100644 --- a/go/adk/pkg/internal/azureai/azureai_test.go +++ b/go/adk/pkg/internal/azureai/azureai_test.go @@ -2,6 +2,7 @@ package azureai import ( "context" + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -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 { @@ -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) { diff --git a/go/adk/pkg/models/azure_openai_test.go b/go/adk/pkg/models/azure_openai_test.go index 68a1a28fd5..fa10d9a51b 100644 --- a/go/adk/pkg/models/azure_openai_test.go +++ b/go/adk/pkg/models/azure_openai_test.go @@ -2,7 +2,9 @@ package models import ( "context" + "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "strings" @@ -10,7 +12,10 @@ import ( "github.com/go-logr/logr" "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/responses" "github.com/openai/openai-go/v3/shared" + adkmodel "google.golang.org/adk/v2/model" + "google.golang.org/genai" ) func TestNewAzureOpenAIModelWithLoggerRequiresEndpoint(t *testing.T) { @@ -118,6 +123,93 @@ func TestAzureOpenAIAPIKeySendsApiKeyHeader(t *testing.T) { } } +func TestAzureOpenAIResponsesUsesV1Endpoint(t *testing.T) { + t.Setenv("AZURE_OPENAI_API_KEY", "test-key") + + var gotPath, gotAPIVersion string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAPIVersion = r.URL.Query().Get("api-version") + 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}}}`) + })) + t.Cleanup(server.Close) + + model, err := NewAzureOpenAIModelWithLogger(context.Background(), &AzureOpenAIConfig{ + Model: "gpt-4o-deploy", + Endpoint: server.URL, + Deployment: "gpt-4o-deploy", + APIVersion: "2024-06-01", + APIFormat: OpenAIAPIFormatResponses, + }, logr.Discard()) + if err != nil { + t.Fatalf("NewAzureOpenAIModelWithLogger() error = %v", err) + } + + _, err = model.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) + } +} + +func TestAzureOpenAIGenerateContentResponsesUsesV1Endpoint(t *testing.T) { + t.Setenv("AZURE_OPENAI_API_KEY", "test-key") + + var gotPath, gotAPIVersion 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") + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &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}}}`) + })) + t.Cleanup(server.Close) + + model, err := NewAzureOpenAIModelWithLogger(context.Background(), &AzureOpenAIConfig{ + Model: "gpt-4o-deploy", + Endpoint: server.URL, + Deployment: "gpt-4o-deploy", + APIVersion: "2024-06-01", + APIFormat: OpenAIAPIFormatResponses, + }, logr.Discard()) + if err != nil { + t.Fatalf("NewAzureOpenAIModelWithLogger() error = %v", err) + } + + var got *adkmodel.LLMResponse + for resp, genErr := range model.GenerateContent(context.Background(), &adkmodel.LLMRequest{ + Contents: []*genai.Content{{Role: "user", Parts: []*genai.Part{{Text: "hello"}}}}, + }, false) { + if genErr != nil { + t.Fatalf("GenerateContent error = %v", genErr) + } + got = resp + } + 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 gotBody["model"] != "gpt-4o-deploy" { + t.Fatalf("body model = %#v, want gpt-4o-deploy", gotBody["model"]) + } + if got == nil || got.Content == nil || len(got.Content.Parts) != 1 || got.Content.Parts[0].Text != "ok" { + t.Fatalf("response = %#v", got) + } +} + // TestAzureOpenAIPassthroughInjectsBearerToken verifies that with // APIKeyPassthrough enabled, the placeholder Api-Key is overwritten per request // by the bearer token carried in the context. diff --git a/go/adk/pkg/models/openai.go b/go/adk/pkg/models/openai.go index 9b532cd50e..0847a8532b 100644 --- a/go/adk/pkg/models/openai.go +++ b/go/adk/pkg/models/openai.go @@ -42,6 +42,7 @@ type AzureOpenAIConfig struct { Endpoint string Deployment string APIVersion string + APIFormat string // credential overrides the Azure credential used for the implicit Workload // Identity auth path. When nil, azureai.NewDefaultCredential is used. It is @@ -118,10 +119,10 @@ func newOpenAIModelFromConfig(config *OpenAIConfig, apiKey string, logger logr.L } // NewAzureOpenAIModelWithLogger creates a new Azure OpenAI model instance with a logger. -// It targets the Azure OpenAI OpenAI-compatible data plane -// (POST {endpoint}/openai/deployments/{deployment}/chat/completions) through the -// shared azureai client. Endpoint, api-version, and deployment come from the -// model config, with AZURE_OPENAI_ENDPOINT / OPENAI_API_VERSION env fallbacks. +// It targets the Azure OpenAI OpenAI-compatible data plane through the shared +// azureai client. Chat Completions uses the deployments endpoint; Responses uses +// the Azure OpenAI v1 endpoint. Endpoint, api-version, and deployment come from +// the model config, with AZURE_OPENAI_ENDPOINT / OPENAI_API_VERSION env fallbacks. // // Authentication is implicit and mirrors Foundry: the incoming bearer token when // APIKeyPassthrough is enabled; otherwise the AZURE_OPENAI_API_KEY Api-Key header @@ -161,6 +162,7 @@ func NewAzureOpenAIModelWithLogger(ctx context.Context, config *AzureOpenAIConfi Deployment: deployment, APIVersion: apiVersion, HTTPClient: httpClient, + Responses: config.APIFormat == OpenAIAPIFormatResponses, } // Implicit auth: the incoming bearer token when APIKeyPassthrough is enabled @@ -184,12 +186,13 @@ func NewAzureOpenAIModelWithLogger(ctx context.Context, config *AzureOpenAIConfi return nil, err } if logger.GetSink() != nil { - logger.Info("Initialized Azure OpenAI model", "model", config.Model, "deployment", deployment, "endpoint", endpoint, "apiVersion", apiVersion) + logger.Info("Initialized Azure OpenAI model", "model", config.Model, "deployment", deployment, "endpoint", endpoint, "apiVersion", apiVersion, "apiFormat", config.APIFormat) } return &OpenAIModel{ Config: &OpenAIConfig{ TransportConfig: config.TransportConfig, Model: deployment, + APIFormat: config.APIFormat, }, Client: client, IsAzure: true, diff --git a/go/api/adk/types.go b/go/api/adk/types.go index 14bfb66ce5..49b826483c 100644 --- a/go/api/adk/types.go +++ b/go/api/adk/types.go @@ -139,6 +139,7 @@ type AzureOpenAI struct { Endpoint string `json:"endpoint,omitempty"` Deployment string `json:"deployment,omitempty"` APIVersion string `json:"api_version,omitempty"` + APIFormat string `json:"api_format,omitempty"` MaxTokens *int `json:"max_tokens,omitempty"` Temperature *float64 `json:"temperature,omitempty"` TopP *float64 `json:"top_p,omitempty"` diff --git a/go/api/config/crd/bases/kagent.dev_modelconfigs.yaml b/go/api/config/crd/bases/kagent.dev_modelconfigs.yaml index ef6bace3b5..d8f3c83d91 100644 --- a/go/api/config/crd/bases/kagent.dev_modelconfigs.yaml +++ b/go/api/config/crd/bases/kagent.dev_modelconfigs.yaml @@ -1100,6 +1100,16 @@ spec: azureOpenAI: description: Azure OpenAI-specific configuration properties: + apiFormat: + default: chatCompletions + description: |- + APIFormat selects which Azure OpenAI HTTP API the runtime uses for this model. + chatCompletions (default) uses the deployments API. + responses uses the Azure OpenAI v1 Responses API. + enum: + - chatCompletions + - responses + type: string apiVersion: description: API version for the Azure OpenAI API type: string diff --git a/go/api/v1alpha3/modelconfig_cel_test.go b/go/api/v1alpha3/modelconfig_cel_test.go index 11a8a32f3c..4a4d903cb3 100644 --- a/go/api/v1alpha3/modelconfig_cel_test.go +++ b/go/api/v1alpha3/modelconfig_cel_test.go @@ -184,6 +184,77 @@ func TestOpenAIConfigValidation(t *testing.T) { }, wantReject: "reasoningEffort", }, + { + name: "Azure OpenAI omitted format accepted", + build: func() ctrl_client.Object { + return &ModelConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "mc-azure-default-format", Namespace: ns}, + Spec: ModelConfigSpec{ + Model: "gpt-4o", + Provider: ModelProviderAzureOpenAI, + AzureOpenAI: &AzureOpenAIConfig{ + Endpoint: "https://example.openai.azure.com", + APIVersion: "2024-06-01", + }, + }, + } + }, + }, + { + name: "Azure OpenAI Chat Completions format accepted", + build: func() ctrl_client.Object { + format := OpenAIAPIFormatChatCompletions + return &ModelConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "mc-azure-chat-completions", Namespace: ns}, + Spec: ModelConfigSpec{ + Model: "gpt-4o", + Provider: ModelProviderAzureOpenAI, + AzureOpenAI: &AzureOpenAIConfig{ + Endpoint: "https://example.openai.azure.com", + APIVersion: "2024-06-01", + APIFormat: &format, + }, + }, + } + }, + }, + { + name: "Azure OpenAI Responses format accepted", + build: func() ctrl_client.Object { + format := OpenAIAPIFormatResponses + return &ModelConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "mc-azure-responses", Namespace: ns}, + Spec: ModelConfigSpec{ + Model: "gpt-4o", + Provider: ModelProviderAzureOpenAI, + AzureOpenAI: &AzureOpenAIConfig{ + Endpoint: "https://example.openai.azure.com", + APIVersion: "2024-06-01", + APIFormat: &format, + }, + }, + } + }, + }, + { + name: "Azure OpenAI unknown format rejected", + build: func() ctrl_client.Object { + format := OpenAIAPIFormat("invalid") + return &ModelConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "mc-azure-invalid-format", Namespace: ns}, + Spec: ModelConfigSpec{ + Model: "gpt-4o", + Provider: ModelProviderAzureOpenAI, + AzureOpenAI: &AzureOpenAIConfig{ + Endpoint: "https://example.openai.azure.com", + APIVersion: "2024-06-01", + APIFormat: &format, + }, + }, + } + }, + wantReject: "apiFormat", + }, } for _, c := range cases { diff --git a/go/api/v1alpha3/modelconfig_types.go b/go/api/v1alpha3/modelconfig_types.go index 015575a62e..296afb55b2 100644 --- a/go/api/v1alpha3/modelconfig_types.go +++ b/go/api/v1alpha3/modelconfig_types.go @@ -215,7 +215,7 @@ type OpenAIConfig struct { TokenExchange *TokenExchangeConfig `json:"tokenExchange,omitempty"` } -// OpenAIAPIFormat selects the OpenAI HTTP API shape used by the Go ADK runtime. +// OpenAIAPIFormat selects the OpenAI HTTP API shape used by the Go and Python runtimes. // +kubebuilder:validation:Enum=chatCompletions;responses type OpenAIAPIFormat string @@ -264,6 +264,13 @@ type AzureOpenAIConfig struct { // Top-p sampling parameter // +optional TopP string `json:"topP,omitempty"` + + // APIFormat selects which Azure OpenAI HTTP API the runtime uses for this model. + // chatCompletions (default) uses the deployments API. + // responses uses the Azure OpenAI v1 Responses API. + // +optional + // +kubebuilder:default=chatCompletions + APIFormat *OpenAIAPIFormat `json:"apiFormat,omitempty"` } // OllamaConfig contains Ollama-specific configuration options diff --git a/go/api/v1alpha3/zz_generated.deepcopy.go b/go/api/v1alpha3/zz_generated.deepcopy.go index a42d4f8f64..4a51961ede 100644 --- a/go/api/v1alpha3/zz_generated.deepcopy.go +++ b/go/api/v1alpha3/zz_generated.deepcopy.go @@ -855,6 +855,11 @@ func (in *ArtifactSource) DeepCopy() *ArtifactSource { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AzureOpenAIConfig) DeepCopyInto(out *AzureOpenAIConfig) { *out = *in + if in.APIFormat != nil { + in, out := &in.APIFormat, &out.APIFormat + *out = new(OpenAIAPIFormat) + **out = **in + } if in.MaxTokens != nil { in, out := &in.MaxTokens, &out.MaxTokens *out = new(int) diff --git a/go/core/internal/controller/translator/agent/adk_api_translator.go b/go/core/internal/controller/translator/agent/adk_api_translator.go index 90249e017b..9472781a0e 100644 --- a/go/core/internal/controller/translator/agent/adk_api_translator.go +++ b/go/core/internal/controller/translator/agent/adk_api_translator.go @@ -558,6 +558,9 @@ func (a *adkApiTranslator) translateModel(ctx context.Context, namespace, modelC TopP: utils.ParseStringToFloat64(model.Spec.AzureOpenAI.TopP), MaxTokens: model.Spec.AzureOpenAI.MaxTokens, } + if model.Spec.AzureOpenAI.APIFormat != nil && *model.Spec.AzureOpenAI.APIFormat != "" { + azureOpenAI.APIFormat = string(*model.Spec.AzureOpenAI.APIFormat) + } // Populate TLS fields in BaseModel populateTLSFields(&azureOpenAI.BaseModel, model.Spec.TLS) azureOpenAI.APIKeyPassthrough = model.Spec.APIKeyPassthrough diff --git a/go/core/internal/controller/translator/agent/azure_openai_translator_test.go b/go/core/internal/controller/translator/agent/azure_openai_translator_test.go index 2c7ea234fb..954024a925 100644 --- a/go/core/internal/controller/translator/agent/azure_openai_translator_test.go +++ b/go/core/internal/controller/translator/agent/azure_openai_translator_test.go @@ -21,6 +21,7 @@ import ( func TestTranslateModelAzureOpenAISettings(t *testing.T) { scheme := schemev1.Scheme require.NoError(t, v1alpha3.AddToScheme(scheme)) + apiFormat := v1alpha3.OpenAIAPIFormatResponses modelConfig := &v1alpha3.ModelConfig{ ObjectMeta: metav1.ObjectMeta{Name: "azure-model", Namespace: "default"}, @@ -31,6 +32,7 @@ func TestTranslateModelAzureOpenAISettings(t *testing.T) { Endpoint: "https://example.openai.azure.com/", DeploymentName: "gpt-4o-deploy", APIVersion: "2024-06-01", + APIFormat: &apiFormat, }, }, } @@ -46,11 +48,39 @@ func TestTranslateModelAzureOpenAISettings(t *testing.T) { assert.Equal(t, "https://example.openai.azure.com/", azureModel.Endpoint) assert.Equal(t, "gpt-4o-deploy", azureModel.Deployment) assert.Equal(t, "2024-06-01", azureModel.APIVersion) + assert.Equal(t, "responses", azureModel.APIFormat) assert.Equal(t, "https://example.openai.azure.com/", envVarValue(t, deploymentData.EnvVars, env.AzureOpenAIEndpoint.Name())) assert.Equal(t, "2024-06-01", envVarValue(t, deploymentData.EnvVars, env.OpenAIAPIVersion.Name())) } +func TestTranslateModelAzureOpenAIOmitsAPIFormatByDefault(t *testing.T) { + scheme := schemev1.Scheme + require.NoError(t, v1alpha3.AddToScheme(scheme)) + + modelConfig := &v1alpha3.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "azure-model", Namespace: "default"}, + Spec: v1alpha3.ModelConfigSpec{ + Model: "gpt-4o", + Provider: v1alpha3.ModelProviderAzureOpenAI, + AzureOpenAI: &v1alpha3.AzureOpenAIConfig{ + Endpoint: "https://example.openai.azure.com/", + DeploymentName: "gpt-4o-deploy", + APIVersion: "2024-06-01", + }, + }, + } + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(modelConfig).Build() + tr := &adkApiTranslator{kube: kubeClient} + + model, _, _, err := tr.translateModel(context.Background(), "default", "azure-model") + require.NoError(t, err) + + azureModel, ok := model.(*adk.AzureOpenAI) + require.True(t, ok) + assert.Empty(t, azureModel.APIFormat) +} + // TestModelToEmbeddingConfigAzureOpenAI verifies the Azure data-plane settings flow into // the embedding config so azure_openai memory embeddings resolve the same // data-plane URL as the chat model. diff --git a/go/core/internal/service/model/discovery_test.go b/go/core/internal/service/model/discovery_test.go index b80e6af14c..77562a009c 100644 --- a/go/core/internal/service/model/discovery_test.go +++ b/go/core/internal/service/model/discovery_test.go @@ -86,7 +86,7 @@ func TestDiscoverySupportedProviderDefinitions(t *testing.T) { "tokenExchange", }, modelProviders[0].OptionalParams) assert.Equal(t, []string{"azureEndpoint", "apiVersion"}, modelProviders[2].RequiredParams) - assert.Equal(t, []string{"azureDeployment", "azureAdToken", "temperature", "maxTokens", "topP"}, modelProviders[2].OptionalParams) + assert.Equal(t, []string{"azureDeployment", "azureAdToken", "temperature", "maxTokens", "topP", "apiFormat"}, modelProviders[2].OptionalParams) assert.Equal(t, []string{"deployment", "endpoint"}, modelProviders[3].RequiredParams) assert.Equal(t, []string{"apiVersion"}, modelProviders[3].OptionalParams) assert.Equal(t, []string{"", "maxOutputTokens", "candidateCount", "responseMimeType"}, modelProviders[6].OptionalParams) diff --git a/go/core/v2/translator/model.go b/go/core/v2/translator/model.go index 5a9f7f68ad..6af3333b8f 100644 --- a/go/core/v2/translator/model.go +++ b/go/core/v2/translator/model.go @@ -389,6 +389,9 @@ func (c *Compiler) translateModel(ctx context.Context, model *v1alpha3.ModelConf TopP: utils.ParseStringToFloat64(model.Spec.AzureOpenAI.TopP), MaxTokens: model.Spec.AzureOpenAI.MaxTokens, } + if model.Spec.AzureOpenAI.APIFormat != nil && *model.Spec.AzureOpenAI.APIFormat != "" { + azureOpenAI.APIFormat = string(*model.Spec.AzureOpenAI.APIFormat) + } // Populate TLS fields in BaseModel populateTLSFields(&azureOpenAI.BaseModel, model.Spec.TLS) azureOpenAI.APIKeyPassthrough = model.Spec.APIKeyPassthrough diff --git a/helm/README.md b/helm/README.md index 8f924b7716..cdfbb1717b 100644 --- a/helm/README.md +++ b/helm/README.md @@ -19,6 +19,9 @@ helm install kagent ./helm/kagent/ --namespace kagent --set providers.default=ol helm install kagent ./helm/kagent/ --namespace kagent --set providers.default=openAI --set providers.openAI.apiKey=your-openai-api-key helm install kagent ./helm/kagent/ --namespace kagent --set providers.default=anthropic --set providers.anthropic.apiKey=your-anthropic-api-key helm install kagent ./helm/kagent/ --namespace kagent --set providers.default=azureOpenAI --set providers.azureOpenAI.apiKey=your-openai-api-key + +# Azure OpenAI Responses API (for models that no longer accept Chat Completions): +# --set providers.azureOpenAI.config.apiFormat=responses ``` ### Using Make diff --git a/helm/kagent-crds/templates/kagent.dev_modelconfigs.yaml b/helm/kagent-crds/templates/kagent.dev_modelconfigs.yaml index ef6bace3b5..d8f3c83d91 100644 --- a/helm/kagent-crds/templates/kagent.dev_modelconfigs.yaml +++ b/helm/kagent-crds/templates/kagent.dev_modelconfigs.yaml @@ -1100,6 +1100,16 @@ spec: azureOpenAI: description: Azure OpenAI-specific configuration properties: + apiFormat: + default: chatCompletions + description: |- + APIFormat selects which Azure OpenAI HTTP API the runtime uses for this model. + chatCompletions (default) uses the deployments API. + responses uses the Azure OpenAI v1 Responses API. + enum: + - chatCompletions + - responses + type: string apiVersion: description: API version for the Azure OpenAI API type: string diff --git a/helm/kagent/tests/modelconfig_test.yaml b/helm/kagent/tests/modelconfig_test.yaml index 1f4b612374..109a7e9137 100644 --- a/helm/kagent/tests/modelconfig_test.yaml +++ b/helm/kagent/tests/modelconfig_test.yaml @@ -67,6 +67,34 @@ tests: path: spec.apiKeySecretKey value: AZUREOPENAI_API_KEY + - it: should render azure openai apiFormat when configured + set: + providers: + default: azureOpenAI + azureOpenAI: + provider: AzureOpenAI + model: "gpt-5" + apiKeySecretRef: kagent-azure-openai + apiKeySecretKey: AZUREOPENAI_API_KEY + config: + apiVersion: "2024-06-01" + azureEndpoint: "https://example.openai.azure.com" + azureDeployment: "gpt-5" + apiFormat: responses + asserts: + - equal: + path: spec.provider + value: "AzureOpenAI" + - equal: + path: spec.azureOpenAI.apiFormat + value: responses + - equal: + path: spec.azureOpenAI.azureEndpoint + value: "https://example.openai.azure.com" + - equal: + path: spec.azureOpenAI.azureDeployment + value: "gpt-5" + - it: should configure ollama provider set: providers: diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index 1e22645854..485db9af18 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -635,6 +635,9 @@ providers: azureAdToken: "" azureDeployment: "" azureEndpoint: "" + # Use responses for models that require the Azure OpenAI v1 Responses API + # instead of Chat Completions. Default is chatCompletions. + # apiFormat: responses gemini: provider: Gemini model: "gemini-2.5-flash-lite" diff --git a/python/packages/kagent-adk/src/kagent/adk/models/_openai.py b/python/packages/kagent-adk/src/kagent/adk/models/_openai.py index b0cca99d6b..f5bbec4c25 100644 --- a/python/packages/kagent-adk/src/kagent/adk/models/_openai.py +++ b/python/packages/kagent-adk/src/kagent/adk/models/_openai.py @@ -32,6 +32,7 @@ from openai.types.shared_params import FunctionDefinition, FunctionParameters from pydantic import Field +from ._openai_responses import generate_content_responses from ._ssl import KAgentTLSMixin from ._token_source import GDCHTokenSource from ._utils import function_declaration_schema @@ -348,6 +349,8 @@ class BaseOpenAI(KAgentTLSMixin, BaseLlm): temperature: Optional[float] = None timeout: Optional[int] = None top_p: Optional[float] = None + # chatCompletions (default) or responses. Azure Responses uses /openai/v1/responses. + api_format: Optional[Literal["chatCompletions", "responses"]] = None # API key passthrough: forward the Bearer token from incoming requests as the LLM API key api_key_passthrough: Optional[bool] = None @@ -360,6 +363,9 @@ def set_passthrough_key(self, token: str) -> None: self.api_key = token self.__dict__.pop("_client", None) # invalidate cached client + def _uses_responses_api(self) -> bool: + return self.api_format == "responses" + @classmethod def supported_models(cls) -> list[str]: """Returns a list of supported models in regex for LlmRegistry.""" @@ -403,6 +409,11 @@ async def generate_content_async( yield LlmResponse(error_message=f"Failed to refresh token-exchange credential: {exc}") return + if self._uses_responses_api(): + async for resp in generate_content_responses(self, llm_request, stream): + yield resp + return + # Convert messages system_instruction = None if llm_request.config and llm_request.config.system_instruction: @@ -588,29 +599,50 @@ class AzureOpenAI(BaseOpenAI): azure_endpoint: Optional[str] = None azure_deployment: Optional[str] = None - @cached_property - def _client(self) -> AsyncAzureOpenAI: - """Get the Azure OpenAI client with optional custom SSL configuration.""" - api_version = self.api_version or os.environ.get("OPENAI_API_VERSION", "2024-02-15-preview") + def _resolved_azure_endpoint(self) -> str: azure_endpoint = self.azure_endpoint or os.environ.get("AZURE_OPENAI_ENDPOINT") - api_key = self.api_key or os.environ.get("AZURE_OPENAI_API_KEY") - if not azure_endpoint: raise ValueError( "Azure endpoint must be provided either via azure_endpoint parameter or AZURE_OPENAI_ENDPOINT environment variable" ) + return azure_endpoint + def _resolved_azure_api_key(self) -> str: + api_key = self.api_key or os.environ.get("AZURE_OPENAI_API_KEY") if not api_key: raise ValueError( "API key must be provided either via api_key parameter or AZURE_OPENAI_API_KEY environment variable" ) + return api_key + + def _responses_client(self) -> AsyncOpenAI: + """Azure OpenAI v1 Responses API client: {endpoint}/openai/v1/ without api-version.""" + azure_endpoint = self._resolved_azure_endpoint().rstrip("/") + api_key = self._resolved_azure_api_key() + headers = dict(self.default_headers or {}) + headers["Api-Key"] = api_key + return AsyncOpenAI( + api_key=api_key, + base_url=f"{azure_endpoint}/openai/v1/", + default_headers=headers, + timeout=self.timeout, + http_client=self._create_http_client(), + ) - http_client = self._create_http_client() + @cached_property + def _client(self) -> AsyncOpenAI: + """Get the Azure OpenAI client with optional custom SSL configuration.""" + if self._uses_responses_api(): + return self._responses_client() + + api_version = self.api_version or os.environ.get("OPENAI_API_VERSION", "2024-02-15-preview") + azure_endpoint = self._resolved_azure_endpoint() + api_key = self._resolved_azure_api_key() return AsyncAzureOpenAI( api_key=api_key, api_version=api_version, azure_endpoint=azure_endpoint, default_headers=self.default_headers, - http_client=http_client, + http_client=self._create_http_client(), ) diff --git a/python/packages/kagent-adk/src/kagent/adk/models/_openai_responses.py b/python/packages/kagent-adk/src/kagent/adk/models/_openai_responses.py new file mode 100644 index 0000000000..c7b2b46729 --- /dev/null +++ b/python/packages/kagent-adk/src/kagent/adk/models/_openai_responses.py @@ -0,0 +1,277 @@ +"""OpenAI Responses API path for BaseOpenAI (OpenAI and Azure OpenAI).""" + +from __future__ import annotations + +import base64 +import json +from typing import TYPE_CHECKING, Any, AsyncGenerator, Optional + +from google.adk.models.llm_response import LlmResponse +from google.genai import types +from google.genai.types import FunctionCall, FunctionResponse + +from ._utils import function_declaration_schema + +if TYPE_CHECKING: + from google.adk.models.llm_request import LlmRequest + + from ._openai import BaseOpenAI + + +def _system_instruction(llm_request: LlmRequest) -> Optional[str]: + if not llm_request.config or not llm_request.config.system_instruction: + return None + system_instruction = llm_request.config.system_instruction + if isinstance(system_instruction, str): + return system_instruction + if hasattr(system_instruction, "parts"): + text_parts = [] + for part in getattr(system_instruction, "parts", []) or []: + if hasattr(part, "text") and part.text: + text_parts.append(part.text) + return "\n".join(text_parts) if text_parts else None + return None + + +def _function_response_output(func_response: FunctionResponse) -> str: + if isinstance(func_response.response, str): + return func_response.response + if func_response.response and "content" in func_response.response: + content_list = func_response.response["content"] + if content_list: + return "\n".join(item["text"] for item in content_list if "text" in item) + if func_response.response and "result" in func_response.response: + return str(func_response.response["result"]) + return "" + + +def contents_to_responses_input(contents: list[types.Content]) -> list[dict[str, Any]]: + """Convert google.genai Content list to Responses API input items.""" + function_responses: dict[str, FunctionResponse] = {} + for content in contents: + for part in content.parts or []: + if part.function_response: + tool_call_id = part.function_response.id or "call_1" + function_responses[tool_call_id] = part.function_response + + items: list[dict[str, Any]] = [] + for content in contents: + role = content.role or "user" + if role == "system": + continue + + text_parts: list[str] = [] + function_calls: list[FunctionCall] = [] + image_urls: list[str] = [] + for part in content.parts or []: + if part.text: + text_parts.append(part.text) + elif part.function_call: + function_calls.append(part.function_call) + elif part.inline_data and part.inline_data.mime_type and part.inline_data.mime_type.startswith("image"): + if part.inline_data.data: + image_data = base64.b64encode(part.inline_data.data).decode() + image_urls.append(f"data:{part.inline_data.mime_type};base64,{image_data}") + + if function_calls and role in ("model", "assistant"): + if text_parts: + items.append({"role": "assistant", "content": "\n".join(text_parts)}) + for func_call in function_calls: + tool_call_id = func_call.id or "call_1" + items.append( + { + "type": "function_call", + "call_id": tool_call_id, + "name": func_call.name or "", + "arguments": json.dumps(func_call.args) if func_call.args else "{}", + } + ) + output = "No response available for this function call." + if tool_call_id in function_responses: + output = _function_response_output(function_responses[tool_call_id]) or output + items.append({"type": "function_call_output", "call_id": tool_call_id, "output": output}) + continue + + if not text_parts and not image_urls: + continue + + msg_role = "assistant" if role in ("model", "assistant") else "user" + if image_urls: + content_parts: list[dict[str, Any]] = [{"type": "input_text", "text": t} for t in text_parts] + content_parts.extend({"type": "input_image", "image_url": url} for url in image_urls) + items.append({"role": msg_role, "content": content_parts}) + else: + items.append({"role": msg_role, "content": "\n".join(text_parts)}) + + return items + + +def tools_to_responses_tools(tools: list[types.Tool]) -> list[dict[str, Any]]: + """Convert google.genai Tools to Responses function tools.""" + out: list[dict[str, Any]] = [] + for tool in tools: + if not tool.function_declarations: + continue + for func_decl in tool.function_declarations: + parameters = function_declaration_schema(func_decl) + out.append( + { + "type": "function", + "name": func_decl.name or "", + "description": func_decl.description or "", + "parameters": parameters, + "strict": False, + } + ) + return out + + +def _usage_metadata(usage: Any) -> Optional[types.GenerateContentResponseUsageMetadata]: + if usage is None: + return None + input_tokens = getattr(usage, "input_tokens", None) or 0 + output_tokens = getattr(usage, "output_tokens", None) or 0 + if input_tokens == 0 and output_tokens == 0: + return None + return types.GenerateContentResponseUsageMetadata( + prompt_token_count=input_tokens, + candidates_token_count=output_tokens, + total_token_count=getattr(usage, "total_tokens", None) or (input_tokens + output_tokens), + ) + + +def _finish_reason(status: Optional[str]) -> types.FinishReason: + if status == "incomplete": + return types.FinishReason.MAX_TOKENS + if status == "failed": + return types.FinishReason.OTHER + return types.FinishReason.STOP + + +def _function_call_part(name: str, arguments: str, call_id: str) -> types.Part: + try: + args = json.loads(arguments) if arguments else {} + except json.JSONDecodeError: + args = {} + part = types.Part.from_function_call(name=name, args=args) + if part.function_call: + part.function_call.id = call_id + return part + + +def response_to_llm_response(response: Any) -> LlmResponse: + """Convert an OpenAI Responses API result to LlmResponse.""" + parts: list[types.Part] = [] + for item in getattr(response, "output", None) or []: + item_type = getattr(item, "type", None) + if item_type == "message": + for content in getattr(item, "content", None) or []: + if getattr(content, "type", None) == "output_text" and getattr(content, "text", None): + parts.append(types.Part.from_text(text=content.text)) + elif item_type == "function_call": + call_id = getattr(item, "call_id", None) or getattr(item, "id", None) or "call_1" + parts.append( + _function_call_part( + getattr(item, "name", "") or "", + getattr(item, "arguments", "") or "", + call_id, + ) + ) + + return LlmResponse( + content=types.Content(role="model", parts=parts), + usage_metadata=_usage_metadata(getattr(response, "usage", None)), + finish_reason=_finish_reason(getattr(response, "status", None)), + ) + + +def _build_request_kwargs(model: BaseOpenAI, llm_request: LlmRequest) -> dict[str, Any]: + kwargs: dict[str, Any] = { + "model": llm_request.model or model.model, + "input": contents_to_responses_input(llm_request.contents), + } + instructions = _system_instruction(llm_request) + if instructions: + kwargs["instructions"] = instructions + if model.temperature is not None: + kwargs["temperature"] = model.temperature + if model.max_completion_tokens: + kwargs["max_output_tokens"] = model.max_completion_tokens + elif model.max_tokens: + kwargs["max_output_tokens"] = model.max_tokens + if model.top_p is not None: + kwargs["top_p"] = model.top_p + if model.reasoning_effort is not None: + kwargs["reasoning"] = {"effort": model.reasoning_effort} + + if llm_request.config and llm_request.config.tools: + genai_tools = [tool for tool in llm_request.config.tools if hasattr(tool, "function_declarations")] + if genai_tools: + openai_tools = tools_to_responses_tools(genai_tools) + if openai_tools: + kwargs["tools"] = openai_tools + kwargs["tool_choice"] = "auto" + return kwargs + + +async def generate_content_responses( + model: BaseOpenAI, llm_request: LlmRequest, stream: bool +) -> AsyncGenerator[LlmResponse, None]: + """Generate content using the OpenAI Responses API.""" + kwargs = _build_request_kwargs(model, llm_request) + try: + if stream: + aggregated_text = "" + tool_calls: dict[str, types.Part] = {} + tool_call_order: list[str] = [] + usage_metadata = None + finish_reason = types.FinishReason.STOP + + async for event in await model._client.responses.create(stream=True, **kwargs): + event_type = getattr(event, "type", None) + if event_type == "response.output_text.delta": + delta = getattr(event, "delta", None) or "" + if not delta: + continue + aggregated_text += delta + yield LlmResponse( + content=types.Content(role="model", parts=[types.Part.from_text(text=delta)]), + partial=True, + turn_complete=False, + ) + elif event_type == "response.output_item.done": + item = getattr(event, "item", None) + if getattr(item, "type", None) == "function_call": + call_id = getattr(item, "call_id", None) or getattr(item, "id", None) or "call_1" + if call_id not in tool_calls: + tool_call_order.append(call_id) + tool_calls[call_id] = _function_call_part( + getattr(item, "name", "") or "", + getattr(item, "arguments", "") or "", + call_id, + ) + elif event_type == "response.completed": + completed = getattr(event, "response", None) + usage_metadata = _usage_metadata(getattr(completed, "usage", None)) + finish_reason = _finish_reason(getattr(completed, "status", None)) + elif event_type == "response.incomplete": + incomplete = getattr(event, "response", None) + usage_metadata = _usage_metadata(getattr(incomplete, "usage", None)) + finish_reason = types.FinishReason.MAX_TOKENS + + final_parts: list[types.Part] = [] + if aggregated_text: + final_parts.append(types.Part.from_text(text=aggregated_text)) + final_parts.extend(tool_calls[call_id] for call_id in tool_call_order) + yield LlmResponse( + content=types.Content(role="model", parts=final_parts), + partial=False, + finish_reason=finish_reason, + usage_metadata=usage_metadata, + turn_complete=True, + ) + else: + response = await model._client.responses.create(stream=False, **kwargs) + yield response_to_llm_response(response) + except Exception as e: + yield LlmResponse(error_code="API_ERROR", error_message=str(e)) diff --git a/python/packages/kagent-adk/src/kagent/adk/types.py b/python/packages/kagent-adk/src/kagent/adk/types.py index a9a953ca18..02a3662756 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -292,6 +292,13 @@ class OpenAI(BaseLLM): class AzureOpenAI(BaseLLM): type: Literal["azure_openai"] + endpoint: str | None = None + deployment: str | None = None + api_version: str | None = None + api_format: Literal["chatCompletions", "responses"] | None = None + max_tokens: int | None = None + temperature: float | None = None + top_p: float | None = None class Anthropic(BaseLLM): @@ -705,6 +712,13 @@ def _create_llm_from_model_config(model_config: ModelUnion): model=model_config.model, type="azure_openai", default_headers=extra_headers, + azure_endpoint=model_config.endpoint, + azure_deployment=model_config.deployment, + api_version=model_config.api_version, + api_format=model_config.api_format, + max_tokens=model_config.max_tokens, + temperature=model_config.temperature, + top_p=model_config.top_p, **_transport_kwargs(model_config), ) if model_config.type == "gemini": diff --git a/python/packages/kagent-adk/tests/unittests/models/test_azure_live.py b/python/packages/kagent-adk/tests/unittests/models/test_azure_live.py new file mode 100644 index 0000000000..0a3644db2d --- /dev/null +++ b/python/packages/kagent-adk/tests/unittests/models/test_azure_live.py @@ -0,0 +1,118 @@ +"""Live Azure AI Foundry checks. Skipped unless AZURE_LIVE=1 and credentials are set.""" + +from __future__ import annotations + +import os + +import pytest +from google.adk.models.llm_request import LlmRequest +from google.genai.types import Content, Part +from openai import DefaultAsyncHttpxClient + +from kagent.adk.models import AzureOpenAI +from kagent.adk.types import AgentConfig + + +def _live_enabled() -> bool: + return os.getenv("AZURE_LIVE") == "1" and bool(os.getenv("AZURE_OPENAI_API_KEY")) + + +pytestmark = pytest.mark.skipif(not _live_enabled(), reason="set AZURE_LIVE=1 and AZURE_OPENAI_API_KEY") + + +def _logging_azure(**kwargs) -> tuple[AzureOpenAI, list[str]]: + recorded_urls: list[str] = [] + + class _LoggingAzureOpenAI(AzureOpenAI): + def _create_http_client(self): + async def on_request(request): + recorded_urls.append(str(request.url)) + + return DefaultAsyncHttpxClient(event_hooks={"request": [on_request]}) + + return _LoggingAzureOpenAI(**kwargs), recorded_urls + + +def _endpoint() -> str: + return os.environ["AZURE_OPENAI_ENDPOINT"].rstrip("/") + + +def _deployment() -> str: + return os.getenv("AZURE_OPENAI_DEPLOYMENT", "gpt-4.1") + + +def _request() -> LlmRequest: + return LlmRequest( + model=_deployment(), + contents=[Content(role="user", parts=[Part.from_text(text="Reply with exactly: pong")])], + ) + + +@pytest.mark.asyncio +async def test_live_python_agent_azure_responses_hits_foundry_v1_path(): + llm, recorded_urls = _logging_azure( + model=_deployment(), + type="azure_openai", + azure_endpoint=_endpoint(), + azure_deployment=_deployment(), + api_version="2024-06-01", + api_format="responses", + ) + agent = AgentConfig.model_validate( + { + "model": { + "type": "azure_openai", + "model": _deployment(), + "endpoint": _endpoint() + "/", + "deployment": _deployment(), + "api_version": "2024-06-01", + "api_format": "responses", + }, + "description": "Python Responses live smoke", + "instruction": "Reply with one short sentence.", + } + ).to_agent("python_responses_smoke") + assert agent.model.api_format == "responses" + + results = [resp async for resp in llm.generate_content_async(_request(), stream=False)] + text = results[-1].content.parts[0].text.strip().lower() + print("python responses urls:", recorded_urls) + print("python responses text:", text) + assert "pong" in text + assert any("/openai/v1/responses" in url for url in recorded_urls), recorded_urls + assert all("api-version=" not in url for url in recorded_urls if "/openai/v1/responses" in url) + + +@pytest.mark.asyncio +async def test_live_python_agent_azure_chat_completions_hits_foundry_deployment_path(): + llm, recorded_urls = _logging_azure( + model=_deployment(), + type="azure_openai", + azure_endpoint=_endpoint(), + azure_deployment=_deployment(), + api_version="2024-06-01", + api_format="chatCompletions", + ) + agent = AgentConfig.model_validate( + { + "model": { + "type": "azure_openai", + "model": _deployment(), + "endpoint": _endpoint() + "/", + "deployment": _deployment(), + "api_version": "2024-06-01", + "api_format": "chatCompletions", + }, + "description": "Python Chat Completions live smoke", + "instruction": "Reply with one short sentence.", + } + ).to_agent("python_chatcompletions_smoke") + assert agent.model.api_format == "chatCompletions" + + results = [resp async for resp in llm.generate_content_async(_request(), stream=False)] + text = results[-1].content.parts[0].text.strip().lower() + print("python chatCompletions urls:", recorded_urls) + print("python chatCompletions text:", text) + assert "pong" in text + assert any("/chat/completions" in url for url in recorded_urls), recorded_urls + assert any("api-version=" in url for url in recorded_urls if "/chat/completions" in url) diff --git a/python/packages/kagent-adk/tests/unittests/models/test_openai.py b/python/packages/kagent-adk/tests/unittests/models/test_openai.py index a0e512fee5..7d7a82286e 100644 --- a/python/packages/kagent-adk/tests/unittests/models/test_openai.py +++ b/python/packages/kagent-adk/tests/unittests/models/test_openai.py @@ -1061,3 +1061,182 @@ def test_round_trip_preserves_thought_signature_for_follow_up_tool_result(self): tool_messages = [m for m in messages if m["role"] == "tool"] assert len(tool_messages) == 1 assert tool_messages[0]["extra_content"] == {"google": {"thought_signature": "YWJj"}} + + +def test_azure_openai_responses_client_uses_v1_base_url(): + """Azure Responses mode uses {endpoint}/openai/v1/ without api-version.""" + from kagent.adk.models import AzureOpenAI + + with mock.patch("kagent.adk.models._openai.AsyncOpenAI") as mock_openai: + azure_llm = AzureOpenAI( + model="gpt-5", + type="azure_openai", + api_key="fake", + azure_endpoint="https://example.openai.azure.com", + api_version="2024-06-01", + api_format="responses", + ) + + _ = azure_llm._client + + mock_openai.assert_called_once() + kwargs = mock_openai.call_args[1] + assert kwargs["base_url"] == "https://example.openai.azure.com/openai/v1/" + assert kwargs["default_headers"]["Api-Key"] == "fake" + assert "api_version" not in kwargs + + +def test_azure_openai_chat_completions_client_uses_azure_sdk(): + """Default Azure mode keeps the deployments-based AsyncAzureOpenAI client.""" + from kagent.adk.models import AzureOpenAI + + with mock.patch("kagent.adk.models._openai.AsyncAzureOpenAI") as mock_azure: + azure_llm = AzureOpenAI( + model="gpt-4o", + type="azure_openai", + api_key="fake", + azure_endpoint="https://example.openai.azure.com", + api_version="2024-06-01", + ) + + _ = azure_llm._client + + mock_azure.assert_called_once() + kwargs = mock_azure.call_args[1] + assert kwargs["azure_endpoint"] == "https://example.openai.azure.com" + assert kwargs["api_version"] == "2024-06-01" + + +@pytest.mark.asyncio +async def test_azure_openai_responses_generate_content_calls_responses_api(llm_request): + from kagent.adk.models import AzureOpenAI + + azure_llm = AzureOpenAI( + model="gpt-5", + type="azure_openai", + api_key="fake", + azure_endpoint="https://example.openai.azure.com", + api_format="responses", + ) + + class MockOutputText: + type = "output_text" + text = "ok" + + class MockMessage: + type = "message" + content = [MockOutputText()] + + class MockUsage: + input_tokens = 1 + output_tokens = 1 + total_tokens = 2 + + class MockResponse: + output = [MockMessage()] + usage = MockUsage() + status = "completed" + + with mock.patch.object(azure_llm, "_client") as mock_client: + mock_client.responses.create = mock.AsyncMock(return_value=MockResponse()) + + results = [resp async for resp in azure_llm.generate_content_async(llm_request, stream=False)] + + mock_client.responses.create.assert_called_once() + mock_client.chat.completions.create.assert_not_called() + kwargs = mock_client.responses.create.call_args.kwargs + assert kwargs["model"] == "gpt-3.5-turbo" + assert kwargs["input"][0]["role"] == "user" + assert "api_version" not in kwargs + assert len(results) == 1 + assert results[0].content.parts[0].text == "ok" + + +@pytest.mark.asyncio +async def test_azure_openai_chat_completions_generate_content_calls_chat_api(llm_request, generate_content_response): + from kagent.adk.models import AzureOpenAI + + azure_llm = AzureOpenAI( + model="gpt-4.1", + type="azure_openai", + api_key="fake", + azure_endpoint="https://example.openai.azure.com", + api_version="2024-06-01", + api_format="chatCompletions", + ) + + with mock.patch.object(azure_llm, "_client") as mock_client: + mock_client.chat.completions.create = mock.AsyncMock(return_value=generate_content_response) + mock_client.responses.create = mock.AsyncMock() + + results = [resp async for resp in azure_llm.generate_content_async(llm_request, stream=False)] + + mock_client.chat.completions.create.assert_called_once() + mock_client.responses.create.assert_not_called() + assert len(results) == 1 + assert results[0].content.parts[0].text == "Hi! How can I help you today?" + + +def test_azure_openai_agent_config_propagates_api_format(): + from kagent.adk.types import AgentConfig + + config = AgentConfig.model_validate( + { + "model": { + "type": "azure_openai", + "model": "gpt-4o-deploy", + "endpoint": "https://example.openai.azure.com/", + "deployment": "gpt-4o-deploy", + "api_version": "2024-06-01", + "api_format": "responses", + "temperature": 0.2, + }, + "description": "d", + "instruction": "i", + } + ) + agent = config.to_agent("test") + assert agent.model.api_format == "responses" + assert agent.model.azure_endpoint == "https://example.openai.azure.com/" + assert agent.model.azure_deployment == "gpt-4o-deploy" + assert agent.model.api_version == "2024-06-01" + assert agent.model.temperature == 0.2 + + +def test_contents_to_responses_input_pairs_function_calls(): + from kagent.adk.models._openai_responses import contents_to_responses_input + + contents = [ + Content(role="user", parts=[Part.from_text(text="hello")]), + Content( + role="model", + parts=[ + Part( + function_call=types.FunctionCall( + id="call_1", + name="lookup", + args={"q": "x"}, + ) + ) + ], + ), + Content( + role="user", + parts=[ + Part( + function_response=types.FunctionResponse( + id="call_1", + name="lookup", + response={"result": "ok"}, + ) + ) + ], + ), + ] + + items = contents_to_responses_input(contents) + assert items[0] == {"role": "user", "content": "hello"} + assert items[1]["type"] == "function_call" + assert items[1]["call_id"] == "call_1" + assert items[1]["name"] == "lookup" + assert items[2] == {"type": "function_call_output", "call_id": "call_1", "output": "ok"} diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts index 4a2dedcbad..2097b46135 100644 --- a/ui/src/types/index.ts +++ b/ui/src/types/index.ts @@ -30,6 +30,7 @@ export interface AzureOpenAIConfig { temperature?: string; maxTokens?: number; topP?: string; + apiFormat?: "chatCompletions" | "responses"; } export interface OllamaConfig {