mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
> Worked on by Mux on Mike's behalf. ## Summary - Disable OpenAI Responses `previous_response_id` chain mode when the prior assistant response has unresolved local tool calls, so the next request can include paired tool outputs instead of sending an incomplete continuation. - Update the fantasy pin to a Responses replay fix that preserves stored reasoning references, only replays web search references when paired with reasoning, and validates local function-call output pairing before send. - Add fake OpenAI Responses input validation for the two production 400 shapes and integration coverage for full-history reasoning plus web search replay. - Add sanitized diagnostics for the OpenAI Responses continuity errors. ## Tests - `go test ./providers/openai -run 'TestResponsesToPrompt_(ReasoningWithStore|ReasoningWithWebSearchCombined|WebSearchRequiresReasoningReference|ReasoningWithFunctionCallCombined|WebSearchProviderExecutedToolResults)|TestPrepareParams_(SkipsProviderExecutedToolReferences|ValidatesFunctionCallOutputPairing)|TestValidateResponsesInput_WebSearchReferenceRequiresReasoning' -count=1` - `go test ./providers/openai -count=1` - `GOWORK=off go test ./coderd/x/chatd/chattest -run TestValidateResponsesAPIInput -count=1` - `GOWORK=off go test ./coderd/x/chatd -run 'TestOpenAIResponses(NoStaleWebSearchReplay|FullReplayPairsReasoningAndWebSearch|ChainModeSkipsWhenLocalCallPending|ChainModeStillFiresForProviderExecutedOnly)$|TestResolveChainMode_' -count=1` - `GOWORK=off go test ./coderd/x/chatd/chatprompt -run 'TestInjectMissingToolResults_' -count=1` - `GOWORK=off go test ./coderd/x/chatd/chaterror -run TestClassify_OpenAIResponsesAPIDiagnostics -count=1` - `GOWORK=off go test ./coderd/x/chatd/... -count=1` - `git diff --check` - `git commit` pre-commit hook
197 lines
5.2 KiB
Go
197 lines
5.2 KiB
Go
package chattest
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// ValidateResponsesAPIInput validates the Responses API item relationships
|
|
// that OpenAI enforces but the fake test server would otherwise miss.
|
|
func ValidateResponsesAPIInput(items []interface{}) *ErrorResponse {
|
|
if err := validateResponsesWebSearchReasoning(items); err != nil {
|
|
return err
|
|
}
|
|
return validateResponsesFunctionCallOutputs(items)
|
|
}
|
|
|
|
type responsesInputKind int
|
|
|
|
const (
|
|
responsesInputOther responsesInputKind = iota
|
|
responsesInputReasoning
|
|
responsesInputWebSearch
|
|
responsesInputFunctionCall
|
|
responsesInputFunctionCallOutput
|
|
)
|
|
|
|
type responsesInputItem struct {
|
|
kind responsesInputKind
|
|
id string
|
|
callID string
|
|
}
|
|
|
|
func validateResponsesWebSearchReasoning(items []interface{}) *ErrorResponse {
|
|
previousKind := responsesInputOther
|
|
for _, raw := range items {
|
|
item := classifyResponsesInputItem(raw)
|
|
if item.kind == responsesInputWebSearch && previousKind != responsesInputReasoning {
|
|
return openAIResponsesValidationError(fmt.Sprintf(
|
|
"Item %q of type 'web_search_call' was provided without its required 'reasoning' item.",
|
|
item.id,
|
|
))
|
|
}
|
|
previousKind = item.kind
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateResponsesFunctionCallOutputs(items []interface{}) *ErrorResponse {
|
|
type callState struct {
|
|
calls int
|
|
outputs int
|
|
firstCall int
|
|
firstOutput int
|
|
}
|
|
states := make(map[string]*callState)
|
|
var callIDs []string
|
|
var outputCallIDs []string
|
|
|
|
stateFor := func(callID string) *callState {
|
|
state, ok := states[callID]
|
|
if ok {
|
|
return state
|
|
}
|
|
state = &callState{firstCall: -1, firstOutput: -1}
|
|
states[callID] = state
|
|
return state
|
|
}
|
|
|
|
for index, raw := range items {
|
|
item := classifyResponsesInputItem(raw)
|
|
switch item.kind {
|
|
case responsesInputFunctionCall:
|
|
if item.callID == "" {
|
|
continue
|
|
}
|
|
state := stateFor(item.callID)
|
|
if state.calls == 0 {
|
|
callIDs = append(callIDs, item.callID)
|
|
state.firstCall = index
|
|
}
|
|
state.calls++
|
|
case responsesInputFunctionCallOutput:
|
|
if item.callID == "" {
|
|
continue
|
|
}
|
|
state := stateFor(item.callID)
|
|
if state.outputs == 0 {
|
|
outputCallIDs = append(outputCallIDs, item.callID)
|
|
state.firstOutput = index
|
|
}
|
|
state.outputs++
|
|
}
|
|
}
|
|
|
|
for _, callID := range callIDs {
|
|
state := states[callID]
|
|
if state.calls > 1 {
|
|
return openAIResponsesValidationError(fmt.Sprintf(
|
|
"Duplicate function call found for call_id %s.", callID,
|
|
))
|
|
}
|
|
}
|
|
for _, callID := range outputCallIDs {
|
|
state := states[callID]
|
|
if state.outputs > 1 {
|
|
return openAIResponsesValidationError(fmt.Sprintf(
|
|
"Duplicate tool output found for function call %s.", callID,
|
|
))
|
|
}
|
|
}
|
|
for _, callID := range outputCallIDs {
|
|
state := states[callID]
|
|
if state.calls == 0 || state.firstOutput < state.firstCall {
|
|
return openAIResponsesValidationError(fmt.Sprintf(
|
|
"Tool output found without preceding function call %s.", callID,
|
|
))
|
|
}
|
|
}
|
|
for _, callID := range callIDs {
|
|
state := states[callID]
|
|
if state.outputs == 0 {
|
|
return openAIResponsesValidationError(fmt.Sprintf(
|
|
"No tool output found for function call %s.", callID,
|
|
))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func classifyResponsesInputItem(raw interface{}) responsesInputItem {
|
|
itemMap, ok := raw.(map[string]interface{})
|
|
if !ok {
|
|
return responsesInputItem{kind: responsesInputOther}
|
|
}
|
|
|
|
itemType := StringResponseField(itemMap, "type")
|
|
id := StringResponseField(itemMap, "id")
|
|
callID := StringResponseField(itemMap, "call_id")
|
|
|
|
switch itemType {
|
|
case "reasoning":
|
|
return responsesInputItem{kind: responsesInputReasoning, id: id}
|
|
case "web_search_call":
|
|
return responsesInputItem{kind: responsesInputWebSearch, id: id}
|
|
case "function_call":
|
|
return responsesInputItem{kind: responsesInputFunctionCall, callID: callID}
|
|
case "function_call_output":
|
|
return responsesInputItem{kind: responsesInputFunctionCallOutput, callID: callID}
|
|
case "item_reference":
|
|
switch {
|
|
case strings.HasPrefix(id, "rs_"):
|
|
return responsesInputItem{kind: responsesInputReasoning, id: id}
|
|
case strings.HasPrefix(id, "ws_"):
|
|
return responsesInputItem{kind: responsesInputWebSearch, id: id}
|
|
default:
|
|
return responsesInputItem{kind: responsesInputOther, id: id}
|
|
}
|
|
}
|
|
|
|
// Some SDK encoders omit the type field for item references. Fall
|
|
// back to stable OpenAI item ID prefixes so tests still catch an
|
|
// invalid prompt shape.
|
|
switch {
|
|
case strings.HasPrefix(id, "rs_"):
|
|
return responsesInputItem{kind: responsesInputReasoning, id: id}
|
|
case strings.HasPrefix(id, "ws_"):
|
|
return responsesInputItem{kind: responsesInputWebSearch, id: id}
|
|
default:
|
|
return responsesInputItem{kind: responsesInputOther, id: id, callID: callID}
|
|
}
|
|
}
|
|
|
|
// StringResponseField returns the string value for key from a decoded
|
|
// Responses API item, or an empty string when the field is absent or not a
|
|
// string.
|
|
func StringResponseField(values map[string]interface{}, key string) string {
|
|
value, ok := values[key]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
text, ok := value.(string)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return text
|
|
}
|
|
|
|
func openAIResponsesValidationError(message string) *ErrorResponse {
|
|
return &ErrorResponse{
|
|
StatusCode: http.StatusBadRequest,
|
|
Type: "invalid_request_error",
|
|
Message: message,
|
|
}
|
|
}
|