Skip to content
Merged
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@ docker run --rm -p 8080:8080 --env-file .env gomodel
| --------------------------- | ------ | ------------------------------------------------------------------------------------------------------------ |
| `/v1/chat/completions` | POST | Chat completions (streaming supported) |
| `/v1/responses` | POST | OpenAI Responses API |
| `/v1/conversations` | POST | Create a conversation (gateway-managed) |
| `/v1/conversations/{id}` | GET | Retrieve a conversation |
| `/v1/conversations/{id}` | POST | Replace conversation metadata in full |
| `/v1/conversations/{id}` | DELETE | Delete a conversation |
| `/v1/embeddings` | POST | Text embeddings |
| `/v1/models` | GET | List available models |
| `/v1/files` | POST | Upload a file (OpenAI-compatible multipart) |
Expand Down
103 changes: 103 additions & 0 deletions docs/advanced/conversations-api.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
---
title: "Conversations API"
description: "Create, retrieve, update, and delete OpenAI-compatible conversations through GoModel."
icon: "messages-square"
tag: "Beta"
---

## Overview

GoModel exposes OpenAI-compatible Conversations API endpoints under
`/v1/conversations`.

Conversations are a **gateway-managed resource**. The OpenAI Conversations API
is provider-specific, so GoModel generates the conversation ID and stores the
conversation itself. This keeps `/v1/conversations` available and consistent
regardless of which provider routes your model traffic, and requires no
provider configuration.

## Supported endpoints

| Endpoint | Behavior |
| --- | --- |
| `POST /v1/conversations` | Creates a conversation. Accepts optional `items` and `metadata`. |
| `GET /v1/conversations/{id}` | Returns a stored conversation. |
| `POST /v1/conversations/{id}` | Replaces the conversation `metadata` in full. |
| `DELETE /v1/conversations/{id}` | Deletes a stored conversation. |

## Conversation object

```json
{
"id": "conv_a1b2c3d4e5f6...",
"object": "conversation",
"created_at": 1741900000,
"metadata": {}
}
```

`metadata` is always present and serializes as `{}` when empty.

## Limits

GoModel enforces the OpenAI Conversations limits so the public contract stays
compatible:

- `items` — at most 20 entries on create.
- `metadata` — at most 16 key-value pairs; keys up to 64 characters; values up
to 512 characters.

The `items` array is accepted and stored with the conversation. It is not yet
exposed through a conversation items listing endpoint.

## Storage and retention

Conversations are held in an in-memory store. They survive across requests but
not process restarts. Retention is bounded: conversations expire after 30 days
and the store keeps at most 10,000 conversations, evicting the oldest first.

## Errors

GoModel returns OpenAI-compatible errors:

- `400 invalid_request_error` — invalid body, missing `metadata` on update, or a
limit exceeded (the `param` field names the offending field).
- `404 not_found_error` — the conversation ID does not exist.

## Examples

Create a conversation:

```bash
curl http://localhost:8080/v1/conversations \
-H "Authorization: Bearer $GOMODEL_MASTER_KEY" \
-H "Content-Type: application/json" \
-d '{
"metadata": { "topic": "support" }
}'
```

Retrieve it:

```bash
curl http://localhost:8080/v1/conversations/conv_abc123 \
-H "Authorization: Bearer $GOMODEL_MASTER_KEY"
```

Update its metadata (replaces all metadata):

```bash
curl http://localhost:8080/v1/conversations/conv_abc123 \
-H "Authorization: Bearer $GOMODEL_MASTER_KEY" \
-H "Content-Type: application/json" \
-d '{
"metadata": { "topic": "billing" }
}'
```

Delete it:

```bash
curl -X DELETE http://localhost:8080/v1/conversations/conv_abc123 \
-H "Authorization: Bearer $GOMODEL_MASTER_KEY"
```
1 change: 1 addition & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"advanced/config-yaml",
"advanced/resilience",
"advanced/responses-api",
"advanced/conversations-api",
"advanced/anthropic-messages-api",
"advanced/guardrails",
"advanced/workflows",
Expand Down
84 changes: 84 additions & 0 deletions internal/conversationstore/store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Package conversationstore provides persistence for the OpenAI-compatible
// Conversations lifecycle endpoints.
package conversationstore

import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"

"gomodel/internal/core"
)

// ErrNotFound indicates a requested conversation was not found.
var ErrNotFound = errors.New("conversation not found")

// StoredConversation keeps the public conversation snapshot separate from
// gateway-only metadata (initial items, owning user path, request id).
type StoredConversation struct {
Conversation *core.Conversation `json:"conversation"`
Items []json.RawMessage `json:"items,omitempty"`
UserPath string `json:"user_path,omitempty"`
RequestID string `json:"request_id,omitempty"`
StoredAt time.Time `json:"stored_at,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
}

// Store defines persistence operations for the Conversations lifecycle API.
type Store interface {
Create(ctx context.Context, conversation *StoredConversation) error
Get(ctx context.Context, id string) (*StoredConversation, error)
Update(ctx context.Context, conversation *StoredConversation) error
Delete(ctx context.Context, id string) error
Close() error
}

func cloneConversation(src *StoredConversation) (*StoredConversation, error) {
if src == nil {
return nil, fmt.Errorf("conversation is nil")
}
normalized := normalizeStoredConversation(src)
b, err := json.Marshal(normalized)
if err != nil {
return nil, fmt.Errorf("marshal conversation: %w", err)
}
var dst StoredConversation
if err := json.Unmarshal(b, &dst); err != nil {
return nil, fmt.Errorf("unmarshal conversation: %w", err)
}
return &dst, nil
}

func normalizeStoredConversation(src *StoredConversation) *StoredConversation {
if src == nil {
return nil
}

normalized := *src
normalized.UserPath = strings.TrimSpace(normalized.UserPath)
normalized.RequestID = strings.TrimSpace(normalized.RequestID)

if src.Conversation != nil {
conversationCopy := *src.Conversation
if conversationCopy.Metadata != nil {
metadataCopy := make(map[string]string, len(conversationCopy.Metadata))
for key, value := range conversationCopy.Metadata {
metadataCopy[key] = value
}
conversationCopy.Metadata = metadataCopy
}
normalized.Conversation = &conversationCopy
}

if len(src.Items) > 0 {
normalized.Items = make([]json.RawMessage, 0, len(src.Items))
for _, item := range src.Items {
normalized.Items = append(normalized.Items, core.CloneRawJSON(item))
}
}

return &normalized
}
Loading