Skip to content

Commit a8f5fc6

Browse files
atulmguptaCopilot
andcommitted
fix(ai): stop forcing Foundry v1 from hostname; retry classic chat on 404
Helix Chat 404 DeploymentNotFound after #118 because every *.services.ai.azure.com host was routed to /openai/v1/chat/completions without api-version. Restore flavor deployments URLs unless the path explicitly contains /openai/v1, and fall back to that classic URL when v1 chat 404s. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5b104204-65b7-4d04-90a4-e2d5897b8e2e
1 parent e4e4ec5 commit a8f5fc6

2 files changed

Lines changed: 96 additions & 14 deletions

File tree

internal/ai/provider/azure/azure.go

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,17 @@ func (a *Adapter) Chat(ctx context.Context, req provider.ChatRequest) (*provider
176176
defer resp.Body.Close()
177177
if resp.StatusCode/100 != 2 {
178178
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
179-
return nil, fmt.Errorf("%w: azure chat status %d: %s", provider.ErrUpstream, resp.StatusCode, string(raw))
179+
chatErr := fmt.Errorf("%w: azure chat status %d: %s", provider.ErrUpstream, resp.StatusCode, string(raw))
180+
// Foundry portal endpoints often include /openai/v1, but
181+
// gpt-5.x deployments 404 on that chat/completions surface.
182+
// Retry the pre-#118 flavor URL (deployments or Foundry
183+
// inference) after stripping /openai/v1.
184+
if resp.StatusCode == http.StatusNotFound && a.usesOpenAIV1() {
185+
if fallback, ferr := a.withoutOpenAIV1().Chat(ctx, req); ferr == nil {
186+
return fallback, nil
187+
}
188+
}
189+
return nil, chatErr
180190
}
181191
var wire azureChatResponse
182192
if err := json.NewDecoder(resp.Body).Decode(&wire); err != nil {
@@ -413,25 +423,48 @@ func (a *Adapter) embedURL(identity string) (string, error) {
413423
}
414424

415425
// isAzureOpenAIV1 reports whether baseURL is Azure AI Foundry's
416-
// OpenAI-compatible v1 surface. Detected from:
417-
// - path containing /openai/v1 (the portal "endpoint" field)
418-
// - host *.services.ai.azure.com (Foundry AI Services resource)
426+
// OpenAI-compatible v1 surface. Only the path is authoritative —
427+
// hostname *.services.ai.azure.com also hosts classic
428+
// /openai/deployments/{name}/chat/completions?api-version= which
429+
// Helix used successfully before auto-detect (#118) forced v1.
419430
func isAzureOpenAIV1(baseURL string) bool {
420431
u, err := url.Parse(strings.TrimSpace(baseURL))
421432
if err != nil {
422433
return false
423434
}
424435
p := strings.ToLower(path.Clean(u.Path))
425-
if strings.Contains(p, "/openai/v1") {
426-
return true
427-
}
428-
return strings.Contains(strings.ToLower(u.Hostname()), "services.ai.azure.com")
436+
return strings.Contains(p, "/openai/v1")
429437
}
430438

431439
func (a *Adapter) usesOpenAIV1() bool {
432440
return isAzureOpenAIV1(a.cfg.BaseURL)
433441
}
434442

443+
func stripOpenAIV1Path(baseURL string) string {
444+
u, err := url.Parse(strings.TrimSpace(baseURL))
445+
if err != nil {
446+
return strings.TrimSpace(baseURL)
447+
}
448+
cleaned := path.Clean(u.Path)
449+
lower := strings.ToLower(cleaned)
450+
if i := strings.Index(lower, "/openai/v1"); i >= 0 {
451+
cleaned = cleaned[:i]
452+
if cleaned == "" {
453+
cleaned = "/"
454+
}
455+
u.Path = cleaned
456+
}
457+
u.RawQuery = ""
458+
u.Fragment = ""
459+
return strings.TrimRight(u.String(), "/")
460+
}
461+
462+
func (a *Adapter) withoutOpenAIV1() *Adapter {
463+
clone := *a
464+
clone.cfg.BaseURL = stripOpenAIV1Path(a.cfg.BaseURL)
465+
return &clone
466+
}
467+
435468
// buildOpenAIV1URL joins BaseURL (ensuring /openai/v1) with extra
436469
// segments and omits api-version. Foundry's v1 GA API 404s when the
437470
// classic Azure OpenAI api-version (2024-10-21) is attached.

internal/ai/provider/azure/azure_test.go

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -602,7 +602,7 @@ func TestIsAzureOpenAIV1(t *testing.T) {
602602
want bool
603603
}{
604604
{"https://my-resource.services.ai.azure.com/openai/v1", true},
605-
{"https://my-resource.services.ai.azure.com", true},
605+
{"https://my-resource.services.ai.azure.com", false},
606606
{"https://my-resource.openai.azure.com/openai/v1", true},
607607
{"https://my-resource.openai.azure.com", false},
608608
{"http://127.0.0.1:1234", false},
@@ -614,7 +614,7 @@ func TestIsAzureOpenAIV1(t *testing.T) {
614614
}
615615
}
616616

617-
func TestChatEndpoint_V1ServicesHost(t *testing.T) {
617+
func TestChatEndpoint_ServicesHostUsesClassicOpenAI(t *testing.T) {
618618
t.Parallel()
619619
a, err := New(provider.ProviderConfig{
620620
BaseURL: "https://my-resource.services.ai.azure.com",
@@ -630,11 +630,14 @@ func TestChatEndpoint_V1ServicesHost(t *testing.T) {
630630
if err != nil {
631631
t.Fatalf("chatEndpoint: %v", err)
632632
}
633-
if model != "gpt-5.6-sol" {
634-
t.Errorf("modelInBody=%q", model)
633+
if model != "" {
634+
t.Errorf("modelInBody=%q, want empty (deployment in URL)", model)
635635
}
636-
if u != "https://my-resource.services.ai.azure.com/openai/v1/chat/completions" {
637-
t.Errorf("url=%s", u)
636+
if !strings.Contains(u, "/openai/deployments/gpt-5.6-sol/chat/completions") {
637+
t.Errorf("url=%s, want classic deployments path", u)
638+
}
639+
if !strings.Contains(u, "api-version=2024-10-21") {
640+
t.Errorf("url=%s, want api-version", u)
638641
}
639642
}
640643

@@ -764,3 +767,49 @@ func TestOpenAIV1_Chat_URLAuthAndBody(t *testing.T) {
764767
t.Fatalf("content=%q", resp.Message.Content)
765768
}
766769
}
770+
771+
func TestChat_V1NotFoundFallsBackToDeployments(t *testing.T) {
772+
t.Parallel()
773+
var v1Hits, deployHits int
774+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
775+
if strings.Contains(r.URL.Path, "/openai/v1/") {
776+
v1Hits++
777+
w.WriteHeader(http.StatusNotFound)
778+
_, _ = io.WriteString(w, `{ "error": { "code": "DeploymentNotFound" } }`)
779+
return
780+
}
781+
if !strings.Contains(r.URL.Path, "/openai/deployments/gpt-5.6-sol/chat/completions") {
782+
t.Errorf("unexpected path=%s", r.URL.Path)
783+
w.WriteHeader(http.StatusNotFound)
784+
return
785+
}
786+
if r.URL.Query().Get("api-version") == "" {
787+
t.Errorf("missing api-version on %s", r.URL.String())
788+
}
789+
deployHits++
790+
_, _ = io.WriteString(w, `{"choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"classic"}}]}`)
791+
}))
792+
t.Cleanup(srv.Close)
793+
a, err := New(provider.ProviderConfig{
794+
BaseURL: srv.URL + "/openai/v1",
795+
Model: "gpt-5.6-sol",
796+
APIKey: "k",
797+
APIVersion: "2024-10-21",
798+
Flavor: provider.AzureFlavorOpenAI,
799+
}, WithHTTPClient(srv.Client()))
800+
if err != nil {
801+
t.Fatalf("New: %v", err)
802+
}
803+
resp, err := a.Chat(context.Background(), provider.ChatRequest{
804+
Messages: []provider.Message{{Role: provider.RoleUser, Content: "ping"}},
805+
})
806+
if err != nil {
807+
t.Fatalf("Chat: %v", err)
808+
}
809+
if resp.Message.Content != "classic" {
810+
t.Fatalf("content=%q", resp.Message.Content)
811+
}
812+
if v1Hits != 1 || deployHits != 1 {
813+
t.Fatalf("hits v1=%d deploy=%d", v1Hits, deployHits)
814+
}
815+
}

0 commit comments

Comments
 (0)