Skip to content

Conversation

locnguyen1986
Copy link
Contributor

@locnguyen1986 locnguyen1986 commented Sep 15, 2025

Changes:

  • Add the new /responses API which match with OpenAI Responses API
  • Migrate all code and method into /v1
  • Introduce the new common Error
  • Fix the ID Gen
  • Improve the Google Auth Timeout

extract handler to different provider and fix API dos
…on-streaming

feat: Add OpenAI Responses API implementation with routes, handlers, validation, and models
…pletion-response-api

feat/94 Add non-stream response flow with conversation integration
…onses-create

Feat/95/ support streaming response with stateful conversation

Choose a reason for hiding this comment

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

@locnguyen1986 Out of curiosity, this file is manually crafted or generated?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yup :) It's generated file from our route files


// IsEmpty checks if the error is empty (no error)
func (e *Error) IsEmpty() bool {
return e == nil || e.Code == ""
Copy link
Collaborator

Choose a reason for hiding this comment

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

e == nil is unreachable

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's fixed

// Error represents a standardized error with code and message
type Error struct {
Code string `json:"code"`
Message string `json:"message"`
Copy link
Collaborator

Choose a reason for hiding this comment

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

We should wrap this with an error instance.

Copy link
Collaborator

Choose a reason for hiding this comment

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

My original suggestion is: if returning 3 args domain object, error, code from functions is redundant.
consider creating a wrapper struct like:

type Result[T any] struct {
    Data   T
    Err    error
    Code *string
}

Messages are not mandatory in the service, as the route or other clients will respond with different messages.

Comment on lines 478 to 481
ResponseFormat interface{}
Tools interface{}
ToolChoice interface{}
Metadata map[string]interface{}
Copy link
Collaborator

Choose a reason for hiding this comment

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

any

Comment on lines 698 to 747
publicID, err := idgen.GenerateSecureID("msg", 42)
if err != nil {
return false, common.NewError("u7v8w9x0-y1z2-3456-uvwx-789012345678", "Failed to generate item ID")
}

// Convert role
var role conversation.ItemRole
switch msg.Role {
case openai.ChatMessageRoleSystem:
role = conversation.ItemRoleSystem
case openai.ChatMessageRoleUser:
role = conversation.ItemRoleUser
case openai.ChatMessageRoleAssistant:
role = conversation.ItemRoleAssistant
default:
role = conversation.ItemRoleUser
}

// Convert content
content := make([]conversation.Content, 0, len(msg.MultiContent))
for _, contentPart := range msg.MultiContent {
if contentPart.Type == openai.ChatMessagePartTypeText {
content = append(content, conversation.Content{
Type: "text",
Text: &conversation.Text{
Value: contentPart.Text,
},
})
}
}

// If no multi-content, use simple text content
if len(content) == 0 && msg.Content != "" {
content = append(content, conversation.Content{
Type: "text",
Text: &conversation.Text{
Value: msg.Content,
},
})
}

item := &conversation.Item{
PublicID: publicID,
Type: conversation.ItemType("message"),
Role: &role,
Content: content,
ConversationID: conv.ID,
ResponseID: responseID,
CreatedAt: time.Now(),
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should it be moved to a simple factory?


// Call domain service (pure business logic)
result, err := responseRoute.responseModelService.CreateResponse(ctx, userID, domainRequest)
if !err.IsEmpty() {
Copy link
Collaborator

Choose a reason for hiding this comment

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

err != nil

Comment on lines 559 to 612
func (s *ResponseService) ConvertDomainResponseToAPIResponse(responseEntity *Response) responsetypes.Response {
apiResponse := responsetypes.Response{
ID: responseEntity.PublicID,
Object: "response",
Created: responseEntity.CreatedAt.Unix(),
Model: responseEntity.Model,
Status: responsetypes.ResponseStatus(responseEntity.Status),
Input: responseEntity.Input,
}

// Add conversation if exists
if responseEntity.ConversationID != nil {
apiResponse.Conversation = &responsetypes.ConversationInfo{
ID: fmt.Sprintf("conv_%d", *responseEntity.ConversationID),
}
}

// Add timestamps
if responseEntity.CompletedAt != nil {
apiResponse.CompletedAt = ptr.ToInt64(responseEntity.CompletedAt.Unix())
}
if responseEntity.CancelledAt != nil {
apiResponse.CancelledAt = ptr.ToInt64(responseEntity.CancelledAt.Unix())
}
if responseEntity.FailedAt != nil {
apiResponse.FailedAt = ptr.ToInt64(responseEntity.FailedAt.Unix())
}

// Parse output if exists
if responseEntity.Output != nil {
var output interface{}
if err := json.Unmarshal([]byte(*responseEntity.Output), &output); err == nil {
apiResponse.Output = output
}
}

// Parse usage if exists
if responseEntity.Usage != nil {
var usage responsetypes.DetailedUsage
if err := json.Unmarshal([]byte(*responseEntity.Usage), &usage); err == nil {
apiResponse.Usage = &usage
}
}

// Parse error if exists
if responseEntity.Error != nil {
var errorData responsetypes.ResponseError
if err := json.Unmarshal([]byte(*responseEntity.Error), &errorData); err == nil {
apiResponse.Error = &errorData
}
}

return apiResponse
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's move it to the http response folder. The HTTP response includes the domain, not the other way around.

}

// ConvertConversationItemToInputItem converts a conversation item to input item format
func (s *ResponseService) ConvertConversationItemToInputItem(item *conversation.Item) responsetypes.InputItem {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's move it to the http response folder. The HTTP response includes the domain, not the other way around.

}
if exists == nil {
exists, err = googleAuthAPI.authService.RegisterUser(reqCtx.Request.Context(), &user.User{
exists, err = googleAuthAPI.userService.RegisterUser(reqCtx.Request.Context(), &user.User{
Copy link
Collaborator

Choose a reason for hiding this comment

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

Please use the authService, it will automatically create organization.


accessTokenExp := time.Now().Add(15 * time.Minute)
// Use handleGoogleToken to calculate expiration with buffer
// Instead of hardcoded 15 minutes, the access token now uses the actual expiration time from Google's token response
Copy link
Collaborator

Choose a reason for hiding this comment

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

I agree that we should move accessTokenExp := time.Now().Add(15 * time.Minute) to a single location.
However, we should not use the expiration time from the third-party library because the access token is signed by our service, It should have the same behavior as guest login.

if err := reqCtx.ShouldBindJSON(&request); err != nil {
reqCtx.AbortWithStatusJSON(http.StatusBadRequest, responses.ErrorResponse{
Code: "e5c96a9e-7ff9-4408-9514-9d206ca85b33",
ErrorInstance: err,
Copy link
Collaborator

Choose a reason for hiding this comment

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

ErrorInstance: err, please keep it

reqCtx.AbortWithStatusJSON(http.StatusBadRequest, responses.ErrorResponse{
Code: "0e5b8426-b1d2-4114-ac81-d3982dc497cf",
Code: err.GetCode(),
Error: err.Error(),
Copy link
Collaborator

Choose a reason for hiding this comment

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

ErrorInstance: err.err?

Comment on lines 231 to 235
reqCtx.AbortWithStatusJSON(http.StatusInternalServerError, responses.ErrorResponse{
Code: "8fc529d7-f384-40f2-ac15-cd1f1e109316",
ErrorInstance: err,
})
return
Copy link
Collaborator

Choose a reason for hiding this comment

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

We should return a response in the route, handling errors.


// doGetConversation performs the business logic for getting a conversation
func (api *ConversationAPI) GetConversation(conv *conversation.Conversation) (*ConversationResponse, *common.Error) {
return domainToConversationResponse(conv), nil
Copy link
Collaborator

Choose a reason for hiding this comment

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

Redundant wrap func:

  • If it serves as the converter, why do we have to return *common.Error?
  • return domainToConversationResponse(conv), nil Nil is unnecessary here.
  • Why don't we just use domainToConversationResponse instead

}

// doUpdateConversation performs the business logic for updating a conversation
func (api *ConversationAPI) UpdateConversation(ctx context.Context, conv *conversation.Conversation, request UpdateConversationRequest) (*ConversationResponse, *common.Error) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

  • It seems like it cannot be reused.
  • It did not consolidate the reusable business logic just add an extra layer of complexity.


reqCtx.JSON(http.StatusOK, openai.ListResponse[*ConversationItemResponse]{
Object: openai.ObjectTypeListList,
return &ListResponse[*ConversationItemResponse]{
Copy link
Collaborator

Choose a reason for hiding this comment

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

openai.ListResponse[*ConversationItemResponse] is reuseable

Comment on lines 723 to 730
func (api *ConversationAPI) DeleteItem(ctx context.Context, conv *conversation.Conversation, item *conversation.Item) (*ConversationItemResponse, *common.Error) {
// Use efficient deletion with item public ID instead of loading all items
itemDeleted, err := api.conversationService.DeleteItemWithConversation(ctx, conv, item)
if err != nil {
return nil, err
}
return domainToConversationItemResponse(itemDeleted), nil
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

It's not business logic.
The business logic is in api.conversationService.DeleteItemWithConversation(ctx, conv, item).

Copy link
Collaborator

Choose a reason for hiding this comment

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

Btw, the response doesn't fit the response of openai's..
https://platform.openai.com/docs/api-reference/conversations/delete-item

Comment on lines 670 to 719
reqCtx.JSON(http.StatusOK, domainToConversationItemResponse(itemDeleted))

reqCtx.JSON(http.StatusOK, result)
Copy link
Collaborator

Choose a reason for hiding this comment

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

My proposal:

reqCtx.JSON(http.StatusOK, domainToConversationItemResponse(itemDeleted))
is better than
reqCtx.JSON(http.StatusOK, result)

While I was still pondering whether the delete operation would return an object like:

{
  "id": "item_123",
  "object": "item.deleted",
  "deleted": true
}

I need to check our codebase:
reqCtx.JSON(http.StatusOK, result) -> and go to api.DeleteItem(ctx, conv, item) -> then find domainToConversationItemResponse(itemDeleted)

It would be better if I could see every possible response in just one route function.

@locnguyen1986 locnguyen1986 merged commit 1f57ed1 into main Sep 16, 2025
1 check passed
@locnguyen1986 locnguyen1986 deleted the epic/92/openai-compatible-response-api branch September 16, 2025 15:49
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.

3 participants