Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a8f5fc6
fix(ai): stop forcing Foundry v1 from hostname; retry classic chat on…
atulmgupta Sep 18, 2026
f8e138a
Merge branch 'main' into fix/helix-azure-foundry-404
atulmgupta Sep 18, 2026
0914109
fix(ai): give gpt-5 Azure chats a real completion budget
atulmgupta Sep 18, 2026
6580a4b
fix(ai): use Foundry Responses API for gpt-5.6-sol
atulmgupta Sep 18, 2026
d33e831
fix(ai): route Foundry by surface, not a hardcoded model
atulmgupta Sep 18, 2026
8c3206b
fix(ai): negotiate Azure chat and Responses without model binding
atulmgupta Sep 18, 2026
b2d1f2d
fix(ai): sharpen alert template creativity and evaluation
atulmgupta Sep 18, 2026
1c5ca8f
fix(web): avoid unsaved warning for untouched recovered alert drafts
atulmgupta Sep 18, 2026
e44ebf9
refactor(ai): make Microsoft Foundry v1 the only Azure surface
atulmgupta Sep 18, 2026
2e29910
feat(alerts): add curated packs and Helix custom groups
atulmgupta Sep 18, 2026
e609b95
fix(alerts): use canonical seconds for pack cooldown requests
atulmgupta Sep 18, 2026
bcb1ab1
fix(ci): classify alert pack configuration mutations
atulmgupta Sep 19, 2026
31db0e7
fix(web): organize settings into responsive readable categories
atulmgupta Sep 19, 2026
485670c
fix(ai): preserve Foundry continuation and terminal semantics
atulmgupta Sep 19, 2026
5f1162e
docs: fix get-started card links
atulmgupta Sep 19, 2026
78d1f84
fix(web): keep settings tour readable before translations initialize
atulmgupta Sep 19, 2026
c6b2ce5
feat(notifications): deepen alert packs and streamline rule management
atulmgupta Sep 19, 2026
b56324f
refactor(web): redesign alert pack preview as a responsive workspace
atulmgupta Sep 19, 2026
6b4aaa7
ci: parallelize test suites with verified coverage merging
atulmgupta Sep 19, 2026
066b773
test(ci): enforce shared browser build and preview lifecycle
atulmgupta Sep 19, 2026
3d3e111
ci: gate Docker builds on successful test and coverage checks
atulmgupta Sep 19, 2026
6debba4
feat(web): edit alert pack rules inline with per-message Helix
atulmgupta Sep 19, 2026
82aa2f7
test(web): guard Settings readability across display and text sizes
atulmgupta Sep 19, 2026
7261999
fix(web): wrap Settings summary values at larger text sizes
atulmgupta Sep 19, 2026
f3c43b6
fix(web): flatten alert pack editing into aligned grid columns
atulmgupta Sep 19, 2026
4534a79
feat(web): add pack channel defaults and consistent alert options
atulmgupta Sep 19, 2026
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
95 changes: 81 additions & 14 deletions internal/ai/provider/azure/azure.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ func (a *Adapter) Chat(ctx context.Context, req provider.ChatRequest) (*provider
if err != nil {
return nil, err
}
body, err := encodeChatRequest(req, modelInBody, false, a.usesOpenAIV1())
body, err := encodeChatRequest(req, modelInBody, false, a.usesCompletionTokenCap(req, modelInBody))
if err != nil {
return nil, err
}
Expand All @@ -176,7 +176,17 @@ func (a *Adapter) Chat(ctx context.Context, req provider.ChatRequest) (*provider
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("%w: azure chat status %d: %s", provider.ErrUpstream, resp.StatusCode, string(raw))
chatErr := fmt.Errorf("%w: azure chat status %d: %s", provider.ErrUpstream, resp.StatusCode, string(raw))
// Foundry portal endpoints often include /openai/v1, but
// gpt-5.x deployments 404 on that chat/completions surface.
// Retry the pre-#118 flavor URL (deployments or Foundry
// inference) after stripping /openai/v1.
if resp.StatusCode == http.StatusNotFound && a.usesOpenAIV1() {
if fallback, ferr := a.withoutOpenAIV1().Chat(ctx, req); ferr == nil {
return fallback, nil
}
}
return nil, chatErr
}
var wire azureChatResponse
if err := json.NewDecoder(resp.Body).Decode(&wire); err != nil {
Expand All @@ -190,7 +200,7 @@ func (a *Adapter) Stream(ctx context.Context, req provider.ChatRequest) (<-chan
if err != nil {
return nil, err
}
body, err := encodeChatRequest(req, modelInBody, true, a.usesOpenAIV1())
body, err := encodeChatRequest(req, modelInBody, true, a.usesCompletionTokenCap(req, modelInBody))
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -413,25 +423,74 @@ func (a *Adapter) embedURL(identity string) (string, error) {
}

// isAzureOpenAIV1 reports whether baseURL is Azure AI Foundry's
// OpenAI-compatible v1 surface. Detected from:
// - path containing /openai/v1 (the portal "endpoint" field)
// - host *.services.ai.azure.com (Foundry AI Services resource)
// OpenAI-compatible v1 surface. Only the path is authoritative —
// hostname *.services.ai.azure.com also hosts classic
// /openai/deployments/{name}/chat/completions?api-version= which
// Helix used successfully before auto-detect (#118) forced v1.
Comment thread
Copilot marked this conversation as resolved.
Outdated
func isAzureOpenAIV1(baseURL string) bool {
u, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil {
return false
}
p := strings.ToLower(path.Clean(u.Path))
if strings.Contains(p, "/openai/v1") {
return true
}
return strings.Contains(strings.ToLower(u.Hostname()), "services.ai.azure.com")
return strings.Contains(p, "/openai/v1")
}

func (a *Adapter) usesOpenAIV1() bool {
return isAzureOpenAIV1(a.cfg.BaseURL)
}

const defaultMaxCompletionTokens = 8192

func needsMaxCompletionTokens(model string) bool {
m := strings.ToLower(strings.TrimSpace(model))
if strings.Contains(m, "gpt-5") {
return true
}
for _, prefix := range []string{"o1", "o3", "o4"} {
if m == prefix || strings.HasPrefix(m, prefix+"-") {
return true
}
}
return false
}

func (a *Adapter) usesCompletionTokenCap(req provider.ChatRequest, modelInBody string) bool {
if a.usesOpenAIV1() {
return true
}
identity := modelInBody
if identity == "" {
identity = a.chatDeployment(req)
}
return needsMaxCompletionTokens(identity)
Comment thread
Copilot marked this conversation as resolved.
Outdated
}

func stripOpenAIV1Path(baseURL string) string {
u, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil {
return strings.TrimSpace(baseURL)
}
cleaned := path.Clean(u.Path)
lower := strings.ToLower(cleaned)
if i := strings.Index(lower, "/openai/v1"); i >= 0 {
cleaned = cleaned[:i]
if cleaned == "" {
cleaned = "/"
}
u.Path = cleaned
}
u.RawQuery = ""
u.Fragment = ""
return strings.TrimRight(u.String(), "/")
}

func (a *Adapter) withoutOpenAIV1() *Adapter {
clone := *a
clone.cfg.BaseURL = stripOpenAIV1Path(a.cfg.BaseURL)
return &clone
}

// buildOpenAIV1URL joins BaseURL (ensuring /openai/v1) with extra
// segments and omits api-version. Foundry's v1 GA API 404s when the
// classic Azure OpenAI api-version (2024-10-21) is attached.
Expand Down Expand Up @@ -595,7 +654,7 @@ type azureStreamFrame struct {
// Azure JSON envelope. modelInBody is non-empty only for the Foundry
// flavor; for Azure OpenAI Service the body MUST omit the model
// field (the deployment name in the URL is the routing key).
func encodeChatRequest(req provider.ChatRequest, modelInBody string, stream bool, v1 bool) ([]byte, error) {
func encodeChatRequest(req provider.ChatRequest, modelInBody string, stream bool, useCompletionTokens bool) ([]byte, error) {
wireMsgs := make([]azureWireMsg, 0, len(req.Messages))
for _, m := range req.Messages {
wm := azureWireMsg{Role: m.Role, Content: m.Content, Name: m.Name, ToolCallID: m.ToolID}
Expand Down Expand Up @@ -634,9 +693,17 @@ func encodeChatRequest(req provider.ChatRequest, modelInBody string, stream bool
Stream: stream,
Temperature: req.Temperature,
}
if v1 {
wire.MaxCompletionTokens = req.MaxTokens
} else {
if useCompletionTokens {
n := req.MaxTokens
if n <= 0 {
// gpt-5 / o-series spend hidden reasoning tokens
// against this cap. Azure 400s with "max_tokens or
// model output limit was reached" when the field is
// omitted or set to 1 (the settings probe).
n = defaultMaxCompletionTokens
}
wire.MaxCompletionTokens = n
} else if req.MaxTokens > 0 {
wire.MaxTokens = req.MaxTokens
}
if len(wireTools) == 0 {
Expand Down
107 changes: 101 additions & 6 deletions internal/ai/provider/azure/azure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -602,7 +602,7 @@ func TestIsAzureOpenAIV1(t *testing.T) {
want bool
}{
{"https://my-resource.services.ai.azure.com/openai/v1", true},
{"https://my-resource.services.ai.azure.com", true},
{"https://my-resource.services.ai.azure.com", false},
{"https://my-resource.openai.azure.com/openai/v1", true},
{"https://my-resource.openai.azure.com", false},
{"http://127.0.0.1:1234", false},
Expand All @@ -614,7 +614,7 @@ func TestIsAzureOpenAIV1(t *testing.T) {
}
}

func TestChatEndpoint_V1ServicesHost(t *testing.T) {
func TestChatEndpoint_ServicesHostUsesClassicOpenAI(t *testing.T) {
t.Parallel()
a, err := New(provider.ProviderConfig{
BaseURL: "https://my-resource.services.ai.azure.com",
Expand All @@ -630,11 +630,14 @@ func TestChatEndpoint_V1ServicesHost(t *testing.T) {
if err != nil {
t.Fatalf("chatEndpoint: %v", err)
}
if model != "gpt-5.6-sol" {
t.Errorf("modelInBody=%q", model)
if model != "" {
t.Errorf("modelInBody=%q, want empty (deployment in URL)", model)
}
if u != "https://my-resource.services.ai.azure.com/openai/v1/chat/completions" {
t.Errorf("url=%s", u)
if !strings.Contains(u, "/openai/deployments/gpt-5.6-sol/chat/completions") {
t.Errorf("url=%s, want classic deployments path", u)
}
if !strings.Contains(u, "api-version=2024-10-21") {
t.Errorf("url=%s, want api-version", u)
}
}

Expand Down Expand Up @@ -764,3 +767,95 @@ func TestOpenAIV1_Chat_URLAuthAndBody(t *testing.T) {
t.Fatalf("content=%q", resp.Message.Content)
}
}

func TestChat_V1NotFoundFallsBackToDeployments(t *testing.T) {
t.Parallel()
var v1Hits, deployHits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/openai/v1/") {
v1Hits++
w.WriteHeader(http.StatusNotFound)
_, _ = io.WriteString(w, `{ "error": { "code": "DeploymentNotFound" } }`)
return
}
if !strings.Contains(r.URL.Path, "/openai/deployments/gpt-5.6-sol/chat/completions") {
t.Errorf("unexpected path=%s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
if r.URL.Query().Get("api-version") == "" {
t.Errorf("missing api-version on %s", r.URL.String())
}
deployHits++
_, _ = io.WriteString(w, `{"choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"classic"}}]}`)
}))
t.Cleanup(srv.Close)
a, err := New(provider.ProviderConfig{
BaseURL: srv.URL + "/openai/v1",
Model: "gpt-5.6-sol",
APIKey: "k",
APIVersion: "2024-10-21",
Flavor: provider.AzureFlavorOpenAI,
}, WithHTTPClient(srv.Client()))
if err != nil {
t.Fatalf("New: %v", err)
}
resp, err := a.Chat(context.Background(), provider.ChatRequest{
Messages: []provider.Message{{Role: provider.RoleUser, Content: "ping"}},
})
if err != nil {
t.Fatalf("Chat: %v", err)
}
if resp.Message.Content != "classic" {
t.Fatalf("content=%q", resp.Message.Content)
}
if v1Hits != 1 || deployHits != 1 {
t.Fatalf("hits v1=%d deploy=%d", v1Hits, deployHits)
}
}

func TestChat_Gpt5Classic_DefaultMaxCompletionTokens(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var probe map[string]any
_ = json.Unmarshal(body, &probe)
if _, has := probe["max_tokens"]; has {
t.Errorf("gpt-5 must not send max_tokens: %s", body)
}
got, _ := probe["max_completion_tokens"].(float64)
if int(got) != defaultMaxCompletionTokens {
t.Errorf("max_completion_tokens=%v want %d", probe["max_completion_tokens"], defaultMaxCompletionTokens)
}
_, _ = io.WriteString(w, `{"choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"ok"}}]}`)
}))
t.Cleanup(srv.Close)
a, err := New(provider.ProviderConfig{
BaseURL: srv.URL,
Model: "gpt-5.6-sol",
APIKey: "k",
APIVersion: "2024-10-21",
Flavor: provider.AzureFlavorOpenAI,
}, WithHTTPClient(srv.Client()))
if err != nil {
t.Fatalf("New: %v", err)
}
if _, err := a.Chat(context.Background(), provider.ChatRequest{
Messages: []provider.Message{{Role: provider.RoleUser, Content: "ping"}},
}); err != nil {
t.Fatalf("Chat: %v", err)
}
}

func TestNeedsMaxCompletionTokens(t *testing.T) {
t.Parallel()
if !needsMaxCompletionTokens("gpt-5.6-sol") {
t.Fatal("gpt-5.6-sol")
}
if needsMaxCompletionTokens("gpt-4o-mini") {
t.Fatal("gpt-4o-mini")
}
if !needsMaxCompletionTokens("o3-mini") {
t.Fatal("o3-mini")
}
}
Loading