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
11 changes: 11 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,17 @@
# Auto-delete usage data older than N days, 0 = keep forever (default: 90)
# USAGE_RETENTION_DAYS=90

# =============================================================================
# Experimental OpenAI Compatibility Flags
# =============================================================================

# Preserve Anthropic reasoning fingerprints/signatures when routing Claude
# through the OpenAI-compatible APIs.
# WARNING: This adds Anthropic-specific non-standard fields such as
# reasoning_details / reasoning_signature / reasoning_index and can break strict
# OpenAI-compatible clients, SDKs, or schema validators. Disabled by default.
# OPENAI_COMPAT_BREAKING_ANTHROPIC_THINKING_SIGNATURES=false

# =============================================================================
# Provider API Keys (uncomment and set the ones you need)
# =============================================================================
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ Key settings:
| `METRICS_ENABLED` | `false` | Enable Prometheus metrics |
| `LOGGING_ENABLED` | `false` | Enable audit logging |
| `GUARDRAILS_ENABLED` | `false` | Enable the configured guardrails pipeline |
| `OPENAI_COMPAT_BREAKING_ANTHROPIC_THINKING_SIGNATURES` | `false` | Preserve Anthropic reasoning fingerprints/signatures in OpenAI-compatible payloads by emitting non-standard fields; breaks strict compatibility |

**Quick Start - Authentication:** By default `GOMODEL_MASTER_KEY` is unset. Without this key, API endpoints are unprotected and anyone can call them. This is insecure for production. **Strongly recommend** setting a strong secret before exposing the service. Add `GOMODEL_MASTER_KEY` to your `.env` or environment for production deployments.

Expand Down
14 changes: 14 additions & 0 deletions docs/advanced/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,20 @@ cp .env.template .env
`.env` file is only loaded if it exists — missing it is not an error.
</Note>

### Experimental Compatibility Flags

Some compatibility features are intentionally opt-in because they preserve
provider-specific behavior by emitting non-standard fields.

`OPENAI_COMPAT_BREAKING_ANTHROPIC_THINKING_SIGNATURES=true`

- Preserves Anthropic reasoning fingerprints/signatures when Claude is accessed
through the OpenAI-compatible Chat Completions and Responses APIs.
- Adds Anthropic-specific fields such as `reasoning_details`,
`reasoning_signature`, and `reasoning_index`.
- Can break strict OpenAI-compatible clients, typed SDKs, and schema validators,
so it is disabled by default.

### 3. Configuration File (YAML)

For more complex setups, you can use an optional YAML configuration file. GOModel looks for it in two locations (in order):
Expand Down
46 changes: 45 additions & 1 deletion internal/core/json_fields.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,50 @@ func CloneUnknownJSONFields(fields UnknownJSONFields) UnknownJSONFields {
return UnknownJSONFields{raw: CloneRawJSON(fields.raw)}
}

// MergeUnknownJSONFields combines multiple unknown-field objects into one.
// Later field sets override earlier ones for duplicate keys.
func MergeUnknownJSONFields(fields ...UnknownJSONFields) UnknownJSONFields {
if len(fields) == 0 {
return UnknownJSONFields{}
}

var merged map[string]json.RawMessage
for _, fieldSet := range fields {
if fieldSet.IsEmpty() {
continue
}

dec := json.NewDecoder(bytes.NewReader(fieldSet.raw))
tok, err := dec.Token()
if err != nil {
continue
}
delim, ok := tok.(json.Delim)
if !ok || delim != '{' {
continue
}

for dec.More() {
key, ok := readJSONObjectKey(dec)
if !ok {
return UnknownJSONFields{}
}

var value json.RawMessage
if err := dec.Decode(&value); err != nil {
return UnknownJSONFields{}
}

if merged == nil {
merged = make(map[string]json.RawMessage)
}
merged[key] = CloneRawJSON(value)
}
Comment on lines +45 to +70
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Inconsistent error handling causes silent data loss.

The error handling strategy is inconsistent:

  • Lines 47-52: Initial tokenization/delimiter errors use continue, preserving previously merged data
  • Lines 58 and 63: Mid-object parse errors use return UnknownJSONFields{}, discarding all accumulated data

If fieldSet #1 merges successfully but `fieldSet `#2 has a corrupt key/value mid-object, the function silently returns empty, losing fieldSet #1``'s data. The call sites in responses_adapter.go assign results directly to `dst.ExtraFields` without validation, so this data loss goes undetected.

Consider making error handling consistent—either skip corrupt field sets entirely (like initial errors) or return an error so callers can react:

Option A: Skip corrupt field sets consistently
 		for dec.More() {
 			key, ok := readJSONObjectKey(dec)
 			if !ok {
-				return UnknownJSONFields{}
+				break // skip this corrupt fieldSet, keep accumulated data
 			}

 			var value json.RawMessage
 			if err := dec.Decode(&value); err != nil {
-				return UnknownJSONFields{}
+				break // skip this corrupt fieldSet, keep accumulated data
 			}

 			if merged == nil {
 				merged = make(map[string]json.RawMessage)
 			}
 			merged[key] = CloneRawJSON(value)
 		}
Option B: Return error for caller awareness
-func MergeUnknownJSONFields(fields ...UnknownJSONFields) UnknownJSONFields {
+func MergeUnknownJSONFields(fields ...UnknownJSONFields) (UnknownJSONFields, error) {
 	if len(fields) == 0 {
-		return UnknownJSONFields{}
+		return UnknownJSONFields{}, nil
 	}
 // ... update error sites to return (UnknownJSONFields{}, err)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
dec := json.NewDecoder(bytes.NewReader(fieldSet.raw))
tok, err := dec.Token()
if err != nil {
continue
}
delim, ok := tok.(json.Delim)
if !ok || delim != '{' {
continue
}
for dec.More() {
key, ok := readJSONObjectKey(dec)
if !ok {
return UnknownJSONFields{}
}
var value json.RawMessage
if err := dec.Decode(&value); err != nil {
return UnknownJSONFields{}
}
if merged == nil {
merged = make(map[string]json.RawMessage)
}
merged[key] = CloneRawJSON(value)
}
dec := json.NewDecoder(bytes.NewReader(fieldSet.raw))
tok, err := dec.Token()
if err != nil {
continue
}
delim, ok := tok.(json.Delim)
if !ok || delim != '{' {
continue
}
for dec.More() {
key, ok := readJSONObjectKey(dec)
if !ok {
break // skip this corrupt fieldSet, keep accumulated data
}
var value json.RawMessage
if err := dec.Decode(&value); err != nil {
break // skip this corrupt fieldSet, keep accumulated data
}
if merged == nil {
merged = make(map[string]json.RawMessage)
}
merged[key] = CloneRawJSON(value)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/json_fields.go` around lines 45 - 70, The current parsing in
internal/core/json_fields.go inconsistently handles errors: initial
token/delimiter errors use continue but mid-object errors return
UnknownJSONFields{}, causing previously merged data (merged map) to be
discarded; change the mid-object error handling in the json decoding loop (where
readJSONObjectKey and dec.Decode(&value) are called) to mirror the initial
behavior by skipping the entire corrupt fieldSet instead of returning empty —
i.e., on failing readJSONObjectKey or dec.Decode, continue to the next fieldSet,
preserve merged data, and only return UnknownJSONFields{} if no valid entries
were ever merged (or alternatively change the function to return an error so
callers can decide), update callers (e.g., responses_adapter.go usage of
dst.ExtraFields) accordingly to handle either a nil/unchanged result or an
error.

}

return UnknownJSONFieldsFromMap(merged)
}

// UnknownJSONFieldsFromMap converts a raw field map into a compact JSON object.
func UnknownJSONFieldsFromMap(fields map[string]json.RawMessage) UnknownJSONFields {
if len(fields) == 0 {
Expand All @@ -41,7 +85,7 @@ func UnknownJSONFieldsFromMap(fields map[string]json.RawMessage) UnknownJSONFiel
}
sort.Strings(keys)

buf := bytes.NewBuffer(make([]byte, 0, len(keys)*16))
var buf bytes.Buffer
buf.WriteByte('{')
for i, key := range keys {
if i > 0 {
Expand Down
23 changes: 23 additions & 0 deletions internal/core/json_fields_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,29 @@ func TestUnknownJSONFieldsFromMap_EmptyRawValueEncodesAsNull(t *testing.T) {
}
}

func TestMergeUnknownJSONFields(t *testing.T) {
first := UnknownJSONFieldsFromMap(map[string]json.RawMessage{
"x_first": json.RawMessage(`true`),
"x_shared": json.RawMessage(`"first"`),
})
second := UnknownJSONFieldsFromMap(map[string]json.RawMessage{
"x_second": json.RawMessage(`{"ok":true}`),
"x_shared": json.RawMessage(`"second"`),
})

merged := MergeUnknownJSONFields(first, second)

if got := merged.Lookup("x_first"); !bytes.Equal(got, []byte("true")) {
t.Fatalf("x_first = %s, want true", got)
}
if got := merged.Lookup("x_second"); !bytes.Equal(got, []byte(`{"ok":true}`)) {
t.Fatalf("x_second = %s, want object", got)
}
if got := merged.Lookup("x_shared"); !bytes.Equal(got, []byte(`"second"`)) {
t.Fatalf("x_shared = %s, want later override", got)
}
}

func TestExtractUnknownJSONFieldsObjectByScan_RejectsInvalidJSONSyntax(t *testing.T) {
tests := []struct {
name string
Expand Down
8 changes: 5 additions & 3 deletions internal/core/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ func (r *ResponsesRequest) WithStreaming() *ResponsesRequest {
// ResponsesInputElement represents a single item in the Responses API input array.
// It is a discriminated union keyed on Type:
// - "" or "message": a chat-style message with Role and Content
// - "reasoning": a reasoning item with Content
// - "function_call": a tool invocation with CallID, Name, and Arguments
// - "function_call_output": a tool result with CallID and Output
//
Expand All @@ -53,7 +54,7 @@ func (r *ResponsesRequest) WithStreaming() *ResponsesRequest {
// extensions can round-trip; Swagger ignores ExtraFields, and typed fields
// should be preferred when available.
type ResponsesInputElement struct {
Type string `json:"type,omitempty"` // "message", "function_call", "function_call_output"
Type string `json:"type,omitempty"` // "message", "reasoning", "function_call", "function_call_output"

// Message fields (type="" or "message")
Role string `json:"role,omitempty"`
Expand Down Expand Up @@ -88,7 +89,7 @@ type ResponsesResponse struct {
// ResponsesOutputItem represents an item in the output array.
type ResponsesOutputItem struct {
ID string `json:"id"`
Type string `json:"type"` // "message", "function_call", etc.
Type string `json:"type"` // "message", "reasoning", "function_call", etc.
Role string `json:"role,omitempty"`
Status string `json:"status,omitempty"`
CallID string `json:"call_id,omitempty"`
Expand All @@ -99,8 +100,9 @@ type ResponsesOutputItem struct {

// ResponsesContentItem represents a content item in the output.
type ResponsesContentItem struct {
Type string `json:"type"` // "output_text", "input_image", "input_audio", etc.
Type string `json:"type"` // "output_text", "reasoning_text", "input_image", "input_audio", etc.
Text string `json:"text,omitempty"`
Signature string `json:"signature,omitempty"`
ImageURL *ImageURLContent `json:"image_url,omitempty"`
InputAudio *InputAudioContent `json:"input_audio,omitempty"`
// Providers can return structured annotation objects here (for example
Expand Down
24 changes: 24 additions & 0 deletions internal/core/responses_json.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,18 @@ func (e *ResponsesInputElement) UnmarshalJSON(data []byte) error {
if v, ok := raw["output"]; ok {
e.Output = stringifyRawValue(v)
}
case "reasoning":
if v, ok := raw["status"]; ok {
_ = json.Unmarshal(v, &e.Status)
}
if v, ok := raw["content"]; ok {
trimmed := bytes.TrimSpace(v)
if len(trimmed) != 0 && !bytes.Equal(trimmed, []byte("null")) {
var content any
_ = json.Unmarshal(trimmed, &content)
e.Content = content
}
}
default: // message (type="" or "message")
if v, ok := raw["role"]; ok {
_ = json.Unmarshal(v, &e.Role)
Expand All @@ -174,6 +186,8 @@ func (e *ResponsesInputElement) UnmarshalJSON(data []byte) error {
knownFields = append(knownFields, "call_id", "id", "name", "arguments", "status")
case "function_call_output":
knownFields = append(knownFields, "call_id", "status", "output")
case "reasoning":
knownFields = append(knownFields, "status", "content")
default:
knownFields = append(knownFields, "role", "status", "content")
}
Expand Down Expand Up @@ -216,6 +230,16 @@ func (e ResponsesInputElement) MarshalJSON() ([]byte, error) {
Output: e.Output,
Status: e.Status,
}, e.ExtraFields)
case "reasoning":
return marshalWithUnknownJSONFields(struct {
Type string `json:"type"`
Content any `json:"content,omitempty"`
Status string `json:"status,omitempty"`
}{
Type: "reasoning",
Content: e.Content,
Status: e.Status,
}, e.ExtraFields)
default: // message
type msg struct {
Type string `json:"type,omitempty"`
Expand Down
71 changes: 71 additions & 0 deletions internal/core/responses_json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,35 @@ func TestResponsesRequestUnmarshalJSON_ArrayInputFunctionCall(t *testing.T) {
}
}

func TestResponsesRequestUnmarshalJSON_ArrayInputReasoning(t *testing.T) {
var req ResponsesRequest
if err := json.Unmarshal([]byte(`{"model":"gpt-4o-mini","input":[
{"type":"reasoning","status":"completed","content":[{"type":"reasoning_text","text":"Let me think.","signature":"sig_123"}]},
{"type":"message","role":"assistant","content":"Hello"}
]}`), &req); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}

input, ok := req.Input.([]ResponsesInputElement)
if !ok || len(input) != 2 {
t.Fatalf("Input = %#v, want []ResponsesInputElement len=2", req.Input)
}
if input[0].Type != "reasoning" || input[0].Status != "completed" {
t.Fatalf("Input[0] = %+v, want reasoning/completed", input[0])
}
content, ok := input[0].Content.([]any)
if !ok || len(content) != 1 {
t.Fatalf("Input[0].Content = %#v, want []any len=1", input[0].Content)
}
part, ok := content[0].(map[string]any)
if !ok {
t.Fatalf("Input[0].Content[0] = %#v, want object", content[0])
}
if part["signature"] != "sig_123" {
t.Fatalf("Input[0].Content[0].signature = %#v, want sig_123", part["signature"])
}
}

func TestResponsesRequestUnmarshalJSON_FunctionCallAcceptsIDField(t *testing.T) {
var req ResponsesRequest
if err := json.Unmarshal([]byte(`{"model":"gpt-4o-mini","input":[
Expand Down Expand Up @@ -515,6 +544,48 @@ func TestResponsesInputElementMarshalJSON_FunctionCallOutput(t *testing.T) {
}
}

func TestResponsesInputElementMarshalJSON_Reasoning(t *testing.T) {
elem := ResponsesInputElement{
Type: "reasoning",
Content: []any{
map[string]any{
"type": "reasoning_text",
"text": "Let me think.",
"signature": "sig_123",
},
},
Status: "completed",
}

body, err := json.Marshal(elem)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}

var decoded map[string]any
if err := json.Unmarshal(body, &decoded); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}

if decoded["type"] != "reasoning" {
t.Fatalf("type = %v, want reasoning", decoded["type"])
}
if decoded["status"] != "completed" {
t.Fatalf("status = %v, want completed", decoded["status"])
}
content, ok := decoded["content"].([]any)
if !ok || len(content) != 1 {
t.Fatalf("content = %#v, want []any len=1", decoded["content"])
}
part, ok := content[0].(map[string]any)
if !ok {
t.Fatalf("content[0] = %#v, want object", content[0])
}
if part["signature"] != "sig_123" {
t.Fatalf("content[0].signature = %#v, want sig_123", part["signature"])
}
}

func TestResponsesInputElementRoundTrip(t *testing.T) {
original := `{"model":"gpt-4o-mini","input":[
{"role":"user","content":"What is the weather?"},
Expand Down
Loading
Loading