Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

View file

@ -0,0 +1,312 @@
package responses
import (
"bytes"
"encoding/json"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, _ bool) []byte {
rawJSON := inputRawJSON
inputResult := util.GetGJSONBytesNoCopy(rawJSON, "input")
if inputResult.Type == gjson.String {
input, _ := sjson.SetBytes([]byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`), "0.content.0.text", inputResult.String())
rawJSON, _ = sjson.SetRawBytes(rawJSON, "input", input)
inputResult = util.GetGJSONBytesNoCopy(rawJSON, "input")
}
rawJSON = setCodexRequiredBool(rawJSON, "stream", true)
rawJSON = setCodexRequiredBool(rawJSON, "store", false)
rawJSON = setCodexRequiredBool(rawJSON, "parallel_tool_calls", true)
rawJSON = setCodexRequiredInclude(rawJSON)
// Codex Responses rejects token limit fields, so strip them out before forwarding.
rawJSON = deleteCodexRequestFields(rawJSON, "max_output_tokens", "max_completion_tokens", "temperature", "top_p")
if serviceTier := gjson.GetBytes(rawJSON, "service_tier"); serviceTier.Exists() && serviceTier.String() != "priority" {
rawJSON = deleteCodexRequestFields(rawJSON, "service_tier")
}
rawJSON = deleteCodexRequestFields(rawJSON, "truncation", "prompt_cache_options", "prompt_cache_retention")
rawJSON = stripCodexResponsesCacheBreakpoints(rawJSON)
rawJSON = applyResponsesCompactionCompatibility(rawJSON)
// Delete the user field as it is not supported by the Codex upstream.
rawJSON = deleteCodexRequestFields(rawJSON, "user")
// Convert role "system" to "developer" in input array to comply with Codex API requirements.
rawJSON = convertSystemRoleToDeveloper(rawJSON)
rawJSON = normalizeCodexBuiltinTools(rawJSON)
return rawJSON
}
func setCodexRequiredBool(rawJSON []byte, path string, value bool) []byte {
current := gjson.GetBytes(rawJSON, path)
if value && current.Type == gjson.True || !value && current.Type == gjson.False {
return rawJSON
}
updated, errSet := sjson.SetBytes(rawJSON, path, value)
if errSet != nil {
return rawJSON
}
return updated
}
func setCodexRequiredInclude(rawJSON []byte) []byte {
current := gjson.GetBytes(rawJSON, "include")
values := current.Array()
if current.IsArray() && len(values) == 1 && values[0].Type == gjson.String && values[0].String() == "reasoning.encrypted_content" {
return rawJSON
}
updated, errSet := sjson.SetRawBytes(rawJSON, "include", []byte(`["reasoning.encrypted_content"]`))
if errSet != nil {
return rawJSON
}
return updated
}
func deleteCodexRequestFields(rawJSON []byte, paths ...string) []byte {
for _, path := range paths {
if !gjson.GetBytes(rawJSON, path).Exists() {
continue
}
updated, errDelete := sjson.DeleteBytes(rawJSON, path)
if errDelete == nil {
rawJSON = updated
}
}
return rawJSON
}
// stripCodexResponsesCacheBreakpoints removes any "prompt_cache_breakpoint" hint
// attached to individual input[].content[] items. Some clients (e.g. GitHub
// Copilot CLI) attach this field per content item when targeting the OpenAI
// Responses format. Codex Responses rejects it outright:
// {"error":{"message":"prompt_cache_breakpoint is not supported on this model", ...}}.
// The top-level prompt_cache_options strip above does not cover this nested case.
func stripCodexResponsesCacheBreakpoints(rawJSON []byte) []byte {
if !bytes.Contains(rawJSON, []byte(`"prompt_cache_breakpoint"`)) {
return rawJSON
}
input := util.GetGJSONBytesNoCopy(rawJSON, "input")
if !input.IsArray() {
return rawJSON
}
inputItems := input.Array()
if len(inputItems) == 0 {
return rawJSON
}
changed := false
rebuiltInput := make([][]byte, 0, len(inputItems))
for _, item := range inputItems {
itemRaw := []byte(item.Raw)
content := item.Get("content")
if content.IsArray() {
updatedContent, contentChanged := stripPromptCacheBreakpointFromContent(content)
if contentChanged {
if updatedItem, errSet := sjson.SetRawBytes(itemRaw, "content", updatedContent); errSet == nil {
itemRaw = updatedItem
changed = true
}
}
}
rebuiltInput = append(rebuiltInput, itemRaw)
}
if !changed {
return rawJSON
}
updated, errSet := sjson.SetRawBytes(rawJSON, "input", translatorcommon.JoinRawArray(rebuiltInput))
if errSet != nil {
return rawJSON
}
return updated
}
// stripPromptCacheBreakpointFromContent removes "prompt_cache_breakpoint" from each
// content part that carries it and reports whether anything changed.
func stripPromptCacheBreakpointFromContent(content gjson.Result) ([]byte, bool) {
parts := content.Array()
hasBreakpoint := false
for _, part := range parts {
if part.Get("prompt_cache_breakpoint").Exists() {
hasBreakpoint = true
break
}
}
if !hasBreakpoint {
return nil, false
}
changed := false
rebuiltParts := make([][]byte, 0, len(parts))
for _, part := range parts {
partRaw := []byte(part.Raw)
if part.Get("prompt_cache_breakpoint").Exists() {
if updated, errDelete := sjson.DeleteBytes(partRaw, "prompt_cache_breakpoint"); errDelete == nil {
partRaw = updated
changed = true
}
}
rebuiltParts = append(rebuiltParts, partRaw)
}
if !changed {
return nil, false
}
return translatorcommon.JoinRawArray(rebuiltParts), true
}
// applyResponsesCompactionCompatibility handles OpenAI Responses context_management.compaction
// for Codex upstream compatibility.
//
// Codex /responses currently rejects context_management with:
// {"detail":"Unsupported parameter: context_management"}.
//
// Compatibility strategy:
// 1) Remove context_management before forwarding to Codex upstream.
func applyResponsesCompactionCompatibility(rawJSON []byte) []byte {
if !gjson.GetBytes(rawJSON, "context_management").Exists() {
return rawJSON
}
rawJSON, _ = sjson.DeleteBytes(rawJSON, "context_management")
return rawJSON
}
// convertSystemRoleToDeveloper traverses the input array and converts any message items
// with role "system" to role "developer". This is necessary because Codex API does not
// accept "system" role in the input array.
func convertSystemRoleToDeveloper(rawJSON []byte) []byte {
return convertSystemRoleToDeveloperWithInput(rawJSON, util.GetGJSONBytesNoCopy(rawJSON, "input"))
}
func convertSystemRoleToDeveloperWithInput(rawJSON []byte, inputResult gjson.Result) []byte {
if !inputResult.IsArray() {
return rawJSON
}
inputItems := inputResult.Array()
if len(inputItems) == 0 {
return rawJSON
}
hasSystemRole := false
for _, item := range inputItems {
if item.IsObject() && item.Get("role").String() == "system" {
hasSystemRole = true
break
}
}
if !hasSystemRole {
return rawJSON
}
changed := false
rebuiltInput := make([]json.RawMessage, 0, len(inputItems))
for _, item := range inputItems {
itemRaw := []byte(item.Raw)
if item.IsObject() && item.Get("role").String() == "system" {
updatedItem, errSetItem := sjson.SetRawBytes(itemRaw, "role", []byte(`"developer"`))
if errSetItem != nil {
return rawJSON
}
itemRaw = updatedItem
changed = true
}
rebuiltInput = append(rebuiltInput, json.RawMessage(itemRaw))
}
if !changed {
return rawJSON
}
inputRaw, errMarshalInput := json.Marshal(rebuiltInput)
if errMarshalInput != nil {
return rawJSON
}
updated, errSetInput := sjson.SetRawBytes(rawJSON, "input", inputRaw)
if errSetInput != nil {
return rawJSON
}
return updated
}
// normalizeCodexBuiltinTools rewrites legacy/preview built-in tool variants to the
// stable names expected by the current Codex upstream.
func normalizeCodexBuiltinTools(rawJSON []byte) []byte {
result := normalizeCodexBuiltinToolArray(rawJSON, "tools")
result = normalizeCodexBuiltinToolAtPath(result, "tool_choice.type")
return normalizeCodexBuiltinToolArray(result, "tool_choice.tools")
}
func normalizeCodexBuiltinToolArray(rawJSON []byte, path string) []byte {
tools := gjson.GetBytes(rawJSON, path)
if !tools.IsArray() {
return rawJSON
}
changed := false
var toolItems [][]byte
tools.ForEach(func(_, tool gjson.Result) bool {
item := []byte(tool.Raw)
currentType := tool.Get("type").String()
normalizedType := normalizeCodexBuiltinToolType(currentType)
if normalizedType != "" {
updated, errSetType := sjson.SetBytes(item, "type", normalizedType)
if errSetType == nil {
item = updated
changed = true
log.Debugf("codex responses: normalized builtin tool type at %s.%d.type from %q to %q", path, len(toolItems), currentType, normalizedType)
}
}
toolItems = append(toolItems, item)
return true
})
if !changed {
return rawJSON
}
updated, errSetTools := sjson.SetRawBytes(rawJSON, path, translatorcommon.JoinRawArray(toolItems))
if errSetTools != nil {
return rawJSON
}
return updated
}
func normalizeCodexBuiltinToolAtPath(rawJSON []byte, path string) []byte {
currentType := gjson.GetBytes(rawJSON, path).String()
normalizedType := normalizeCodexBuiltinToolType(currentType)
if normalizedType == "" {
return rawJSON
}
updated, err := sjson.SetBytes(rawJSON, path, normalizedType)
if err != nil {
return rawJSON
}
log.Debugf("codex responses: normalized builtin tool type at %s from %q to %q", path, currentType, normalizedType)
return updated
}
// normalizeCodexBuiltinToolType centralizes the current known Codex Responses
// built-in tool alias compatibility. If Codex introduces more legacy aliases,
// extend this helper instead of adding path-specific rewrite logic elsewhere.
func normalizeCodexBuiltinToolType(toolType string) string {
switch toolType {
case "web_search_preview", "web_search_preview_2025_03_11":
return "web_search"
default:
return ""
}
}

View file

@ -0,0 +1,680 @@
package responses
import (
"fmt"
"strconv"
"strings"
"testing"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
var benchmarkConvertSystemRoleOutput []byte
var benchmarkConvertNormalizedOutput []byte
// TestConvertSystemRoleToDeveloper_BasicConversion tests the basic system -> developer role conversion
func TestConvertSystemRoleToDeveloper_BasicConversion(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.2",
"input": [
{
"type": "message",
"role": "system",
"content": [{"type": "input_text", "text": "You are a pirate."}]
},
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Say hello."}]
}
]
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
// Check that system role was converted to developer
firstItemRole := gjson.Get(outputStr, "input.0.role")
if firstItemRole.String() != "developer" {
t.Errorf("Expected role 'developer', got '%s'", firstItemRole.String())
}
// Check that user role remains unchanged
secondItemRole := gjson.Get(outputStr, "input.1.role")
if secondItemRole.String() != "user" {
t.Errorf("Expected role 'user', got '%s'", secondItemRole.String())
}
// Check content is preserved
firstItemContent := gjson.Get(outputStr, "input.0.content.0.text")
if firstItemContent.String() != "You are a pirate." {
t.Errorf("Expected content 'You are a pirate.', got '%s'", firstItemContent.String())
}
}
// TestConvertSystemRoleToDeveloper_MultipleSystemMessages tests conversion with multiple system messages
func TestConvertSystemRoleToDeveloper_MultipleSystemMessages(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.2",
"input": [
{
"type": "message",
"role": "system",
"content": [{"type": "input_text", "text": "You are helpful."}]
},
{
"type": "message",
"role": "system",
"content": [{"type": "input_text", "text": "Be concise."}]
},
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Hello"}]
}
]
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
// Check that both system roles were converted
firstRole := gjson.Get(outputStr, "input.0.role")
if firstRole.String() != "developer" {
t.Errorf("Expected first role 'developer', got '%s'", firstRole.String())
}
secondRole := gjson.Get(outputStr, "input.1.role")
if secondRole.String() != "developer" {
t.Errorf("Expected second role 'developer', got '%s'", secondRole.String())
}
// Check that user role is unchanged
thirdRole := gjson.Get(outputStr, "input.2.role")
if thirdRole.String() != "user" {
t.Errorf("Expected third role 'user', got '%s'", thirdRole.String())
}
}
// TestConvertSystemRoleToDeveloper_NoSystemMessages tests that requests without system messages are unchanged
func TestConvertSystemRoleToDeveloper_NoSystemMessages(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.2",
"input": [
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Hello"}]
},
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "Hi there!"}]
}
]
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
// Check that user and assistant roles are unchanged
firstRole := gjson.Get(outputStr, "input.0.role")
if firstRole.String() != "user" {
t.Errorf("Expected role 'user', got '%s'", firstRole.String())
}
secondRole := gjson.Get(outputStr, "input.1.role")
if secondRole.String() != "assistant" {
t.Errorf("Expected role 'assistant', got '%s'", secondRole.String())
}
}
// TestConvertSystemRoleToDeveloper_EmptyInput tests that empty input arrays are handled correctly
func TestConvertSystemRoleToDeveloper_EmptyInput(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.2",
"input": []
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
// Check that input is still an empty array
inputArray := gjson.Get(outputStr, "input")
if !inputArray.IsArray() {
t.Error("Input should still be an array")
}
if len(inputArray.Array()) != 0 {
t.Errorf("Expected empty array, got %d items", len(inputArray.Array()))
}
}
// TestConvertSystemRoleToDeveloper_NoInputField tests that requests without input field are unchanged
func TestConvertSystemRoleToDeveloper_NoInputField(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.2",
"stream": false
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
// Check that other fields are still set correctly
stream := gjson.Get(outputStr, "stream")
if !stream.Bool() {
t.Error("Stream should be set to true by conversion")
}
store := gjson.Get(outputStr, "store")
if store.Bool() {
t.Error("Store should be set to false by conversion")
}
}
// TestConvertOpenAIResponsesRequestToCodex_OriginalIssue tests the exact issue reported by the user
func TestConvertOpenAIResponsesRequestToCodex_OriginalIssue(t *testing.T) {
// This is the exact input that was failing with "System messages are not allowed"
inputJSON := []byte(`{
"model": "gpt-5.2",
"input": [
{
"type": "message",
"role": "system",
"content": "You are a pirate. Always respond in pirate speak."
},
{
"type": "message",
"role": "user",
"content": "Say hello."
}
],
"stream": false
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
// Verify system role was converted to developer
firstRole := gjson.Get(outputStr, "input.0.role")
if firstRole.String() != "developer" {
t.Errorf("Expected role 'developer', got '%s'", firstRole.String())
}
// Verify stream was set to true (as required by Codex)
stream := gjson.Get(outputStr, "stream")
if !stream.Bool() {
t.Error("Stream should be set to true")
}
// Verify other required fields for Codex
store := gjson.Get(outputStr, "store")
if store.Bool() {
t.Error("Store should be false")
}
parallelCalls := gjson.Get(outputStr, "parallel_tool_calls")
if !parallelCalls.Bool() {
t.Error("parallel_tool_calls should be true")
}
include := gjson.Get(outputStr, "include")
if !include.IsArray() || len(include.Array()) != 1 {
t.Error("include should be an array with one element")
} else if include.Array()[0].String() != "reasoning.encrypted_content" {
t.Errorf("Expected include[0] to be 'reasoning.encrypted_content', got '%s'", include.Array()[0].String())
}
}
func TestConvertOpenAIResponsesRequestToCodexReusesNormalizedPayload(t *testing.T) {
inputJSON := []byte(`{"model":"gpt-5.6","stream":true,"store":false,"parallel_tool_calls":true,"include":["reasoning.encrypted_content"],"service_tier":"priority","input":[{"type":"message","role":"user","content":"hello"}]}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.6", inputJSON, true)
if &output[0] != &inputJSON[0] {
t.Fatal("normalized request payload was copied")
}
if string(output) != string(inputJSON) {
t.Fatalf("normalized request changed:\n got: %s\nwant: %s", output, inputJSON)
}
}
func TestConvertOpenAIResponsesRequestToCodexNormalizesRequiredFields(t *testing.T) {
inputJSON := []byte(`{
"model":"gpt-5.6",
"stream":"true",
"store":true,
"parallel_tool_calls":false,
"include":["file_search_call.results","reasoning.encrypted_content"],
"max_output_tokens":4096,
"max_completion_tokens":4096,
"temperature":0.2,
"top_p":0.9,
"service_tier":"standard",
"truncation":"auto",
"prompt_cache_options":{"mode":"implicit"},
"prompt_cache_retention":"24h",
"user":"request-owner",
"input":[{"type":"message","role":"system","content":"hello"}]
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.6", inputJSON, true)
if stream := gjson.GetBytes(output, "stream"); stream.Type != gjson.True {
t.Fatalf("stream = %s, want true", stream.Raw)
}
if store := gjson.GetBytes(output, "store"); store.Type != gjson.False {
t.Fatalf("store = %s, want false", store.Raw)
}
if parallel := gjson.GetBytes(output, "parallel_tool_calls"); parallel.Type != gjson.True {
t.Fatalf("parallel_tool_calls = %s, want true", parallel.Raw)
}
include := gjson.GetBytes(output, "include").Array()
if len(include) != 1 || include[0].Type != gjson.String || include[0].String() != "reasoning.encrypted_content" {
t.Fatalf("include = %s, want reasoning.encrypted_content only", gjson.GetBytes(output, "include").Raw)
}
if role := gjson.GetBytes(output, "input.0.role").String(); role != "developer" {
t.Fatalf("input.0.role = %q, want developer", role)
}
for _, path := range []string{
"max_output_tokens",
"max_completion_tokens",
"temperature",
"top_p",
"service_tier",
"truncation",
"prompt_cache_options",
"prompt_cache_retention",
"user",
} {
if gjson.GetBytes(output, path).Exists() {
t.Fatalf("%s should be removed: %s", path, output)
}
}
}
func TestConvertOpenAIResponsesRequestToCodex_FiltersPromptCacheRetention(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.6-terra",
"prompt_cache_retention": "24h",
"input": [
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "hello"
}
]
}
]
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.6-terra", inputJSON, true)
if gjson.GetBytes(output, "prompt_cache_retention").Exists() {
t.Fatalf("prompt_cache_retention should be removed: %s", string(output))
}
}
// TestConvertSystemRoleToDeveloper_AssistantRole tests that assistant role is preserved
func TestConvertSystemRoleToDeveloper_AssistantRole(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.2",
"input": [
{
"type": "message",
"role": "system",
"content": [{"type": "input_text", "text": "You are helpful."}]
},
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Hello"}]
},
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "Hi!"}]
}
]
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
// Check system -> developer
firstRole := gjson.Get(outputStr, "input.0.role")
if firstRole.String() != "developer" {
t.Errorf("Expected first role 'developer', got '%s'", firstRole.String())
}
// Check user unchanged
secondRole := gjson.Get(outputStr, "input.1.role")
if secondRole.String() != "user" {
t.Errorf("Expected second role 'user', got '%s'", secondRole.String())
}
// Check assistant unchanged
thirdRole := gjson.Get(outputStr, "input.2.role")
if thirdRole.String() != "assistant" {
t.Errorf("Expected third role 'assistant', got '%s'", thirdRole.String())
}
}
func TestConvertOpenAIResponsesRequestToCodex_NormalizesWebSearchPreview(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.4-mini",
"input": "find latest OpenAI model news",
"tools": [
{"type": "web_search_preview_2025_03_11"}
],
"tool_choice": {
"type": "allowed_tools",
"tools": [
{"type": "web_search_preview"},
{"type": "web_search_preview_2025_03_11"}
]
}
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.4-mini", inputJSON, false)
if got := gjson.GetBytes(output, "tools.0.type").String(); got != "web_search" {
t.Fatalf("tools.0.type = %q, want %q: %s", got, "web_search", string(output))
}
if got := gjson.GetBytes(output, "tool_choice.type").String(); got != "allowed_tools" {
t.Fatalf("tool_choice.type = %q, want %q: %s", got, "allowed_tools", string(output))
}
if got := gjson.GetBytes(output, "tool_choice.tools.0.type").String(); got != "web_search" {
t.Fatalf("tool_choice.tools.0.type = %q, want %q: %s", got, "web_search", string(output))
}
if got := gjson.GetBytes(output, "tool_choice.tools.1.type").String(); got != "web_search" {
t.Fatalf("tool_choice.tools.1.type = %q, want %q: %s", got, "web_search", string(output))
}
}
func TestConvertOpenAIResponsesRequestToCodex_NormalizesTopLevelToolChoicePreviewAlias(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.4-mini",
"input": "find latest OpenAI model news",
"tool_choice": {"type": "web_search_preview_2025_03_11"}
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.4-mini", inputJSON, false)
if got := gjson.GetBytes(output, "tool_choice.type").String(); got != "web_search" {
t.Fatalf("tool_choice.type = %q, want %q: %s", got, "web_search", string(output))
}
}
func TestUserFieldDeletion(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.2",
"user": "test-user",
"input": [{"role": "user", "content": "Hello"}]
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
// Verify user field is deleted
userField := gjson.Get(outputStr, "user")
if userField.Exists() {
t.Errorf("user field should be deleted, but it was found with value: %s", userField.Raw)
}
}
func TestContextManagementCompactionCompatibility(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.2",
"context_management": [
{
"type": "compaction",
"compact_threshold": 12000
}
],
"input": [{"role":"user","content":"hello"}]
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
if gjson.Get(outputStr, "context_management").Exists() {
t.Fatalf("context_management should be removed for Codex compatibility")
}
if gjson.Get(outputStr, "truncation").Exists() {
t.Fatalf("truncation should be removed for Codex compatibility")
}
}
func TestTruncationRemovedForCodexCompatibility(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.2",
"truncation": "disabled",
"input": [{"role":"user","content":"hello"}]
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
if gjson.Get(outputStr, "truncation").Exists() {
t.Fatalf("truncation should be removed for Codex compatibility")
}
}
func TestStripCodexResponsesCacheBreakpoints(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.2",
"input": [
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "Hello world",
"prompt_cache_breakpoint": {"mode": "explicit"}
},
{
"type": "input_text",
"text": "Second part"
}
]
}
]
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
if strings.Contains(outputStr, "prompt_cache_breakpoint") {
t.Fatalf("prompt_cache_breakpoint should not exist in the output JSON")
}
if gjson.Get(outputStr, "input.0.content.0.text").String() != "Hello world" {
t.Fatalf("text content should be preserved")
}
if gjson.Get(outputStr, "input.0.content.1.text").String() != "Second part" {
t.Fatalf("second content part should be preserved")
}
}
func TestStripCodexResponsesCacheBreakpoints_WithSystemRole(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.2",
"input": [
{
"type": "message",
"role": "system",
"content": [
{
"type": "input_text",
"text": "System prompt",
"prompt_cache_breakpoint": {"mode": "explicit"}
}
]
},
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "User query",
"prompt_cache_breakpoint": {"mode": "explicit"}
}
]
}
]
}`)
output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
// Check system role is converted to developer
if gjson.Get(outputStr, "input.0.role").String() != "developer" {
t.Fatalf("expected role 'developer', got %q", gjson.Get(outputStr, "input.0.role").String())
}
// Check prompt_cache_breakpoint is completely removed from payload
if strings.Contains(outputStr, "prompt_cache_breakpoint") {
t.Fatalf("prompt_cache_breakpoint should not exist in the output JSON")
}
if gjson.Get(outputStr, "input.0.content.0.text").String() != "System prompt" {
t.Fatalf("expected system prompt text preserved, got %q", gjson.Get(outputStr, "input.0.content.0.text").String())
}
if gjson.Get(outputStr, "input.1.content.0.text").String() != "User query" {
t.Fatalf("expected user query text preserved, got %q", gjson.Get(outputStr, "input.1.content.0.text").String())
}
}
func BenchmarkConvertSystemRoleToDeveloperLargeInput(b *testing.B) {
cases := []struct {
name string
inputJSON []byte
}{
{
name: "200_input_1_system",
inputJSON: makeLargeResponsesInputForBenchmark(200, 200),
},
{
name: "200_input_2_system",
inputJSON: makeLargeResponsesInputForBenchmark(200, 100),
},
{
name: "2000_input_20_system",
inputJSON: makeLargeResponsesInputForBenchmark(2000, 100),
},
}
benchmarks := []struct {
name string
fn func([]byte) []byte
}{
{
name: "previous_root_path_rewrite",
fn: convertSystemRoleToDeveloperPreviousRootPathRewriteForBenchmark,
},
{
name: "current_rebuilt_input_json_marshal",
fn: convertSystemRoleToDeveloper,
},
}
for _, testCase := range cases {
for _, benchmark := range benchmarks {
b.Run(testCase.name+"/"+benchmark.name, func(b *testing.B) {
output := benchmark.fn(testCase.inputJSON)
if got := gjson.GetBytes(output, "input.0.role").String(); got != "developer" {
b.Fatalf("input.0.role = %q, want %q", got, "developer")
}
if got := gjson.GetBytes(output, "input.1.role").String(); got != "user" {
b.Fatalf("input.1.role = %q, want %q", got, "user")
}
b.ReportAllocs()
b.SetBytes(int64(len(testCase.inputJSON)))
b.ResetTimer()
var benchmarkOutput []byte
for i := 0; i < b.N; i++ {
benchmarkOutput = benchmark.fn(testCase.inputJSON)
}
benchmarkConvertSystemRoleOutput = benchmarkOutput
})
}
}
}
func BenchmarkConvertOpenAIResponsesRequestToCodexNormalizedPayload(b *testing.B) {
cases := []struct {
name string
inputJSON []byte
}{
{name: "1KiB", inputJSON: makeNormalizedResponsesRequestForBenchmark(1 << 10)},
{name: "1MiB", inputJSON: makeNormalizedResponsesRequestForBenchmark(1 << 20)},
{name: "8MiB", inputJSON: makeNormalizedResponsesRequestForBenchmark(8 << 20)},
}
for _, testCase := range cases {
b.Run(testCase.name, func(b *testing.B) {
b.ReportAllocs()
b.SetBytes(int64(len(testCase.inputJSON)))
b.ResetTimer()
var output []byte
for b.Loop() {
output = ConvertOpenAIResponsesRequestToCodex("gpt-5.6", testCase.inputJSON, true)
}
benchmarkConvertNormalizedOutput = output
})
}
}
func makeNormalizedResponsesRequestForBenchmark(contentBytes int) []byte {
var builder strings.Builder
builder.Grow(contentBytes + 256)
builder.WriteString(`{"model":"gpt-5.6","stream":true,"store":false,"parallel_tool_calls":true,"include":["reasoning.encrypted_content"],"input":[{"type":"message","role":"user","content":"`)
builder.WriteString(strings.Repeat("x", contentBytes))
builder.WriteString(`"}]}`)
return []byte(builder.String())
}
func makeLargeResponsesInputForBenchmark(inputCount int, systemEvery int) []byte {
var builder strings.Builder
builder.Grow(inputCount * 96)
builder.WriteString(`{"model":"gpt-5.2","input":[`)
for i := 0; i < inputCount; i++ {
if i > 0 {
builder.WriteByte(',')
}
role := "user"
if i%systemEvery == 0 {
role = "system"
}
builder.WriteString(`{"type":"message","role":"`)
builder.WriteString(role)
builder.WriteString(`","content":[{"type":"input_text","text":"message `)
builder.WriteString(strconv.Itoa(i))
builder.WriteString(`"}]}`)
}
builder.WriteString(`]}`)
return []byte(builder.String())
}
func convertSystemRoleToDeveloperPreviousRootPathRewriteForBenchmark(rawJSON []byte) []byte {
inputResult := gjson.GetBytes(rawJSON, "input")
if !inputResult.IsArray() {
return rawJSON
}
inputArray := inputResult.Array()
result := rawJSON
for i := 0; i < len(inputArray); i++ {
rolePath := fmt.Sprintf("input.%d.role", i)
if gjson.GetBytes(result, rolePath).String() == "system" {
result, _ = sjson.SetBytes(result, rolePath, "developer")
}
}
return result
}

View file

@ -0,0 +1,62 @@
package responses
import (
"bytes"
"context"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// ConvertCodexResponseToOpenAIResponses converts OpenAI Chat Completions streaming chunks
// to OpenAI Responses SSE events (response.*).
func ConvertCodexResponseToOpenAIResponses(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) [][]byte {
if bytes.HasPrefix(rawJSON, []byte("data:")) {
rawJSON = bytes.TrimSpace(rawJSON[5:])
rawJSON = setResponsesModel(rawJSON, modelName, originalRequestRawJSON, requestRawJSON)
out := make([]byte, 0, len(rawJSON)+len("data: "))
out = append(out, []byte("data: ")...)
out = append(out, rawJSON...)
return [][]byte{out}
}
return [][]byte{setResponsesModel(rawJSON, modelName, originalRequestRawJSON, requestRawJSON)}
}
func setResponsesModel(rawJSON []byte, modelName string, originalRequestRawJSON, requestRawJSON []byte) []byte {
eventType := gjson.GetBytes(rawJSON, "type").String()
if eventType != "response.created" && eventType != "response.in_progress" {
return rawJSON
}
if gjson.GetBytes(rawJSON, "response.model").Exists() {
return rawJSON
}
requestModelName := translatorcommon.RequestModelName(originalRequestRawJSON, requestRawJSON)
if requestModelName == "" {
requestModelName = modelName
}
if requestModelName == "" {
return rawJSON
}
updated, errSet := sjson.SetBytes(rawJSON, "response.model", requestModelName)
if errSet != nil {
return rawJSON
}
return updated
}
// ConvertCodexResponseToOpenAIResponsesNonStream builds a single Responses JSON
// from a non-streaming OpenAI Chat Completions response.
func ConvertCodexResponseToOpenAIResponsesNonStream(_ context.Context, _ string, _, _, rawJSON []byte, _ *any) []byte {
rootResult := gjson.ParseBytes(rawJSON)
// Verify this is a terminal response event.
responseType := rootResult.Get("type").String()
if responseType != "response.completed" && responseType != "response.incomplete" {
return []byte{}
}
responseResult := rootResult.Get("response")
return []byte(responseResult.Raw)
}

View file

@ -0,0 +1,38 @@
package responses
import (
"context"
"testing"
"github.com/tidwall/gjson"
)
func TestConvertCodexResponseToOpenAIResponses_CreatedIncludesOriginalRequestModel(t *testing.T) {
request := []byte(`{"model":"original-codex-model"}`)
translatedRequest := []byte(`{"model":"translated-codex-model"}`)
for eventName, raw := range map[string][]byte{
"response.created": []byte(`data: {"type":"response.created","response":{"id":"resp_1"}}`),
"response.in_progress": []byte(`data: {"type":"response.in_progress","response":{"id":"resp_1"}}`),
} {
outputs := ConvertCodexResponseToOpenAIResponses(context.Background(), "fallback-model", request, translatedRequest, raw, nil)
if len(outputs) != 1 {
t.Fatalf("%s outputs = %d, want 1", eventName, len(outputs))
}
if got := gjson.GetBytes(outputs[0], "response.model").String(); got != "original-codex-model" {
t.Fatalf("%s models = %q, want original-codex-model; payload=%s", eventName, got, outputs[0])
}
}
}
func TestConvertCodexResponseToOpenAIResponsesNonStreamIncomplete(t *testing.T) {
raw := []byte(`{"type":"response.incomplete","response":{"id":"resp_1","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"output":[],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}`)
out := ConvertCodexResponseToOpenAIResponsesNonStream(context.Background(), "gpt-5.5", nil, nil, raw, nil)
if got := gjson.GetBytes(out, "status").String(); got != "incomplete" {
t.Fatalf("status = %q, want incomplete; payload=%s", got, out)
}
if got := gjson.GetBytes(out, "incomplete_details.reason").String(); got != "max_output_tokens" {
t.Fatalf("incomplete reason = %q, want max_output_tokens; payload=%s", got, out)
}
}

View file

@ -0,0 +1,19 @@
package responses
import (
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
translator.Register(
OpenaiResponse,
Codex,
ConvertOpenAIResponsesRequestToCodex,
interfaces.TranslateResponse{
Stream: ConvertCodexResponseToOpenAIResponses,
NonStream: ConvertCodexResponseToOpenAIResponsesNonStream,
},
)
}