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
14 changes: 14 additions & 0 deletions internal/requestflag/requestflag.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,20 @@ func ExtractRequestContents(cmd *cli.Command) RequestContents {
return res
}

// FlagBool returns the boolean value of a request flag. Nullable boolean flags
// expose *bool through cli.Command.Value, while cli.Command.Bool only handles
// concrete bool values.
func FlagBool(cmd *cli.Command, name string) bool {
switch value := cmd.Value(name).(type) {
case bool:
return value
case *bool:
return value != nil && *value
default:
return false
}
}

func GetMissingRequiredFlags(cmd *cli.Command, body any) []cli.Flag {
missing := []cli.Flag{}
for _, flag := range cmd.Flags {
Expand Down
48 changes: 48 additions & 0 deletions internal/requestflag/requestflag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,54 @@ func TestYamlHandling(t *testing.T) {
})
}

func TestFlagBool(t *testing.T) {
t.Parallel()

tests := []struct {
name string
flag cli.Flag
want bool
}{
{
name: "concrete true",
flag: &Flag[bool]{Name: "stream", Default: true},
want: true,
},
{
name: "concrete false",
flag: &Flag[bool]{Name: "stream", Default: false},
want: false,
},
{
name: "nullable true",
flag: &Flag[*bool]{Name: "stream", Default: Ptr(true)},
want: true,
},
{
name: "nullable false",
flag: &Flag[*bool]{Name: "stream", Default: Ptr(false)},
want: false,
},
{
name: "nullable null",
flag: &Flag[*bool]{Name: "stream", Default: nil},
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, FlagBool(&cli.Command{Flags: []cli.Flag{tt.flag}}, "stream"))
})
}

t.Run("unknown flag", func(t *testing.T) {
t.Parallel()
assert.False(t, FlagBool(&cli.Command{}, "stream"))
})
}

// TestNullLiteralHandling pins how each Flag[T] type handles the literal value "null"
// when passed via the CLI. Pointer-typed flags serialize nil as JSON null, which is how
// nullable body fields (`anyOf: [T, null]` / `{nullable: true}`) let users clear a field
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/audiotranscription.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ func handleAudioTranscriptionsCreate(ctx context.Context, cmd *cli.Command) erro
format := cmd.Root().String("format")
explicitFormat := cmd.Root().IsSet("format")
transform := cmd.Root().String("transform")
if cmd.Bool("stream") {
if requestflag.FlagBool(cmd, "stream") {
stream := client.Audio.Transcriptions.NewStreaming(ctx, params, options...)
maxItems := int64(-1)
if cmd.IsSet("max-items") {
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/betaresponse.go
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,7 @@ func handleBetaResponsesCreate(ctx context.Context, cmd *cli.Command) error {
format := cmd.Root().String("format")
explicitFormat := cmd.Root().IsSet("format")
transform := cmd.Root().String("transform")
if cmd.Bool("stream") {
if requestflag.FlagBool(cmd, "stream") {
stream := client.Beta.Responses.NewStreaming(ctx, params, options...)
maxItems := int64(-1)
if cmd.IsSet("max-items") {
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/betathread.go
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,7 @@ func handleBetaThreadsCreateAndRun(ctx context.Context, cmd *cli.Command) error
format := cmd.Root().String("format")
explicitFormat := cmd.Root().IsSet("format")
transform := cmd.Root().String("transform")
if cmd.Bool("stream") {
if requestflag.FlagBool(cmd, "stream") {
stream := client.Beta.Threads.NewAndRunStreaming(ctx, params, options...)
maxItems := int64(-1)
if cmd.IsSet("max-items") {
Expand Down
4 changes: 2 additions & 2 deletions pkg/cmd/betathreadrun.go
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ func handleBetaThreadsRunsCreate(ctx context.Context, cmd *cli.Command) error {
format := cmd.Root().String("format")
explicitFormat := cmd.Root().IsSet("format")
transform := cmd.Root().String("transform")
if cmd.Bool("stream") {
if requestflag.FlagBool(cmd, "stream") {
stream := client.Beta.Threads.Runs.NewStreaming(
ctx,
cmd.Value("thread-id").(string),
Expand Down Expand Up @@ -645,7 +645,7 @@ func handleBetaThreadsRunsSubmitToolOutputs(ctx context.Context, cmd *cli.Comman
format := cmd.Root().String("format")
explicitFormat := cmd.Root().IsSet("format")
transform := cmd.Root().String("transform")
if cmd.Bool("stream") {
if requestflag.FlagBool(cmd, "stream") {
stream := client.Beta.Threads.Runs.SubmitToolOutputsStreaming(
ctx,
cmd.Value("thread-id").(string),
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/chatcompletion.go
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ func handleChatCompletionsCreate(ctx context.Context, cmd *cli.Command) error {
format := cmd.Root().String("format")
explicitFormat := cmd.Root().IsSet("format")
transform := cmd.Root().String("transform")
if cmd.Bool("stream") {
if requestflag.FlagBool(cmd, "stream") {
stream := client.Chat.Completions.NewStreaming(ctx, params, options...)
maxItems := int64(-1)
if cmd.IsSet("max-items") {
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ func handleCompletionsCreate(ctx context.Context, cmd *cli.Command) error {
format := cmd.Root().String("format")
explicitFormat := cmd.Root().IsSet("format")
transform := cmd.Root().String("transform")
if cmd.Bool("stream") {
if requestflag.FlagBool(cmd, "stream") {
stream := client.Completions.NewStreaming(ctx, params, options...)
maxItems := int64(-1)
if cmd.IsSet("max-items") {
Expand Down
4 changes: 2 additions & 2 deletions pkg/cmd/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ func handleImagesEdit(ctx context.Context, cmd *cli.Command) error {
format := cmd.Root().String("format")
explicitFormat := cmd.Root().IsSet("format")
transform := cmd.Root().String("transform")
if cmd.Bool("stream") {
if requestflag.FlagBool(cmd, "stream") {
stream := client.Images.EditStreaming(ctx, params, options...)
maxItems := int64(-1)
if cmd.IsSet("max-items") {
Expand Down Expand Up @@ -375,7 +375,7 @@ func handleImagesGenerate(ctx context.Context, cmd *cli.Command) error {
format := cmd.Root().String("format")
explicitFormat := cmd.Root().IsSet("format")
transform := cmd.Root().String("transform")
if cmd.Bool("stream") {
if requestflag.FlagBool(cmd, "stream") {
stream := client.Images.GenerateStreaming(ctx, params, options...)
maxItems := int64(-1)
if cmd.IsSet("max-items") {
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,7 @@ func handleResponsesCreate(ctx context.Context, cmd *cli.Command) error {
format := cmd.Root().String("format")
explicitFormat := cmd.Root().IsSet("format")
transform := cmd.Root().String("transform")
if cmd.Bool("stream") {
if requestflag.FlagBool(cmd, "stream") {
stream := client.Responses.NewStreaming(ctx, params, options...)
maxItems := int64(-1)
if cmd.IsSet("max-items") {
Expand Down
182 changes: 182 additions & 0 deletions pkg/cmd/streaming_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package cmd

import (
"bufio"
"bytes"
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/require"
)

func TestNullableStreamFlags(t *testing.T) {
cliPath := buildTestCLI(t)
inputFile := filepath.Join(t.TempDir(), "input.txt")
require.NoError(t, os.WriteFile(inputFile, []byte("input"), 0o600))

tests := []struct {
name string
args []string
}{
{
name: "completions create",
args: []string{"completions", "create", "--model", "model", "--prompt", "prompt"},
},
{
name: "responses create",
args: []string{"responses", "create", "--model", "model", "--input", "input"},
},
{
name: "beta responses create",
args: []string{"beta:responses", "create", "--model", "model", "--input", "input"},
},
{
name: "chat completions create",
args: []string{"chat:completions", "create", "--model", "model", "--message", "{content: input, role: user}"},
},
{
name: "audio transcriptions create",
args: []string{"audio:transcriptions", "create", "--file", inputFile, "--model", "model"},
},
{
name: "beta thread runs create",
args: []string{"beta:threads:runs", "create", "--thread-id", "thread", "--assistant-id", "assistant"},
},
{
name: "beta thread runs submit tool outputs",
args: []string{"beta:threads:runs", "submit-tool-outputs", "--thread-id", "thread", "--run-id", "run", "--tool-output", "{output: output, tool_call_id: call}"},
},
{
name: "beta threads create and run",
args: []string{"beta:threads", "create-and-run", "--assistant-id", "assistant"},
},
{
name: "images edit",
args: []string{"images", "edit", "--image", inputFile, "--prompt", "prompt"},
},
{
name: "images generate",
args: []string{"images", "generate", "--prompt", "prompt"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprint(w, "data: {}\n\ndata: [DONE]\n\n")
}))
defer server.Close()

args := append(baseTestCLIArgs(server.URL), tt.args...)
args = append(args, "--stream", "true", "--max-items", "1")
output, err := runTestCLI(t, cliPath, args...)
require.NoError(t, err, "output: %s", output)
require.Contains(t, string(output), "{}")
})
}

t.Run("null selects the non-streaming handler", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"id":"completion"}`)
}))
defer server.Close()

args := append(baseTestCLIArgs(server.URL), "completions", "create", "--model", "model", "--prompt", "prompt", "--stream", "null")
output, err := runTestCLI(t, cliPath, args...)
require.NoError(t, err, "output: %s", output)
require.Contains(t, string(output), `"id":"completion"`)
})

t.Run("writes an SSE event before the response completes", func(t *testing.T) {
releaseResponse := make(chan struct{})
released := false
defer func() {
if !released {
close(releaseResponse)
}
}()

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
fmt.Fprint(w, "data: {\"id\":\"event\"}\n\n")
flusher.Flush()
<-releaseResponse
fmt.Fprint(w, "data: [DONE]\n\n")
flusher.Flush()
}))
defer server.Close()

ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
args := append(baseTestCLIArgs(server.URL), "completions", "create", "--model", "model", "--prompt", "prompt", "--stream", "true", "--max-items", "1")
command := exec.CommandContext(ctx, cliPath, args...)
command.Env = append(os.Environ(), "PAGER=cat")
stdout, err := command.StdoutPipe()
require.NoError(t, err)
var stderr bytes.Buffer
command.Stderr = &stderr
require.NoError(t, command.Start())

type outputResult struct {
line string
err error
}
output := make(chan outputResult, 1)
go func() {
line, readErr := bufio.NewReader(stdout).ReadString('\n')
output <- outputResult{line: line, err: readErr}
}()

select {
case result := <-output:
require.NoError(t, result.err)
require.JSONEq(t, `{"id":"event"}`, result.line)
case <-ctx.Done():
require.FailNow(t, "command did not emit streamed output before the response completed")
}

close(releaseResponse)
released = true
require.NoError(t, command.Wait(), "stderr: %s", stderr.String())
})
}

func buildTestCLI(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "openai")
command := exec.Command("go", "build", "-o", path, "../../cmd/openai")
output, err := command.CombinedOutput()
require.NoError(t, err, "output: %s", output)
return path
}

func baseTestCLIArgs(baseURL string) []string {
return []string{
"--base-url", baseURL,
"--format", "raw",
"--api-key", "key",
}
}

func runTestCLI(t *testing.T, path string, args ...string) ([]byte, error) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
command := exec.CommandContext(ctx, path, args...)
command.Env = append(os.Environ(), "PAGER=cat")
return command.CombinedOutput()
}