rails-ai-gateway 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +1 -1
- data/app/assets/images/rails_ai_gateway/favicon.webp +0 -0
- data/app/assets/images/rails_ai_gateway/logo.webp +0 -0
- data/app/assets/stylesheets/rails_ai_gateway/admin.css +166 -37
- data/app/controllers/rails_ai_gateway/admin_controller.rb +8 -0
- data/app/models/rails_ai_gateway/provider.rb +5 -0
- data/app/views/layouts/rails_ai_gateway/admin.html.erb +18 -9
- data/app/views/rails_ai_gateway/admin/errors.html.erb +6 -3
- data/app/views/rails_ai_gateway/admin/index.html.erb +33 -23
- data/app/views/rails_ai_gateway/admin/key.html.erb +10 -5
- data/config/routes.rb +2 -0
- data/lib/generators/rails_ai_gateway/install_generator.rb +1 -1
- data/lib/rails-ai-gateway.rb +1 -0
- data/lib/rails_ai_gateway/proxy.rb +28 -2
- data/lib/rails_ai_gateway/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: af35d6d1d275d95fdc5823eded924c98cd4e39e18e1f67388f1fd0f7e9f056f3
|
|
4
|
+
data.tar.gz: bc9d7f02956947396a2801b27199328aba3b38b1d9917e2e00d9a70a1fc46b11
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 352f0608dbf281db2d14dcb452913cae5c758b4347d41a87bc38dc1fbc564792890a6b7f09edcab39741db71accdebb8ddeffb881a67c0b9900c358412da574f
|
|
7
|
+
data.tar.gz: 28780f6a22d71ac9c77ddc1a1f236fbbcf2e9fd12f8ed5412c495b7977ead1096c94a797e42642a1f1c8f87459077d5d8d8d35559645bb3f8d5652c7e85bcdd1
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 - 2026-09-20
|
|
4
|
+
|
|
5
|
+
- Redesign admin UI as a responsive AI gateway operations console.
|
|
6
|
+
- Add generated WebP brand mark and favicon served under any engine mount path.
|
|
7
|
+
- Improve dashboard metrics, provider and route summaries, key status, and request-log scanning.
|
|
8
|
+
- Add mobile card tables, stronger keyboard focus, and reduced-motion support.
|
|
9
|
+
|
|
10
|
+
## 0.1.1 - 2026-09-20
|
|
11
|
+
|
|
12
|
+
- Fix automatic Bundler loading for hyphenated gem name.
|
|
13
|
+
- Fix install generator migration task for host Rails applications.
|
|
14
|
+
- Normalize provider API roots to prevent duplicated endpoint paths.
|
|
15
|
+
- Normalize upstream errors, preserve safe `Retry-After`, and terminate failed SSE streams.
|
|
16
|
+
- Validate successful embedding response shape.
|
|
17
|
+
- Improve installation, security, development, and contribution documentation.
|
|
18
|
+
|
|
3
19
|
## 0.1.0 - 2026-09-20
|
|
4
20
|
|
|
5
21
|
- Add mountable Rails engine with OpenAI-compatible chat, embedding, and model endpoints.
|
data/README.md
CHANGED
|
@@ -51,7 +51,7 @@ Provider API keys cannot be saved without it.
|
|
|
51
51
|
Web UI guides initial setup:
|
|
52
52
|
|
|
53
53
|
1. Add provider with full API base URL, such as `https://api.openai.com/v1`.
|
|
54
|
-
2. Add public model route, such as `fast-chat` mapped to `gpt-
|
|
54
|
+
2. Add public model route, such as `fast-chat` mapped to `gpt-5.6-luna`.
|
|
55
55
|
3. Create gateway key and save token when shown. Raw token cannot be displayed again.
|
|
56
56
|
|
|
57
57
|
Routes with same public model name form fallback chain. Lower priority runs first. Gateway
|
|
Binary file
|
|
Binary file
|
|
@@ -1,41 +1,170 @@
|
|
|
1
|
-
:root {
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: dark;
|
|
3
|
+
--ink: #07111f;
|
|
4
|
+
--panel: #0d1929;
|
|
5
|
+
--panel-raised: #122238;
|
|
6
|
+
--line: #263850;
|
|
7
|
+
--text: #f3f7f5;
|
|
8
|
+
--muted: #98a9b9;
|
|
9
|
+
--acid: #a8ff2a;
|
|
10
|
+
--cyan: #24d6ee;
|
|
11
|
+
--danger: #ff6b78;
|
|
12
|
+
font-family: "Avenir Next", "Segoe UI", ui-sans-serif, system-ui, sans-serif;
|
|
13
|
+
color: var(--text);
|
|
14
|
+
background: var(--ink);
|
|
15
|
+
line-height: 1.5;
|
|
16
|
+
scroll-behavior: smooth;
|
|
17
|
+
}
|
|
18
|
+
|
|
2
19
|
* { box-sizing: border-box; }
|
|
3
|
-
body { margin: 0; }
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
.
|
|
18
|
-
.
|
|
19
|
-
|
|
20
|
+
body { margin: 0; min-width: 320px; background: var(--ink); }
|
|
21
|
+
a { color: var(--cyan); text-underline-offset: .18em; }
|
|
22
|
+
code, pre, textarea { font-family: "SFMono-Regular", Consolas, ui-monospace, monospace; overflow-wrap: anywhere; }
|
|
23
|
+
|
|
24
|
+
.site-header { position: sticky; top: 0; z-index: 10; border-bottom: 1px solid var(--line); background: rgba(7, 17, 31, .94); backdrop-filter: blur(14px); }
|
|
25
|
+
.header-inner { max-width: 1320px; min-height: 76px; margin: auto; padding: .7rem 1.5rem; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: clamp(1.5rem, 4vw, 4rem); }
|
|
26
|
+
.brand { display: inline-flex; align-items: center; gap: .7rem; color: var(--text); text-decoration: none; }
|
|
27
|
+
.brand img { width: 44px; height: 44px; border-radius: 11px; border: 1px solid #32445a; }
|
|
28
|
+
.brand span, .brand strong, .brand small { display: block; }
|
|
29
|
+
.brand strong { font-size: .98rem; letter-spacing: -.01em; }
|
|
30
|
+
.brand small { margin-top: .05rem; color: var(--muted); font: 500 .66rem "Fira Code", monospace; text-transform: uppercase; letter-spacing: .09em; }
|
|
31
|
+
nav { display: flex; align-items: center; gap: .25rem; }
|
|
32
|
+
nav a { color: var(--muted); padding: .5rem .7rem; border-radius: 5px; font-size: .85rem; font-weight: 600; text-decoration: none; transition: color .18s ease, background .18s ease; }
|
|
33
|
+
nav a:hover { color: var(--text); background: var(--panel-raised); }
|
|
34
|
+
.system-state { justify-self: end; display: inline-flex; align-items: center; gap: .45rem; color: #c6d0da; font: 500 .7rem "Fira Code", monospace; text-transform: uppercase; letter-spacing: .06em; white-space: nowrap; }
|
|
35
|
+
.system-state i, .badge i { width: 7px; height: 7px; border-radius: 50%; background: var(--acid); box-shadow: 0 0 10px rgba(168, 255, 42, .7); }
|
|
36
|
+
|
|
37
|
+
main, footer { max-width: 1320px; margin: auto; padding: 0 1.5rem; }
|
|
38
|
+
main { padding-top: clamp(3rem, 7vw, 6rem); padding-bottom: 4rem; }
|
|
39
|
+
footer { padding-top: 1.5rem; padding-bottom: 2.5rem; border-top: 1px solid var(--line); display: flex; justify-content: space-between; gap: 1rem; color: var(--muted); font: 400 .72rem "Fira Code", monospace; }
|
|
40
|
+
|
|
41
|
+
.hero { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(280px, .6fr); align-items: end; gap: clamp(2rem, 8vw, 7rem); padding-bottom: clamp(3rem, 7vw, 5rem); }
|
|
42
|
+
.eyebrow, .section-index { margin: 0 0 .8rem; color: var(--acid); font: 600 .72rem "Fira Code", monospace; letter-spacing: .14em; text-transform: uppercase; }
|
|
43
|
+
h1 { margin: 0; max-width: 820px; font-size: clamp(3.2rem, 8vw, 7.3rem); line-height: .88; letter-spacing: -.065em; }
|
|
44
|
+
h1 em { color: var(--acid); font-style: normal; }
|
|
45
|
+
.hero-copy { max-width: 650px; margin: 1.5rem 0 0; color: #bac6d1; font-size: clamp(1rem, 1.8vw, 1.25rem); }
|
|
46
|
+
.endpoint-card { min-width: 0; padding: 1.1rem; border: 1px solid var(--line); border-left: 3px solid var(--cyan); background: #091522; }
|
|
47
|
+
.endpoint-card span, .endpoint-card small { display: block; color: var(--muted); font-size: .73rem; }
|
|
48
|
+
.endpoint-card code { display: block; margin: .65rem 0; overflow: hidden; color: var(--text); font-size: .82rem; text-overflow: ellipsis; white-space: nowrap; }
|
|
49
|
+
|
|
50
|
+
.stats { display: grid; grid-template-columns: repeat(4, 1fr); border: 1px solid var(--line); background: var(--line); gap: 1px; }
|
|
51
|
+
.stats div { min-width: 0; padding: 1.25rem; background: var(--panel); }
|
|
52
|
+
.stats span, .stats strong, .stats small { display: block; }
|
|
53
|
+
.stats span { color: var(--muted); font: 500 .7rem "Fira Code", monospace; text-transform: uppercase; letter-spacing: .08em; }
|
|
54
|
+
.stats strong { margin: .5rem 0 .2rem; font-size: clamp(2rem, 4vw, 3.2rem); line-height: 1; letter-spacing: -.05em; }
|
|
55
|
+
.stats strong i { color: var(--muted); font: 500 .8rem "Fira Code", monospace; letter-spacing: 0; }
|
|
56
|
+
.stats small { color: #71869a; font-size: .75rem; }
|
|
57
|
+
|
|
58
|
+
section { margin-top: clamp(4rem, 8vw, 7rem); scroll-margin-top: 100px; }
|
|
59
|
+
.section-heading { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(280px, .8fr); align-items: end; gap: 2rem; margin-bottom: 1.5rem; padding-bottom: 1.25rem; border-bottom: 1px solid var(--line); }
|
|
60
|
+
.section-heading h2 { margin: 0; font-size: clamp(2rem, 4vw, 3.5rem); line-height: .95; letter-spacing: -.045em; }
|
|
61
|
+
.section-heading p:last-child { max-width: 520px; margin: 0; color: var(--muted); }
|
|
62
|
+
|
|
63
|
+
details { border-bottom: 1px solid var(--line); background: transparent; }
|
|
64
|
+
details:first-of-type { border-top: 1px solid var(--line); }
|
|
65
|
+
summary { min-height: 76px; display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem .4rem; cursor: pointer; font-weight: 600; list-style: none; transition: color .18s ease, background .18s ease; }
|
|
66
|
+
summary::-webkit-details-marker { display: none; }
|
|
67
|
+
summary:hover { color: var(--acid); background: rgba(168, 255, 42, .025); }
|
|
68
|
+
summary::after { content: "+"; flex: 0 0 auto; color: var(--muted); font: 400 1.4rem "Fira Code", monospace; }
|
|
69
|
+
details[open] > summary::after { content: "−"; }
|
|
70
|
+
summary > span:first-child { min-width: 0; overflow-wrap: anywhere; }
|
|
71
|
+
summary small { display: block; margin-top: .2rem; color: var(--muted); font: 400 .72rem "Fira Code", monospace; }
|
|
72
|
+
.badge, .route-meta { display: inline-flex; align-items: center; gap: .45rem; margin-left: auto; color: #d7e0e8; font: 500 .7rem "Fira Code", monospace; white-space: nowrap; }
|
|
73
|
+
.badge.inactive i { background: #64768a; box-shadow: none; }
|
|
74
|
+
.route-meta b { padding: .2rem .4rem; border: 1px solid var(--line); color: var(--acid); font-weight: 500; }
|
|
75
|
+
.create-panel { margin-top: 1rem; border: 1px dashed #3a5068 !important; }
|
|
76
|
+
.create-panel > summary { padding-left: 1rem; padding-right: 1rem; color: var(--cyan); }
|
|
77
|
+
.create-panel > summary span b { display: inline-grid; place-items: center; width: 22px; height: 22px; margin-right: .45rem; border: 1px solid currentColor; border-radius: 50%; font: 400 1rem "Fira Code", monospace; }
|
|
78
|
+
|
|
79
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 1rem; align-items: end; margin: 0; padding: 1rem .4rem 1.5rem; }
|
|
80
|
+
label { display: block; margin-bottom: .4rem; color: #cbd5df; font: 500 .72rem "Fira Code", monospace; }
|
|
20
81
|
input, select, textarea, button { font: inherit; }
|
|
21
|
-
input:not([type=checkbox]), select, textarea { width: 100%; padding: .
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
input
|
|
25
|
-
.
|
|
82
|
+
input:not([type=checkbox]), select, textarea { width: 100%; min-height: 44px; padding: .7rem .75rem; border: 1px solid #38506a; border-radius: 4px; outline: none; background: #091522; color: var(--text); transition: border .18s ease, box-shadow .18s ease; }
|
|
83
|
+
input:not([type=checkbox]):hover, select:hover, textarea:hover { border-color: #58708a; }
|
|
84
|
+
input:not([type=checkbox]):focus, select:focus, textarea:focus { border-color: var(--cyan); box-shadow: 0 0 0 3px rgba(36, 214, 238, .12); }
|
|
85
|
+
input::placeholder, textarea::placeholder { color: #65798d; }
|
|
86
|
+
button, input[type=submit], .back-link { min-height: 42px; padding: .65rem 1rem; border: 1px solid var(--acid); border-radius: 4px; cursor: pointer; background: var(--acid); color: #0a1410; font-weight: 700; text-decoration: none; transition: background .18s ease, border-color .18s ease, color .18s ease; }
|
|
87
|
+
button:hover, input[type=submit]:hover, .back-link:hover { border-color: #c0ff66; background: #c0ff66; }
|
|
88
|
+
button.danger { min-height: 34px; padding: .4rem .65rem; border-color: #68404c; background: transparent; color: #ff8c96; font-size: .75rem; }
|
|
89
|
+
button.danger:hover { border-color: var(--danger); background: rgba(255, 107, 120, .08); }
|
|
90
|
+
input:disabled { opacity: .45; cursor: not-allowed; }
|
|
91
|
+
.check { min-height: 44px; display: flex; align-items: center; gap: .55rem; }
|
|
26
92
|
.check label { margin: 0; }
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
.
|
|
30
|
-
.table-wrap { overflow-x: auto; }
|
|
31
|
-
table { width: 100%; border-collapse: collapse; font-size: .
|
|
32
|
-
caption { text-align: left; color:
|
|
33
|
-
th, td {
|
|
34
|
-
th {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
93
|
+
input[type=checkbox] { width: 17px; height: 17px; accent-color: var(--acid); }
|
|
94
|
+
|
|
95
|
+
.filter-bar { margin-bottom: 1rem; padding: 1rem; border: 1px solid var(--line); background: var(--panel); }
|
|
96
|
+
.table-wrap { width: 100%; overflow-x: auto; border: 1px solid var(--line); }
|
|
97
|
+
table { width: 100%; border-collapse: collapse; font-size: .83rem; }
|
|
98
|
+
caption { padding: .75rem 1rem; text-align: left; color: var(--muted); font: 400 .7rem "Fira Code", monospace; background: #091522; }
|
|
99
|
+
th, td { padding: .9rem 1rem; border-bottom: 1px solid var(--line); text-align: left; vertical-align: middle; }
|
|
100
|
+
th { color: #8ea1b3; background: var(--panel); font: 500 .65rem "Fira Code", monospace; letter-spacing: .08em; text-transform: uppercase; white-space: nowrap; }
|
|
101
|
+
tbody tr { background: #0a1624; transition: background .15s ease; }
|
|
102
|
+
tbody tr:hover { background: #101f31; }
|
|
103
|
+
tbody tr:last-child td { border-bottom: 0; }
|
|
104
|
+
td code { padding: .2rem .35rem; color: var(--cyan); background: #06101b; }
|
|
105
|
+
.status { display: inline-flex; align-items: center; min-width: 56px; padding: .22rem .45rem; border: 1px solid #365744; color: #b8f5ca; background: rgba(69, 202, 106, .07); font: 500 .66rem "Fira Code", monospace; }
|
|
106
|
+
.status-revoked, .status-expired, .status-error { border-color: #66404a; color: #ff9ba4; background: rgba(255, 107, 120, .07); }
|
|
107
|
+
.status-pending { border-color: #59606a; color: #bdc5cc; }
|
|
108
|
+
.timestamp { white-space: nowrap; }
|
|
109
|
+
.timestamp small { display: block; color: var(--muted); font: 400 .68rem "Fira Code", monospace; }
|
|
110
|
+
.log-detail { border: 0; }
|
|
111
|
+
.log-detail summary { min-height: 0; padding: 0; color: var(--cyan); font-size: .78rem; }
|
|
112
|
+
.log-detail summary::after { font-size: 1rem; }
|
|
113
|
+
.log-detail code, .log-detail pre { display: block; max-width: 30rem; margin-top: .7rem; white-space: pre-wrap; }
|
|
114
|
+
.muted { color: var(--muted); }
|
|
115
|
+
|
|
116
|
+
.focus-page { max-width: 820px; min-height: 65vh; display: flex; flex-direction: column; justify-content: center; }
|
|
117
|
+
.focus-page h1 { font-size: clamp(3rem, 8vw, 6rem); }
|
|
118
|
+
.token-card { margin: 2rem 0; padding: 1.25rem; border: 1px solid var(--acid); background: rgba(168, 255, 42, .04); }
|
|
119
|
+
.token-card textarea { resize: vertical; color: var(--acid); }
|
|
120
|
+
.back-link { align-self: flex-start; }
|
|
121
|
+
.error-label { color: var(--danger); }
|
|
122
|
+
.error-list { margin: 2rem 0; padding: 1.25rem 1.25rem 1.25rem 2.5rem; border-left: 3px solid var(--danger); background: rgba(255, 107, 120, .06); }
|
|
123
|
+
|
|
124
|
+
:focus-visible { outline: 3px solid #ffd166; outline-offset: 3px; }
|
|
39
125
|
.skip { position: absolute; left: -10000px; }
|
|
40
|
-
.skip:focus { left: 1rem; top: .
|
|
41
|
-
|
|
126
|
+
.skip:focus { left: 1rem; top: .75rem; z-index: 20; padding: .5rem .75rem; background: var(--text); color: var(--ink); }
|
|
127
|
+
|
|
128
|
+
@media (max-width: 950px) {
|
|
129
|
+
.header-inner { grid-template-columns: auto 1fr; }
|
|
130
|
+
nav { justify-content: flex-end; }
|
|
131
|
+
.system-state { display: none; }
|
|
132
|
+
.hero { grid-template-columns: 1fr; }
|
|
133
|
+
.endpoint-card { max-width: 560px; }
|
|
134
|
+
.stats { grid-template-columns: repeat(2, 1fr); }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
@media (max-width: 680px) {
|
|
138
|
+
.header-inner { display: flex; flex-wrap: wrap; padding: .65rem 1rem; }
|
|
139
|
+
.brand { flex: 1; }
|
|
140
|
+
nav { width: 100%; overflow-x: auto; justify-content: flex-start; padding-bottom: .1rem; }
|
|
141
|
+
nav a { padding-left: .5rem; padding-right: .5rem; white-space: nowrap; }
|
|
142
|
+
main { padding: 3.5rem 1rem 3rem; }
|
|
143
|
+
footer { padding-left: 1rem; padding-right: 1rem; flex-direction: column; }
|
|
144
|
+
h1 { font-size: clamp(3rem, 15vw, 5rem); }
|
|
145
|
+
.stats { grid-template-columns: 1fr 1fr; }
|
|
146
|
+
.section-heading { grid-template-columns: 1fr; gap: .75rem; }
|
|
147
|
+
summary { align-items: flex-start; }
|
|
148
|
+
.badge, .route-meta { margin-top: .15rem; }
|
|
149
|
+
.table-wrap { border: 0; overflow: visible; }
|
|
150
|
+
table, thead, tbody, tr, th, td { display: block; }
|
|
151
|
+
caption { border: 1px solid var(--line); }
|
|
152
|
+
thead { position: absolute; left: -10000px; }
|
|
153
|
+
tbody { display: grid; gap: .75rem; margin-top: .75rem; }
|
|
154
|
+
tbody tr { padding: .65rem 1rem; border: 1px solid var(--line); }
|
|
155
|
+
tbody td { display: flex; justify-content: space-between; gap: 1rem; padding: .55rem 0; border-bottom: 1px solid var(--line); text-align: right; }
|
|
156
|
+
tbody td::before { content: attr(data-label); color: var(--muted); font: 500 .65rem "Fira Code", monospace; text-transform: uppercase; text-align: left; }
|
|
157
|
+
tbody td:last-child { border-bottom: 0; }
|
|
158
|
+
.log-detail code, .log-detail pre { max-width: 55vw; text-align: left; }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
@media (max-width: 420px) {
|
|
162
|
+
.stats { grid-template-columns: 1fr; }
|
|
163
|
+
.brand small { display: none; }
|
|
164
|
+
.route-meta { white-space: normal; justify-content: flex-end; }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
@media (prefers-reduced-motion: reduce) {
|
|
168
|
+
:root { scroll-behavior: auto; }
|
|
169
|
+
*, *::before, *::after { transition-duration: .01ms !important; }
|
|
170
|
+
}
|
|
@@ -66,6 +66,14 @@ module RailsAiGateway
|
|
|
66
66
|
send_file Engine.root.join("app/assets/stylesheets/rails_ai_gateway/admin.css"), type: "text/css", disposition: "inline"
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
+
def logo
|
|
70
|
+
send_file Engine.root.join("app/assets/images/rails_ai_gateway/logo.webp"), type: "image/webp", disposition: "inline"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def favicon
|
|
74
|
+
send_file Engine.root.join("app/assets/images/rails_ai_gateway/favicon.webp"), type: "image/webp", disposition: "inline"
|
|
75
|
+
end
|
|
76
|
+
|
|
69
77
|
private
|
|
70
78
|
|
|
71
79
|
def authorize_admin
|
|
@@ -4,6 +4,7 @@ module RailsAiGateway
|
|
|
4
4
|
class Provider < ApplicationRecord
|
|
5
5
|
encrypts :api_key
|
|
6
6
|
has_many :model_routes, dependent: :restrict_with_error
|
|
7
|
+
before_validation :normalize_base_url
|
|
7
8
|
validates :name, presence: true, uniqueness: true, length: { maximum: 255 }
|
|
8
9
|
validates :base_url, presence: true, length: { maximum: 2048 }
|
|
9
10
|
validates :api_key, format: { without: /[\r\n]/ }, allow_nil: true
|
|
@@ -11,6 +12,10 @@ module RailsAiGateway
|
|
|
11
12
|
|
|
12
13
|
private
|
|
13
14
|
|
|
15
|
+
def normalize_base_url
|
|
16
|
+
self.base_url = base_url.to_s.strip.sub(%r{/+(?:chat/completions|responses|embeddings)?/*\z}, "")
|
|
17
|
+
end
|
|
18
|
+
|
|
14
19
|
def valid_base_url
|
|
15
20
|
uri = URI.parse(base_url.to_s)
|
|
16
21
|
schemes = RailsAiGateway.configuration.allow_http ? %w[https http] : %w[https]
|
|
@@ -3,22 +3,31 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="theme-color" content="#07111f">
|
|
6
7
|
<title>Rails AI Gateway</title>
|
|
7
8
|
<%= csrf_meta_tags %>
|
|
9
|
+
<%= tag.link rel: "icon", href: favicon_path, type: "image/webp" %>
|
|
8
10
|
<%= stylesheet_link_tag style_path %>
|
|
9
11
|
</head>
|
|
10
12
|
<body>
|
|
11
13
|
<a class="skip" href="#main">Skip to content</a>
|
|
12
|
-
<header>
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
<header class="site-header">
|
|
15
|
+
<div class="header-inner">
|
|
16
|
+
<%= link_to admin_path, class: "brand", aria: { label: "Rails AI Gateway overview" } do %>
|
|
17
|
+
<%= image_tag logo_path, alt: "", width: 44, height: 44 %>
|
|
18
|
+
<span><strong>Rails AI</strong><small>Gateway console</small></span>
|
|
19
|
+
<% end %>
|
|
20
|
+
<nav aria-label="Gateway sections">
|
|
21
|
+
<%= link_to "Overview", admin_path %>
|
|
22
|
+
<%= link_to "Providers", admin_path(anchor: "providers") %>
|
|
23
|
+
<%= link_to "Models", admin_path(anchor: "models") %>
|
|
24
|
+
<%= link_to "Keys", admin_path(anchor: "keys") %>
|
|
25
|
+
<%= link_to "Logs", admin_path(anchor: "logs") %>
|
|
26
|
+
</nav>
|
|
27
|
+
<span class="system-state"><i aria-hidden="true"></i> Engine online</span>
|
|
28
|
+
</div>
|
|
20
29
|
</header>
|
|
21
30
|
<main id="main"><%= yield %></main>
|
|
22
|
-
<footer>Rails AI Gateway <%= RailsAiGateway::VERSION
|
|
31
|
+
<footer><span>Rails AI Gateway <%= RailsAiGateway::VERSION %></span><span>Request metadata only. Prompts and responses are not stored.</span></footer>
|
|
23
32
|
</body>
|
|
24
33
|
</html>
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
-
<
|
|
2
|
-
<
|
|
3
|
-
|
|
1
|
+
<div class="focus-page">
|
|
2
|
+
<p class="eyebrow error-label">Validation error</p>
|
|
3
|
+
<h1>Could not save changes.</h1>
|
|
4
|
+
<ul class="error-list" role="alert"><% @errors.each do |error| %><li><%= error %></li><% end %></ul>
|
|
5
|
+
<%= link_to "Return to gateway", admin_path, class: "back-link" %>
|
|
6
|
+
</div>
|
|
@@ -1,17 +1,27 @@
|
|
|
1
|
-
<
|
|
2
|
-
<
|
|
1
|
+
<div class="hero">
|
|
2
|
+
<div>
|
|
3
|
+
<p class="eyebrow">Control plane</p>
|
|
4
|
+
<h1>Your models,<br><em>one endpoint.</em></h1>
|
|
5
|
+
<p class="hero-copy">Route, protect, and observe AI traffic from the Rails app you already operate.</p>
|
|
6
|
+
</div>
|
|
7
|
+
<div class="endpoint-card">
|
|
8
|
+
<span>OpenAI-compatible base URL</span>
|
|
9
|
+
<code><%= request.base_url %><%= request.script_name %>/v1</code>
|
|
10
|
+
<small>Bearer authentication required</small>
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
3
13
|
<div class="stats" aria-label="Last 24 hours">
|
|
4
|
-
<div><span>Requests
|
|
5
|
-
<div><span>Errors
|
|
6
|
-
<div><span>Average latency</span><strong><%= @latency
|
|
14
|
+
<div><span>Requests</span><strong><%= @requests %></strong><small>Last 24 hours</small></div>
|
|
15
|
+
<div><span>Errors</span><strong><%= @errors %></strong><small><%= @requests.zero? ? "No traffic yet" : "#{((@errors.to_f / @requests) * 100).round(1)}% error rate" %></small></div>
|
|
16
|
+
<div><span>Average latency</span><strong><%= @latency %><i>ms</i></strong><small>Across completed requests</small></div>
|
|
17
|
+
<div><span>Active routes</span><strong><%= @routes.size %></strong><small><%= @providers.count(&:enabled?) %> enabled providers</small></div>
|
|
7
18
|
</div>
|
|
8
19
|
|
|
9
20
|
<section id="providers">
|
|
10
|
-
<h2>Providers</h2>
|
|
11
|
-
<p>Use the complete API base URL, including <code>/v1</code> where required. Credentials are encrypted with ActiveRecord encryption.</p>
|
|
21
|
+
<div class="section-heading"><div><p class="section-index">01 / Connections</p><h2>Providers</h2></div><p>Connect OpenAI-compatible upstreams. Credentials stay encrypted through ActiveRecord.</p></div>
|
|
12
22
|
<% @providers.each do |provider| %>
|
|
13
23
|
<details>
|
|
14
|
-
<summary><%= provider.name
|
|
24
|
+
<summary><span><%= provider.name %><small><%= provider.base_url %></small></span> <span class="badge <%= "inactive" unless provider.enabled? %>"><i></i><%= provider.enabled? ? "Enabled" : "Disabled" %></span></summary>
|
|
15
25
|
<%= form_with scope: :provider, url: provider_path(provider), method: :patch, local: true, class: "grid", namespace: "provider_#{provider.id}" do |form| %>
|
|
16
26
|
<div><%= form.label :name %><%= form.text_field :name, value: provider.name, required: true %></div>
|
|
17
27
|
<div><%= form.label :base_url, "API base URL" %><%= form.url_field :base_url, value: provider.base_url, required: true %></div>
|
|
@@ -22,8 +32,8 @@
|
|
|
22
32
|
<% end %>
|
|
23
33
|
</details>
|
|
24
34
|
<% end %>
|
|
25
|
-
<details <%= "open" if @providers.empty? %>>
|
|
26
|
-
<summary>Add provider</summary>
|
|
35
|
+
<details class="create-panel" <%= "open" if @providers.empty? %>>
|
|
36
|
+
<summary><span><b>+</b> Add provider<small>Connect another OpenAI-compatible API</small></span></summary>
|
|
27
37
|
<%= form_with scope: :provider, url: providers_path, local: true, class: "grid", namespace: "new_provider" do |form| %>
|
|
28
38
|
<div><%= form.label :name %><%= form.text_field :name, required: true, placeholder: "OpenAI" %></div>
|
|
29
39
|
<div><%= form.label :base_url, "API base URL" %><%= form.url_field :base_url, required: true, placeholder: "https://api.openai.com/v1" %></div>
|
|
@@ -34,11 +44,10 @@
|
|
|
34
44
|
</section>
|
|
35
45
|
|
|
36
46
|
<section id="models">
|
|
37
|
-
<h2>Model routing</h2>
|
|
38
|
-
<p>Clients use the public model name. Lower priority runs first; matching names form an ordered fallback chain.</p>
|
|
47
|
+
<div class="section-heading"><div><p class="section-index">02 / Traffic</p><h2>Model routing</h2></div><p>Map stable public names to upstream models. Lower priority runs first.</p></div>
|
|
39
48
|
<% @routes.each do |route| %>
|
|
40
49
|
<details>
|
|
41
|
-
<summary><%= route.name
|
|
50
|
+
<summary><span><%= route.name %><small><%= route.upstream_model %></small></span> <span class="route-meta"><%= route.provider.name %><b>P<%= route.priority %></b></span></summary>
|
|
42
51
|
<%= form_with scope: :model_route, url: model_route_path(route), method: :patch, local: true, class: "grid", namespace: "route_#{route.id}" do |form| %>
|
|
43
52
|
<div><%= form.label :name, "Public model name" %><%= form.text_field :name, value: route.name, required: true %></div>
|
|
44
53
|
<div><%= form.label :provider_id %><%= form.collection_select :provider_id, @providers, :id, :name, selected: route.provider_id %></div>
|
|
@@ -49,12 +58,12 @@
|
|
|
49
58
|
<%= button_to "Delete route", model_route_path(route), method: :delete, class: "danger" %>
|
|
50
59
|
</details>
|
|
51
60
|
<% end %>
|
|
52
|
-
<details>
|
|
53
|
-
<summary>Add model route</summary>
|
|
61
|
+
<details class="create-panel">
|
|
62
|
+
<summary><span><b>+</b> Add model route<small>Create alias or fallback destination</small></span></summary>
|
|
54
63
|
<%= form_with scope: :model_route, url: model_routes_path, local: true, class: "grid", namespace: "new_route" do |form| %>
|
|
55
64
|
<div><%= form.label :name, "Public model name" %><%= form.text_field :name, required: true, placeholder: "fast-chat" %></div>
|
|
56
65
|
<div><%= form.label :provider_id %><%= form.collection_select :provider_id, @providers, :id, :name %></div>
|
|
57
|
-
<div><%= form.label :upstream_model %><%= form.text_field :upstream_model, required: true, placeholder: "gpt-
|
|
66
|
+
<div><%= form.label :upstream_model %><%= form.text_field :upstream_model, required: true, placeholder: "gpt-5.6-luna" %></div>
|
|
58
67
|
<div><%= form.label :priority %><%= form.number_field :priority, value: 0, min: 0, required: true %></div>
|
|
59
68
|
<%= form.submit "Add route", disabled: @providers.empty? %>
|
|
60
69
|
<% end %>
|
|
@@ -62,20 +71,21 @@
|
|
|
62
71
|
</section>
|
|
63
72
|
|
|
64
73
|
<section id="keys">
|
|
65
|
-
<h2>Gateway keys</h2>
|
|
74
|
+
<div class="section-heading"><div><p class="section-index">03 / Access</p><h2>Gateway keys</h2></div><p>Issue scoped client credentials. Raw tokens are shown exactly once.</p></div>
|
|
66
75
|
<div class="table-wrap">
|
|
67
76
|
<table>
|
|
68
77
|
<caption>Client access keys</caption>
|
|
69
78
|
<thead><tr><th scope="col">Name</th><th scope="col">Prefix</th><th scope="col">Models</th><th scope="col">Expires</th><th scope="col">Status</th><th scope="col">Action</th></tr></thead>
|
|
70
79
|
<tbody>
|
|
71
80
|
<% @keys.each do |key| %>
|
|
72
|
-
|
|
81
|
+
<% key_status = key.revoked_at ? "Revoked" : key.expires_at&.past? ? "Expired" : "Active" %>
|
|
82
|
+
<tr><td data-label="Name"><strong><%= key.name %></strong></td><td data-label="Prefix"><code><%= key.prefix %>…</code></td><td data-label="Models"><%= key.allowed_models.empty? ? "All models" : key.allowed_models.join(", ") %></td><td data-label="Expires"><%= key.expires_at || "Never" %></td><td data-label="Status"><span class="status status-<%= key_status.downcase %>"><%= key_status %></span></td><td data-label="Action"><%= button_to "Revoke", gateway_key_path(key), method: :delete, class: "danger", aria: { label: "Revoke #{key.name}" } unless key.revoked_at %></td></tr>
|
|
73
83
|
<% end %>
|
|
74
84
|
</tbody>
|
|
75
85
|
</table>
|
|
76
86
|
</div>
|
|
77
|
-
<details>
|
|
78
|
-
<summary>Create gateway key</summary>
|
|
87
|
+
<details class="create-panel">
|
|
88
|
+
<summary><span><b>+</b> Create gateway key<small>Grant access to clients and services</small></span></summary>
|
|
79
89
|
<%= form_with scope: :gateway_key, url: gateway_keys_path, local: true, class: "grid" do |form| %>
|
|
80
90
|
<div><%= form.label :name %><%= form.text_field :name, required: true, placeholder: "Production app" %></div>
|
|
81
91
|
<div><%= form.label :allowed_models, "Allowed models (comma separated; blank allows all)" %><%= form.text_field :allowed_models, placeholder: "fast-chat, embeddings" %></div>
|
|
@@ -86,8 +96,8 @@
|
|
|
86
96
|
</section>
|
|
87
97
|
|
|
88
98
|
<section id="logs">
|
|
89
|
-
<h2>Request logs</h2>
|
|
90
|
-
<%= form_with url: admin_path(anchor: "logs"), method: :get, local: true, class: "grid" do |form| %>
|
|
99
|
+
<div class="section-heading"><div><p class="section-index">04 / Observe</p><h2>Request logs</h2></div><p>Inspect routing attempts without storing prompts or responses.</p></div>
|
|
100
|
+
<%= form_with url: admin_path(anchor: "logs"), method: :get, local: true, class: "grid filter-bar" do |form| %>
|
|
91
101
|
<div><%= form.label :model, "Model filter" %><%= form.text_field :model, value: params[:model] %></div>
|
|
92
102
|
<div><%= form.label :status, "HTTP status filter" %><%= form.number_field :status, value: params[:status], min: 100, max: 599 %></div>
|
|
93
103
|
<%= form.submit "Filter logs" %>
|
|
@@ -98,7 +108,7 @@
|
|
|
98
108
|
<thead><tr><th scope="col">Time</th><th scope="col">Model</th><th scope="col">Status</th><th scope="col">Latency</th><th scope="col">Reported tokens</th><th scope="col">Details</th></tr></thead>
|
|
99
109
|
<tbody>
|
|
100
110
|
<% @logs.each do |log| %>
|
|
101
|
-
<tr><td><%= log.created_at.strftime("%
|
|
111
|
+
<tr><td data-label="Time"><span class="timestamp"><%= log.created_at.strftime("%b %d") %><small><%= log.created_at.strftime("%H:%M:%S") %></small></span></td><td data-label="Model"><strong><%= log.model %></strong></td><td data-label="Status"><span class="status status-<%= log.status.to_i >= 400 ? "error" : log.status ? "active" : "pending" %>"><%= log.status || "Pending" %></span></td><td data-label="Latency"><%= log.duration_ms %> ms</td><td data-label="Tokens"><%= log.usage&.fetch("total_tokens", nil) || "—" %></td><td data-label="Details"><details class="log-detail"><summary>Inspect</summary><code><%= log.request_id %></code><pre><%= JSON.pretty_generate(log.attempts) %></pre></details></td></tr>
|
|
102
112
|
<% end %>
|
|
103
113
|
</tbody>
|
|
104
114
|
</table>
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
<
|
|
2
|
-
<p>
|
|
3
|
-
<
|
|
4
|
-
<
|
|
5
|
-
<
|
|
1
|
+
<div class="focus-page">
|
|
2
|
+
<p class="eyebrow">Credential issued</p>
|
|
3
|
+
<h1>Save this key now.</h1>
|
|
4
|
+
<p class="hero-copy">Only its digest is stored. This token cannot be displayed again after leaving this page.</p>
|
|
5
|
+
<div class="token-card">
|
|
6
|
+
<label for="new-token"><%= @key.name %></label>
|
|
7
|
+
<textarea id="new-token" readonly rows="3" autocomplete="off" spellcheck="false"><%= @token %></textarea>
|
|
8
|
+
</div>
|
|
9
|
+
<%= link_to "Return to gateway", admin_path, class: "back-link" %>
|
|
10
|
+
</div>
|
data/config/routes.rb
CHANGED
|
@@ -13,4 +13,6 @@ RailsAiGateway::Engine.routes.draw do
|
|
|
13
13
|
post "admin/gateway_keys", to: "admin#create_gateway_key", as: :gateway_keys
|
|
14
14
|
delete "admin/gateway_keys/:id", to: "admin#revoke_gateway_key", as: :gateway_key
|
|
15
15
|
get "admin/style", to: "admin#style", as: :style
|
|
16
|
+
get "admin/logo", to: "admin#logo", as: :logo
|
|
17
|
+
get "admin/favicon", to: "admin#favicon", as: :favicon
|
|
16
18
|
end
|
|
@@ -7,7 +7,7 @@ module RailsAiGateway
|
|
|
7
7
|
|
|
8
8
|
def install
|
|
9
9
|
template "initializer.rb", "config/initializers/rails_ai_gateway.rb"
|
|
10
|
-
rake "
|
|
10
|
+
rake "railties:install:migrations"
|
|
11
11
|
route 'mount RailsAiGateway::Engine, at: "/ai"'
|
|
12
12
|
say "Configure admin_authorization and ActiveRecord encryption, then run bin/rails db:migrate."
|
|
13
13
|
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
require "rails_ai_gateway"
|
|
@@ -39,6 +39,15 @@ module RailsAiGateway
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
42
|
+
ERROR_TYPES = {
|
|
43
|
+
400 => ["invalid_request_error", "invalid_request"],
|
|
44
|
+
401 => ["authentication_error", "invalid_api_key"],
|
|
45
|
+
403 => ["permission_error", "permission_denied"],
|
|
46
|
+
404 => ["invalid_request_error", "model_not_found"],
|
|
47
|
+
409 => ["conflict_error", "conflict"],
|
|
48
|
+
422 => ["invalid_request_error", "unprocessable_entity"],
|
|
49
|
+
429 => ["rate_limit_error", "rate_limit_exceeded"]
|
|
50
|
+
}.freeze
|
|
42
51
|
PRIVATE_RANGES = %w[0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16
|
|
43
52
|
172.16.0.0/12 192.0.0.0/24 192.0.2.0/24 192.168.0.0/16 198.18.0.0/15
|
|
44
53
|
198.51.100.0/24 203.0.113.0/24 224.0.0.0/4 240.0.0.0/4 ::/128 ::1/128
|
|
@@ -122,6 +131,9 @@ module RailsAiGateway
|
|
|
122
131
|
ready << [response.code.to_i, headers]
|
|
123
132
|
response.read_body { |chunk| chunks << chunk }
|
|
124
133
|
end
|
|
134
|
+
if sent_headers && result.first >= 400
|
|
135
|
+
chunks << "data: #{JSON.generate(error_payload("Upstream stream failed", result.first))}\n\ndata: [DONE]\n\n"
|
|
136
|
+
end
|
|
125
137
|
ready << result unless sent_headers
|
|
126
138
|
rescue StandardError
|
|
127
139
|
ready << error("Gateway failed", 500) unless sent_headers
|
|
@@ -169,6 +181,8 @@ module RailsAiGateway
|
|
|
169
181
|
status = attempt[:status] = response.code.to_i
|
|
170
182
|
raise Failure, "Upstream redirects are not supported" if (300..399).cover?(status)
|
|
171
183
|
headers = { "content-type" => "application/json", "cache-control" => "no-store", "x-request-id" => request_id }
|
|
184
|
+
retry_after = response["retry-after"].to_s
|
|
185
|
+
headers["retry-after"] = retry_after if retry_after.match?(/\A\d{1,10}\z/)
|
|
172
186
|
if payload["stream"] && (200..299).cover?(status)
|
|
173
187
|
raise Failure, "Upstream did not return an event stream" unless response["content-type"].to_s.split(";").first == "text/event-stream"
|
|
174
188
|
headers.merge!("content-type" => "text/event-stream", "x-accel-buffering" => "no")
|
|
@@ -189,7 +203,14 @@ module RailsAiGateway
|
|
|
189
203
|
end
|
|
190
204
|
raise Failure, "Upstream did not return a JSON object" unless parsed.is_a?(Hash)
|
|
191
205
|
usage = parsed["usage"].slice("prompt_tokens", "completion_tokens", "total_tokens").select { |_, value| value.is_a?(Integer) && value >= 0 } if parsed["usage"].is_a?(Hash)
|
|
192
|
-
|
|
206
|
+
if (200..299).cover?(status)
|
|
207
|
+
raise Failure, "Upstream embeddings response is malformed" if @endpoint == "embeddings" && !parsed["data"].is_a?(Array)
|
|
208
|
+
result = [status, headers, [body]]
|
|
209
|
+
else
|
|
210
|
+
message = parsed.dig("error", "message") || parsed["message"] || parsed["error"]
|
|
211
|
+
message = "Upstream request failed" unless message.is_a?(String) && message.present?
|
|
212
|
+
result = [status, headers, [JSON.generate(error_payload(message, status))]]
|
|
213
|
+
end
|
|
193
214
|
end
|
|
194
215
|
end
|
|
195
216
|
end
|
|
@@ -233,7 +254,7 @@ module RailsAiGateway
|
|
|
233
254
|
end
|
|
234
255
|
|
|
235
256
|
def error(message, status, request_id = nil)
|
|
236
|
-
response = json(status,
|
|
257
|
+
response = json(status, error_payload(message, status))
|
|
237
258
|
response[1]["www-authenticate"] = "Bearer" if status == 401
|
|
238
259
|
response[1]["x-request-id"] = request_id if request_id
|
|
239
260
|
response
|
|
@@ -242,5 +263,10 @@ module RailsAiGateway
|
|
|
242
263
|
def json(status, payload)
|
|
243
264
|
[status, { "content-type" => "application/json", "cache-control" => "no-store" }, [JSON.generate(payload)]]
|
|
244
265
|
end
|
|
266
|
+
|
|
267
|
+
def error_payload(message, status)
|
|
268
|
+
type, code = ERROR_TYPES.fetch(status, ["server_error", status == 504 ? "gateway_timeout" : "bad_gateway"])
|
|
269
|
+
{ error: { message: message, type: type, code: code } }
|
|
270
|
+
end
|
|
245
271
|
end
|
|
246
272
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-ai-gateway
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Rails AI Gateway contributors
|
|
@@ -120,6 +120,8 @@ files:
|
|
|
120
120
|
- CONTRIBUTING.md
|
|
121
121
|
- LICENSE
|
|
122
122
|
- README.md
|
|
123
|
+
- app/assets/images/rails_ai_gateway/favicon.webp
|
|
124
|
+
- app/assets/images/rails_ai_gateway/logo.webp
|
|
123
125
|
- app/assets/stylesheets/rails_ai_gateway/admin.css
|
|
124
126
|
- app/controllers/rails_ai_gateway/admin_controller.rb
|
|
125
127
|
- app/models/rails_ai_gateway/application_record.rb
|
|
@@ -135,6 +137,7 @@ files:
|
|
|
135
137
|
- db/migrate/20260920000000_create_rails_ai_gateway.rb
|
|
136
138
|
- lib/generators/rails_ai_gateway/install_generator.rb
|
|
137
139
|
- lib/generators/rails_ai_gateway/templates/initializer.rb
|
|
140
|
+
- lib/rails-ai-gateway.rb
|
|
138
141
|
- lib/rails_ai_gateway.rb
|
|
139
142
|
- lib/rails_ai_gateway/configuration.rb
|
|
140
143
|
- lib/rails_ai_gateway/engine.rb
|