collavre_completion_api 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +41 -0
- data/Rakefile +2 -0
- data/app/controllers/collavre_completion_api/api/v1/base_controller.rb +74 -0
- data/app/controllers/collavre_completion_api/api/v1/chat/completions_controller.rb +214 -0
- data/app/controllers/collavre_completion_api/api/v1/models_controller.rb +29 -0
- data/config/routes.rb +10 -0
- data/lib/collavre_completion_api/engine.rb +18 -0
- data/lib/collavre_completion_api/version.rb +5 -0
- data/lib/collavre_completion_api.rb +7 -0
- metadata +94 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d657f80e16c1705fa79850aa8445aa53d90112de021c9640221f71b15d73f71e
|
|
4
|
+
data.tar.gz: 4cef77f5d3433dcb90d9c1a1c82c97a8b9c0d56ec727f61e73fab68a3fa68de1
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4a4d8cbd25e4f17e953085529b075b590e4dd390c1eb2d24cce0bd5a09d4c72ba25434a70b4a85e022a2e2ac8792a2c36adca9efdf657f6ec9565674836ef2a4
|
|
7
|
+
data.tar.gz: adc77be5cc324e467f24438d3677777259a4474fd44a151c4f7ea7642fe3b65cfaec60dbe41069104802b843ccfa521b1d48625a3c926f770ff0206f4bf2fabb
|
data/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# CollavreCompletionApi
|
|
2
|
+
|
|
3
|
+
OpenAI-compatible chat completions API for Collavre.
|
|
4
|
+
|
|
5
|
+
## Endpoints
|
|
6
|
+
|
|
7
|
+
- `POST /api/v1/chat/completions` — Chat completion (streaming + non-streaming)
|
|
8
|
+
- `GET /api/v1/models` — List accessible AI agents as models
|
|
9
|
+
|
|
10
|
+
## Authentication
|
|
11
|
+
|
|
12
|
+
Uses Doorkeeper OAuth Bearer tokens. Pass your OAuth access token as:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
Authorization: Bearer <oauth_token>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Compatible with OpenAI SDK:
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from openai import OpenAI
|
|
22
|
+
client = OpenAI(
|
|
23
|
+
base_url="https://your-collavre.com/api/v1",
|
|
24
|
+
api_key="<oauth_access_token>"
|
|
25
|
+
)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Context Injection
|
|
29
|
+
|
|
30
|
+
Pass optional headers to inject Collavre context into AI prompts:
|
|
31
|
+
|
|
32
|
+
- `X-Collavre-Creative: <creative_id>` — Include creative context
|
|
33
|
+
- `X-Collavre-Topic: <topic_id>` — Filter context to specific topic
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
Add to your host app's `Gemfile`:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
gem "collavre_completion_api", path: "engines/collavre_completion_api"
|
|
41
|
+
```
|
data/Rakefile
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CollavreCompletionApi
|
|
4
|
+
module Api
|
|
5
|
+
module V1
|
|
6
|
+
class BaseController < ActionController::API
|
|
7
|
+
before_action :authenticate!
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def authenticate!
|
|
12
|
+
token = extract_bearer_token
|
|
13
|
+
if token.blank?
|
|
14
|
+
render json: { error: { message: "Missing authentication token", type: "invalid_request_error",
|
|
15
|
+
code: "missing_token" } },
|
|
16
|
+
status: :unauthorized
|
|
17
|
+
return
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
unless authenticate_oauth(token)
|
|
21
|
+
render json: { error: { message: "Invalid authentication token", type: "invalid_request_error",
|
|
22
|
+
code: "invalid_token" } },
|
|
23
|
+
status: :unauthorized
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def authenticate_oauth(token)
|
|
29
|
+
access_token = Doorkeeper::AccessToken.by_token(token)
|
|
30
|
+
return false unless access_token&.accessible?
|
|
31
|
+
|
|
32
|
+
user = Collavre::User.find_by(id: access_token.resource_owner_id)
|
|
33
|
+
return false unless user
|
|
34
|
+
|
|
35
|
+
Collavre::Current.user = user
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def extract_bearer_token
|
|
40
|
+
auth_header = request.headers["Authorization"]
|
|
41
|
+
return nil unless auth_header&.start_with?("Bearer ")
|
|
42
|
+
|
|
43
|
+
auth_header.sub("Bearer ", "")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def current_user
|
|
47
|
+
Collavre::Current.user
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def collavre_creative
|
|
51
|
+
@collavre_creative ||= begin
|
|
52
|
+
creative_id = request.headers["X-Collavre-Creative"]
|
|
53
|
+
return nil if creative_id.blank?
|
|
54
|
+
|
|
55
|
+
creative = Collavre::Creative.find_by(id: creative_id)&.effective_origin
|
|
56
|
+
return nil unless creative&.has_permission?(current_user, :member)
|
|
57
|
+
|
|
58
|
+
creative
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def collavre_topic
|
|
63
|
+
@collavre_topic ||= begin
|
|
64
|
+
topic_id = request.headers["X-Collavre-Topic"]
|
|
65
|
+
return nil if topic_id.blank?
|
|
66
|
+
return nil unless collavre_creative
|
|
67
|
+
|
|
68
|
+
collavre_creative.topics.find_by(id: topic_id)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CollavreCompletionApi
|
|
4
|
+
module Api
|
|
5
|
+
module V1
|
|
6
|
+
module Chat
|
|
7
|
+
class CompletionsController < BaseController
|
|
8
|
+
CREATIVE_CONTEXT_COMMENT_LIMIT = 20
|
|
9
|
+
|
|
10
|
+
def create
|
|
11
|
+
messages = params[:messages] || []
|
|
12
|
+
stream = params[:stream] == true || params[:stream] == "true"
|
|
13
|
+
|
|
14
|
+
if messages.empty?
|
|
15
|
+
render json: { error: { message: "messages is required", type: "invalid_request_error" } },
|
|
16
|
+
status: :bad_request
|
|
17
|
+
return
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
agent = resolve_agent
|
|
21
|
+
unless agent
|
|
22
|
+
render json: { error: { message: "Invalid model", type: "invalid_request_error" } },
|
|
23
|
+
status: :bad_request
|
|
24
|
+
return
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
system_prompt = build_system_prompt(messages, agent)
|
|
28
|
+
contents = build_contents(messages)
|
|
29
|
+
|
|
30
|
+
# Use agent owner's llm_api_key for external LLM calls
|
|
31
|
+
api_key = agent.respond_to?(:llm_api_key) ? agent.llm_api_key : nil
|
|
32
|
+
api_key ||= agent.respond_to?(:creator) ? agent.creator&.llm_api_key : nil
|
|
33
|
+
|
|
34
|
+
client = Collavre::AiClient.new(
|
|
35
|
+
vendor: agent.llm_vendor,
|
|
36
|
+
model: agent.llm_model,
|
|
37
|
+
system_prompt: system_prompt,
|
|
38
|
+
llm_api_key: api_key,
|
|
39
|
+
context: { creative: collavre_creative, user: current_user }
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
model_name = params[:model] || "collavre/#{agent.id}"
|
|
43
|
+
|
|
44
|
+
if stream
|
|
45
|
+
stream_response(client, contents, model_name)
|
|
46
|
+
else
|
|
47
|
+
non_stream_response(client, contents, model_name)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def resolve_agent
|
|
54
|
+
model_param = params[:model].to_s
|
|
55
|
+
|
|
56
|
+
return nil unless model_param.start_with?("collavre/")
|
|
57
|
+
|
|
58
|
+
ai_id = model_param.sub("collavre/", "")
|
|
59
|
+
agent = Collavre::User.find_by(id: ai_id)
|
|
60
|
+
return nil unless agent&.ai_user?
|
|
61
|
+
return nil unless agent_accessible?(agent)
|
|
62
|
+
|
|
63
|
+
agent
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def agent_accessible?(agent)
|
|
67
|
+
agent.created_by_id == current_user.id || agent.searchable?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def build_system_prompt(messages, agent)
|
|
71
|
+
parts = []
|
|
72
|
+
|
|
73
|
+
parts << agent.system_prompt if agent.system_prompt.present?
|
|
74
|
+
|
|
75
|
+
system_messages = messages.select { |m| m[:role] == "system" || m["role"] == "system" }
|
|
76
|
+
system_messages.each do |msg|
|
|
77
|
+
parts << (msg[:content] || msg["content"])
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
parts << build_creative_context if collavre_creative
|
|
81
|
+
|
|
82
|
+
parts.compact.join("\n\n").presence || Collavre::AiClient::SYSTEM_INSTRUCTIONS
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def build_creative_context
|
|
86
|
+
context_parts = []
|
|
87
|
+
context_parts << "## Collavre Context"
|
|
88
|
+
context_parts << "Creative: #{collavre_creative.title}" if collavre_creative.title.present?
|
|
89
|
+
|
|
90
|
+
if collavre_topic
|
|
91
|
+
context_parts << "Topic: #{collavre_topic.title}" if collavre_topic.title.present?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
scope = collavre_creative.comments
|
|
95
|
+
.where(private: false)
|
|
96
|
+
.order(created_at: :desc)
|
|
97
|
+
scope = scope.where(topic_id: collavre_topic.id) if collavre_topic
|
|
98
|
+
recent_comments = scope.limit(CREATIVE_CONTEXT_COMMENT_LIMIT)
|
|
99
|
+
.includes(:user).to_a.reverse
|
|
100
|
+
|
|
101
|
+
if recent_comments.any?
|
|
102
|
+
context_parts << "\n### Recent Discussion"
|
|
103
|
+
recent_comments.each do |comment|
|
|
104
|
+
author = comment.user&.name || "Unknown"
|
|
105
|
+
context_parts << "- **#{author}**: #{comment.content.to_s.truncate(500)}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
context_parts.join("\n")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def build_contents(messages)
|
|
113
|
+
messages.reject { |m|
|
|
114
|
+
role = m[:role] || m["role"]
|
|
115
|
+
role == "system"
|
|
116
|
+
}.map { |m|
|
|
117
|
+
role = m[:role] || m["role"]
|
|
118
|
+
content = m[:content] || m["content"]
|
|
119
|
+
{ role: role, text: content }
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def non_stream_response(client, contents, model)
|
|
124
|
+
result = client.chat(contents)
|
|
125
|
+
completion_id = "chatcmpl-#{SecureRandom.hex(12)}"
|
|
126
|
+
|
|
127
|
+
prompt_tokens = client.last_input_tokens
|
|
128
|
+
completion_tokens = client.last_output_tokens
|
|
129
|
+
|
|
130
|
+
render json: {
|
|
131
|
+
id: completion_id,
|
|
132
|
+
object: "chat.completion",
|
|
133
|
+
created: Time.current.to_i,
|
|
134
|
+
model: model,
|
|
135
|
+
choices: [
|
|
136
|
+
{
|
|
137
|
+
index: 0,
|
|
138
|
+
message: { role: "assistant", content: result || "" },
|
|
139
|
+
finish_reason: "stop"
|
|
140
|
+
}
|
|
141
|
+
],
|
|
142
|
+
usage: {
|
|
143
|
+
prompt_tokens: prompt_tokens,
|
|
144
|
+
completion_tokens: completion_tokens,
|
|
145
|
+
total_tokens: prompt_tokens + completion_tokens
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def stream_response(client, contents, model)
|
|
151
|
+
completion_id = "chatcmpl-#{SecureRandom.hex(12)}"
|
|
152
|
+
|
|
153
|
+
response.headers["Content-Type"] = "text/event-stream"
|
|
154
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
155
|
+
response.headers["Connection"] = "keep-alive"
|
|
156
|
+
|
|
157
|
+
self.response_body = Enumerator.new do |yielder|
|
|
158
|
+
begin
|
|
159
|
+
client.chat(contents) do |chunk|
|
|
160
|
+
data = {
|
|
161
|
+
id: completion_id,
|
|
162
|
+
object: "chat.completion.chunk",
|
|
163
|
+
created: Time.current.to_i,
|
|
164
|
+
model: model,
|
|
165
|
+
choices: [
|
|
166
|
+
{
|
|
167
|
+
index: 0,
|
|
168
|
+
delta: { content: chunk },
|
|
169
|
+
finish_reason: nil
|
|
170
|
+
}
|
|
171
|
+
]
|
|
172
|
+
}
|
|
173
|
+
yielder << "data: #{data.to_json}\n\n"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
final = {
|
|
177
|
+
id: completion_id,
|
|
178
|
+
object: "chat.completion.chunk",
|
|
179
|
+
created: Time.current.to_i,
|
|
180
|
+
model: model,
|
|
181
|
+
choices: [
|
|
182
|
+
{
|
|
183
|
+
index: 0,
|
|
184
|
+
delta: {},
|
|
185
|
+
finish_reason: "stop"
|
|
186
|
+
}
|
|
187
|
+
]
|
|
188
|
+
}
|
|
189
|
+
yielder << "data: #{final.to_json}\n\n"
|
|
190
|
+
rescue StandardError => e
|
|
191
|
+
error_data = {
|
|
192
|
+
id: completion_id,
|
|
193
|
+
object: "chat.completion.chunk",
|
|
194
|
+
created: Time.current.to_i,
|
|
195
|
+
model: model,
|
|
196
|
+
choices: [
|
|
197
|
+
{
|
|
198
|
+
index: 0,
|
|
199
|
+
delta: { content: "\n\n⚠️ Error: #{e.message}" },
|
|
200
|
+
finish_reason: "stop"
|
|
201
|
+
}
|
|
202
|
+
]
|
|
203
|
+
}
|
|
204
|
+
yielder << "data: #{error_data.to_json}\n\n"
|
|
205
|
+
ensure
|
|
206
|
+
yielder << "data: [DONE]\n\n"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CollavreCompletionApi
|
|
4
|
+
module Api
|
|
5
|
+
module V1
|
|
6
|
+
class ModelsController < BaseController
|
|
7
|
+
def index
|
|
8
|
+
ai_agents = Collavre::User.accessible_ai_agents_for(current_user)
|
|
9
|
+
|
|
10
|
+
models = ai_agents.map do |agent|
|
|
11
|
+
{
|
|
12
|
+
id: "collavre/#{agent.id}",
|
|
13
|
+
object: "model",
|
|
14
|
+
created: agent.created_at.to_i,
|
|
15
|
+
owned_by: "collavre",
|
|
16
|
+
meta: {
|
|
17
|
+
name: agent.name,
|
|
18
|
+
llm_vendor: agent.llm_vendor,
|
|
19
|
+
llm_model: agent.llm_model
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
render json: { object: "list", data: models }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CollavreCompletionApi
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace CollavreCompletionApi
|
|
6
|
+
|
|
7
|
+
config.generators do |g|
|
|
8
|
+
g.test_framework :minitest
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Auto-mount engine routes under the Collavre engine
|
|
12
|
+
initializer "collavre_completion_api.routes", before: :add_routing_paths do |app|
|
|
13
|
+
app.routes.append do
|
|
14
|
+
mount CollavreCompletionApi::Engine => "/", as: :collavre_completion_api_engine
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: collavre_completion_api
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Collavre
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '8.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '8.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: collavre
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: doorkeeper
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
description: Plugin engine providing OpenAI-compatible /v1/chat/completions and /v1/models
|
|
55
|
+
endpoints using Collavre AI agents with context injection.
|
|
56
|
+
email:
|
|
57
|
+
- support@collavre.com
|
|
58
|
+
executables: []
|
|
59
|
+
extensions: []
|
|
60
|
+
extra_rdoc_files: []
|
|
61
|
+
files:
|
|
62
|
+
- README.md
|
|
63
|
+
- Rakefile
|
|
64
|
+
- app/controllers/collavre_completion_api/api/v1/base_controller.rb
|
|
65
|
+
- app/controllers/collavre_completion_api/api/v1/chat/completions_controller.rb
|
|
66
|
+
- app/controllers/collavre_completion_api/api/v1/models_controller.rb
|
|
67
|
+
- config/routes.rb
|
|
68
|
+
- lib/collavre_completion_api.rb
|
|
69
|
+
- lib/collavre_completion_api/engine.rb
|
|
70
|
+
- lib/collavre_completion_api/version.rb
|
|
71
|
+
homepage: https://collavre.com
|
|
72
|
+
licenses:
|
|
73
|
+
- AGPL
|
|
74
|
+
metadata:
|
|
75
|
+
homepage_uri: https://collavre.com
|
|
76
|
+
source_code_uri: https://github.com/sh1nj1/plan42
|
|
77
|
+
rdoc_options: []
|
|
78
|
+
require_paths:
|
|
79
|
+
- lib
|
|
80
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
81
|
+
requirements:
|
|
82
|
+
- - ">="
|
|
83
|
+
- !ruby/object:Gem::Version
|
|
84
|
+
version: '0'
|
|
85
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - ">="
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '0'
|
|
90
|
+
requirements: []
|
|
91
|
+
rubygems_version: 3.6.7
|
|
92
|
+
specification_version: 4
|
|
93
|
+
summary: OpenAI-compatible chat completions API for Collavre
|
|
94
|
+
test_files: []
|