Skip to content

fix(requestflag): merge inner flags into untyped array literals - #211

Open
feiiiiii5 wants to merge 1 commit into
openai:mainfrom
feiiiiii5:fix-requestflag-untyped-array-inner-fields
Open

feiiiiii5 wants to merge 1 commit into
openai:mainfrom
feiiiiii5:fix-requestflag-untyped-array-inner-fields

Conversation

@feiiiiii5

Copy link
Copy Markdown

Summary

For a body parameter that is a nullable array of objects, codegen emits an untyped
requestflag.Flag[any] plus InnerFlag children marked OuterIsArrayOfObjects. Combining
an array literal on the outer flag with any inner flag silently discards the inner value:

openai fine-tuning:jobs create \
  --model gpt-4o-mini --training-file file-abc123 \
  --integration '[{type: wandb}]' \
  --integration.wandb '{project: my-project, entity: me}'

request body built from ExtractRequestContents:

{"model":"gpt-4o-mini","training_file":"file-abc123","integrations":[{"type":"wandb"}]}

wandb is gone — no error, no warning, and --integration.wandb still counts as supplied,
so nothing downstream notices. Expected {"integrations":[{"type":"wandb","wandb":{…}}]}.

Problem

cliValue.SetInnerField dispatches the slice case on the static element kind
(internal/requestflag/requestflag.go):

case reflect.Slice:
	if flagValReflect.Type().Elem().Kind() != reflect.Map {
		return
	}
	...
	lastElement := flagValReflect.Index(sliceLen - 1).Interface().(map[string]any)

A Flag[any] outer holds a []any as soon as it is set from a JSON or YAML array literal,
and []any's element kind is reflect.Interface, not Map. Every inner-field assignment
therefore returns at that guard, while Flag.SetInnerField goes on to set
hasBeenSet = true. The typed outer Flag[[]map[string]any] (element kind Map) takes the
merge path — which is the behavior the existing TestInnerFlagAfterNullArrayElement cases
pin, including "merge into the trailing element" and "repeated field starts another element".

Two details in the surrounding code say the []any shape was meant to work: the append
switch already has a case []any: arm that the guard made unreachable, and
innerFieldIsSet in stdinprovenance.go already reads the trailing element of a []any
with a checked assertion.

Affected parameters, all five Flag[any] with array-of-objects inner flags (verified in
--help of the built binary): fine-tuning:jobs create --integration,
responses create --context-management, beta:responses create --context-management,
beta:threads:runs create --additional-message, beta:threads:messages create --attachment.

Live request capture

Same command against a local recorder on 127.0.0.1:4011 (synthetic data, no live endpoint),
binary built from 0169bff vs from this branch:

$ openai --api-key test --base-url http://127.0.0.1:4011 fine-tuning:jobs create \
    --model gpt-4o-mini --training-file file-abc123 \
    --integration '[{type: wandb}]' --integration.wandb '{project: my-project, entity: me}'
build POST /fine_tuning/jobs body, integrations
0169bff [{"type": "wandb"}]
this branch [{"type": "wandb", "wandb": {"entity": "me", "project": "my-project"}}]

Both runs exit 0 and get a 200 — the only signal that the field was lost is the body itself.

Fix

Dispatch elements by their dynamic type, and merge only into a trailing object:

switch flagValReflect.Type().Elem().Kind() {
case reflect.Map, reflect.Interface:
default:
	return
}

sliceLen := flagValReflect.Len()
if sliceLen > 0 {
	lastElement, isObject := flagValReflect.Index(sliceLen - 1).Interface().(map[string]any)
	if isObject {
		… unchanged …
	}
}
  • Trailing map[string]any → existing rule (merge unless it already carries the field;
    fill a nil map in place).
  • Anything that is not an object — a scalar element, an untyped null element, or a trailing
    element that already has the field → append a new object element, i.e. the same
    "repeated field starts another element" rule the typed path already uses. Nothing the user
    typed is discarded either way.
  • The unchecked Interface().(map[string]any) becomes checked, so widening the guard cannot
    turn the assertion below it into a panic path.

No new limits, no error paths, no signature changes: SetInnerField's contract and the
Flag/InnerFlag wiring are untouched. This is the remaining half of the gap #162 closed:
that change made a null element inside a typed []map[string]any work, while the guard
above still drops every inner flag whose outer is an untyped Flag[any].

Tests

New internal/requestflag/innerflag_untyped_array_test.go, 8 cases. Seven run through the
real cli.Command.RunExtractRequestContents path (the same map the request body is
built from), one drives Flag/InnerFlag directly to mirror the existing null-element
test:

vector expected body
--entry '[{"name":"earlier"}]' --entry.description details [{"name":"earlier","description":"details"}]
--entry '[{"name":"earlier"}]' --entry.name demo [{"name":"earlier"},{"name":"demo"}]
--entry '[{}]' --entry.name demo --entry.description details [{"name":"demo","description":"details"}]
--entry '- {name: earlier}' --entry.description details (YAML flow) [{"name":"earlier","description":"details"}]
--entry '["plain"]' --entry.name demo ["plain",{"name":"demo"}]
--entry '[null]' --entry.name demo [null,{"name":"demo"}]
--entry '[]' --entry.name demo [{"name":"demo"}]

On base 0169bff (same test file, requestflag.go restored from git show HEAD:…) all 8
fail, each dropping the inner field:

--- FAIL: TestInnerFlagAfterUntypedArrayLiteral/merge_into_the_trailing_element
	expected: …{"description":"details", "name":"earlier"}
	actual  : …{"name":"earlier"}

With the patch all 8 pass, and the pre-existing internal/requestflag suite passes
unchanged, so the typed []map[string]any / map[string]any vectors are unaffected.

For context on the syntax: inner flags are not listed in --help, but they are codegen'd
for exactly these parameters and are the style the generated inner flags subtests drive
(TestFineTuningJobsCreate in pkg/cmd/finetuningjob_test.go passes --integration.type
and --integration.wandb). Those tests use only inner flags, which is why the regression
escaped: the lost field needs an array literal and an inner flag in the same command.

Validation

cwd .runtime/2026-09-20/oa/src, base 0169bff (= origin/main), go1.25.0 darwin/arm64,
network proxies unset, -count=1 where shown:

Command Result
go test ./internal/requestflag/ -run 'TestInnerFlagAfterUntypedArrayLiteral' -v (unpatched) 8 FAIL
go test ./internal/requestflag/ -run 'TestInnerFlagAfterUntypedArrayLiteral' -v (patched) 8 PASS
go test ./internal/... ok, 0 failures
go test ./internal/requestflag/ -race -count=1 ok, 1.416s
go test ./... -run '^$' ok, all packages compile
go test ./pkg/cmd -count=1 with ./scripts/mock on 127.0.0.1:4010 62.9s, 1 failure: TestFilesCreateCLICancelClosesStalledFIFO
go test ./pkg/cmd -run TestFilesCreateCLICancelClosesStalledFIFO on base, patch reverted same failure → pre-existing, unrelated (FIFO cancellation, multipartresource_unix_test.go)
go vet ./... no output
./scripts/lint rc=0
gofmt -l internal/requestflag/ no output
go mod verify all modules verified
go build -o /tmp/oai ./cmd/openai (patched and git show HEAD: variants), each run against a local 127.0.0.1:4011 recorder bodies as tabulated under "Live request capture"
GOOS=windows GOARCH=amd64 go build ./..., GOOS=linux GOARCH=arm64 go test -c ./internal/requestflag/ ok

The mock-server dependency was reviewed before running it: scripts/steady/manifest.json
pins the Steady commit and Deno runtime, ./scripts/steady/install verified the source and
runtime digests, and the server bound to 127.0.0.1:4010 with synthetic data only.
./scripts/test itself was not run (it additionally cross-compiles the whole test tree); the
pkg/cmd suite above is the mock-backed part that covers this change.

internal/requestflag/requestflag.go is handwritten behavior per AGENTS.md, and the new
test file is wholly handwritten, so no generated-owned file changes and nothing in the
Castiron generation baseline moves. The change is in request parsing, so CODEOWNER review is
expected.

AI-assisted: an AI coding agent produced this change under the account owner's standing
instruction for this repo, ran every command above and read its output. No human reviewed
the diff before it was opened.

cliValue.SetInnerField dispatched the slice case on the static element
kind, so an untyped Flag[any] holding a []any from a JSON or YAML array
literal returned before the merge path and the inner-field value was
dropped while the flag still counted as supplied. Dispatch elements by
their dynamic type and check the trailing-element assertion, so a
Flag[[]map[string]any] outer keeps its existing behavior and a []any
outer follows the same merge or append-new-element rules.
@feiiiiii5
feiiiiii5 requested a review from a team as a code owner September 20, 2026 18:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant