Skip to content

Commit d3dd1c6

Browse files
authored
Merge branch 'main' into feat/ci-coverage-reporting
2 parents 492c9c9 + e4e4ec5 commit d3dd1c6

4 files changed

Lines changed: 133 additions & 4 deletions

File tree

internal/ai/dispatch/dispatch.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -408,8 +408,8 @@ type turnResult struct {
408408
}
409409

410410
// completeTurn uses the provider's real streaming path whenever advertised.
411-
// Capability drift falls back to Chat only when Stream refuses synchronously,
412-
// before any frame has been consumed.
411+
// Any Stream failure that happens before a frame is consumed falls back
412+
// to Chat (capability drift, Foundry stream 404, etc.).
413413
func (d *Dispatcher) completeTurn(
414414
ctx context.Context,
415415
req provider.ChatRequest,
@@ -420,7 +420,10 @@ func (d *Dispatcher) completeTurn(
420420
if err == nil {
421421
return turn, "stream", nil
422422
}
423-
if consumed || !errors.Is(err, provider.ErrCapabilityNotSupported) {
423+
// Fall back to Chat only when Stream refused before any
424+
// frame — including Azure Foundry 404 DeploymentNotFound
425+
// on stream:true while the same Chat probe succeeds.
426+
if consumed {
424427
return turnResult{}, "stream", err
425428
}
426429
}

internal/ai/dispatch/dispatch_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"errors"
7+
"fmt"
78
"strings"
89
"sync"
910
"testing"
@@ -420,6 +421,29 @@ func TestDispatcher_FallsBackWhenStreamCapabilityDrifts(t *testing.T) {
420421
}
421422
}
422423

424+
func TestDispatcher_FallsBackWhenStream404sBeforeFrames(t *testing.T) {
425+
t.Parallel()
426+
427+
p := &streamingScriptedProvider{
428+
streamErr: fmt.Errorf("%w: azure stream status 404: DeploymentNotFound", provider.ErrUpstream),
429+
chatResp: &provider.ChatResponse{
430+
Message: provider.Message{Role: provider.RoleAssistant, Content: "chat-ok"},
431+
},
432+
}
433+
d := New(tools.NewRegistry(), p, nil, 0)
434+
w := NewCaptureWriter()
435+
436+
if err := d.Run(context.Background(), fakeStrategy{}, strategy.StrategyInput{}, w); err != nil {
437+
t.Fatalf("Run: %v", err)
438+
}
439+
if got := strings.Join(w.Deltas(), ""); got != "chat-ok" {
440+
t.Fatalf("joined deltas = %q", got)
441+
}
442+
if p.streamCalls != 1 || p.chatCalls != 1 {
443+
t.Fatalf("provider calls: stream=%d chat=%d", p.streamCalls, p.chatCalls)
444+
}
445+
}
446+
423447
func TestDispatcher_SingleToolCall(t *testing.T) {
424448
t.Parallel()
425449
p := newScripted(

internal/ai/provider/azure/azure.go

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,13 +206,60 @@ func (a *Adapter) Stream(ctx context.Context, req provider.ChatRequest) (<-chan
206206
if resp.StatusCode/100 != 2 {
207207
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
208208
_ = resp.Body.Close()
209-
return nil, fmt.Errorf("%w: azure stream status %d: %s", provider.ErrUpstream, resp.StatusCode, string(raw))
209+
streamErr := fmt.Errorf("%w: azure stream status %d: %s", provider.ErrUpstream, resp.StatusCode, string(raw))
210+
// Foundry OpenAI v1 (especially model-router) accepts the
211+
// same chat/completions URL for non-stream Chat that
212+
// Validate uses, but returns 404 DeploymentNotFound when
213+
// the body has stream:true. Retry as Chat and synthesize
214+
// chunks so Helix still completes.
215+
if resp.StatusCode == http.StatusNotFound {
216+
chatResp, chatErr := a.Chat(ctx, req)
217+
if chatErr == nil {
218+
return chatResponseAsStream(ctx, chatResp), nil
219+
}
220+
}
221+
return nil, streamErr
210222
}
211223
out := make(chan provider.Chunk, 8)
212224
go relayStream(ctx, resp.Body, out)
213225
return out, nil
214226
}
215227

228+
// chatResponseAsStream turns a completed Chat response into the
229+
// Stream channel shape dispatch already consumes.
230+
func chatResponseAsStream(ctx context.Context, resp *provider.ChatResponse) <-chan provider.Chunk {
231+
out := make(chan provider.Chunk, 8)
232+
go func() {
233+
defer close(out)
234+
if resp == nil {
235+
send(ctx, out, provider.Chunk{Err: fmt.Errorf("%w: azure stream fallback returned nil chat", provider.ErrUpstream)})
236+
return
237+
}
238+
if resp.Message.Content != "" {
239+
send(ctx, out, provider.Chunk{Delta: resp.Message.Content})
240+
}
241+
for i := range resp.ToolCalls {
242+
call := resp.ToolCalls[i]
243+
send(ctx, out, provider.Chunk{ToolDelta: &call})
244+
}
245+
finish := resp.FinishReason
246+
if finish == "" {
247+
if len(resp.ToolCalls) > 0 {
248+
finish = provider.FinishToolCalls
249+
} else {
250+
finish = provider.FinishStop
251+
}
252+
}
253+
send(ctx, out, provider.Chunk{
254+
Done: true,
255+
FinishReason: finish,
256+
InputTokens: resp.InputTokens,
257+
OutputTokens: resp.OutputTokens,
258+
})
259+
}()
260+
return out
261+
}
262+
216263
// Embed uses the Azure embeddings route. URL shape depends on flavor:
217264
//
218265
// - OpenAI flavor: {base}/openai/deployments/{depl}/embeddings?api-version=...

internal/ai/provider/azure/azure_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,61 @@ func TestChatEndpoint_V1DoesNotDuplicatePath(t *testing.T) {
658658
}
659659
}
660660

661+
func TestStream_V1NotFoundFallsBackToChat(t *testing.T) {
662+
t.Parallel()
663+
var streamHits, chatHits int
664+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
665+
body, _ := io.ReadAll(r.Body)
666+
var probe map[string]any
667+
_ = json.Unmarshal(body, &probe)
668+
stream, _ := probe["stream"].(bool)
669+
if stream {
670+
streamHits++
671+
w.WriteHeader(http.StatusNotFound)
672+
_, _ = io.WriteString(w, `{ "error": { "type": "invalid_request_error", "code": "DeploymentNotFound", "message": "The API deployment for this resource does not exist." } }`)
673+
return
674+
}
675+
chatHits++
676+
_, _ = io.WriteString(w, `{"choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"from-chat"}}],"usage":{"prompt_tokens":4,"completion_tokens":2}}`)
677+
}))
678+
t.Cleanup(srv.Close)
679+
a, err := New(provider.ProviderConfig{
680+
BaseURL: srv.URL + "/openai/v1",
681+
Model: "model-router",
682+
APIKey: "k",
683+
Flavor: provider.AzureFlavorOpenAI,
684+
}, WithHTTPClient(srv.Client()))
685+
if err != nil {
686+
t.Fatalf("New: %v", err)
687+
}
688+
ch, err := a.Stream(context.Background(), provider.ChatRequest{
689+
Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
690+
})
691+
if err != nil {
692+
t.Fatalf("Stream: %v", err)
693+
}
694+
var content string
695+
var terminal provider.Chunk
696+
for c := range ch {
697+
if c.Err != nil {
698+
t.Fatalf("chunk err: %v", c.Err)
699+
}
700+
content += c.Delta
701+
if c.Done {
702+
terminal = c
703+
}
704+
}
705+
if streamHits != 1 || chatHits != 1 {
706+
t.Fatalf("hits stream=%d chat=%d", streamHits, chatHits)
707+
}
708+
if content != "from-chat" {
709+
t.Fatalf("content=%q", content)
710+
}
711+
if !terminal.Done || terminal.FinishReason != provider.FinishStop {
712+
t.Fatalf("terminal = %+v", terminal)
713+
}
714+
}
715+
661716
func TestOpenAIV1_Chat_URLAuthAndBody(t *testing.T) {
662717
t.Parallel()
663718
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

0 commit comments

Comments
 (0)