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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 67143b76482c8cdcfc4e4badd4801e1d46c9ecc5bc309e2ddd137f14df7a0e67
4
- data.tar.gz: 1ac58e22f791aead20feabf049dc8f89250eff16170b860c9f4da690b096af96
3
+ metadata.gz: af35d6d1d275d95fdc5823eded924c98cd4e39e18e1f67388f1fd0f7e9f056f3
4
+ data.tar.gz: bc9d7f02956947396a2801b27199328aba3b38b1d9917e2e00d9a70a1fc46b11
5
5
  SHA512:
6
- metadata.gz: 6cdeafa5eaf720bd486ff259620eb6868aa0829f96b9c461626fbd6bb696227534330672480df8b4496fc8182ec56563bd422a476b6c0b4dabb1cfc4e16ddf7d
7
- data.tar.gz: 550e16aece7e78223753f79cdbe2a2f6c3308767b3e259cce7b917687dd32055daadeae9e9df9134ce205741267208339126b3b2c531d6a2809ea22562a172de
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-4o-mini`.
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
@@ -1,41 +1,170 @@
1
- :root { color-scheme: light; font-family: system-ui, sans-serif; color: #172538; background: #f5f7fa; line-height: 1.5; }
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
- header { background: #14283f; color: #fff; display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; padding: 1.25rem max(1rem, calc((100vw - 1200px) / 2)); }
5
- header a { color: #fff; text-decoration: none; }
6
- .brand { font-weight: 750; font-size: 1.15rem; }
7
- nav { display: flex; flex-wrap: wrap; gap: 1.25rem; }
8
- main, footer { max-width: 1200px; margin: auto; padding: 1.5rem; }
9
- h1 { font-size: 2rem; letter-spacing: -.03em; margin-bottom: .4rem; }
10
- h2 { margin-top: 0; }
11
- a { color: #175da1; }
12
- section { margin: 1.5rem 0; padding: 1.5rem; background: #fff; border: 1px solid #d8e0e9; border-radius: .5rem; scroll-margin-top: 1rem; }
13
- .muted, footer { color: #536477; }
14
- .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; }
15
- .stats div { background: #fff; border: 1px solid #d8e0e9; padding: 1.25rem; border-radius: .5rem; }
16
- .stats span, .stats strong { display: block; }
17
- .stats strong { font-size: 2rem; margin-top: .4rem; }
18
- .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr)); gap: 1rem; align-items: end; margin: 1rem 0; }
19
- label { display: block; font-weight: 600; font-size: .875rem; margin-bottom: .35rem; }
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: .6rem; border: 1px solid #8595a7; border-radius: .25rem; background: #fff; color: inherit; }
22
- button, input[type=submit] { background: #195a97; color: #fff; border: 1px solid #195a97; border-radius: .25rem; padding: .6rem .85rem; cursor: pointer; font-weight: 600; }
23
- button.danger { color: #a12828; background: #fff; border-color: #d5aaaa; font-size: .85rem; }
24
- input:disabled { opacity: .5; cursor: not-allowed; }
25
- .check { display: flex; align-items: center; gap: .5rem; min-height: 2.8rem; }
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
- details { padding: .85rem 0; border-top: 1px solid #e3e8ef; }
28
- summary { cursor: pointer; font-weight: 600; overflow-wrap: anywhere; }
29
- .badge { font-weight: 400; color: #536477; font-size: .85rem; margin-left: .5rem; }
30
- .table-wrap { overflow-x: auto; }
31
- table { width: 100%; border-collapse: collapse; font-size: .9rem; }
32
- caption { text-align: left; color: #536477; margin-bottom: .75rem; }
33
- th, td { text-align: left; padding: .8rem .6rem; border-bottom: 1px solid #e3e8ef; vertical-align: top; }
34
- th { background: #f5f7fa; white-space: nowrap; }
35
- td details { border: 0; padding: 0; }
36
- code, pre, textarea { font-family: ui-monospace, monospace; overflow-wrap: anywhere; }
37
- pre { max-width: 28rem; white-space: pre-wrap; }
38
- :focus-visible { outline: 3px solid #b46b00; outline-offset: 3px; }
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: .5rem; padding: .5rem; background: #fff; z-index: 1; }
41
- @media (max-width: 600px) { main, footer { padding: 1rem; } section { padding: 1rem; } }
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
- <%= link_to "Rails AI Gateway", admin_path, class: "brand" %>
14
- <nav aria-label="Gateway sections">
15
- <%= link_to "Providers", admin_path(anchor: "providers") %>
16
- <%= link_to "Models", admin_path(anchor: "models") %>
17
- <%= link_to "Keys", admin_path(anchor: "keys") %>
18
- <%= link_to "Logs", admin_path(anchor: "logs") %>
19
- </nav>
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 %> · Request metadata only. Prompts and responses are not stored.</footer>
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
- <h1>Could not save changes</h1>
2
- <ul role="alert"><% @errors.each do |error| %><li><%= error %></li><% end %></ul>
3
- <%= link_to "Back to gateway", admin_path %>
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
- <h1>Gateway overview</h1>
2
- <p class="muted">One endpoint for your models. Configuration and traffic live in your Rails database.</p>
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 · 24 hours</span><strong><%= @requests %></strong></div>
5
- <div><span>Errors · 24 hours</span><strong><%= @errors %></strong></div>
6
- <div><span>Average latency</span><strong><%= @latency %> ms</strong></div>
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 %> <span class="badge"><%= provider.enabled? ? "Enabled" : "Disabled" %></span></summary>
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 %> <span class="badge"><%= route.provider.name %> · priority <%= route.priority %></span></summary>
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-4o-mini" %></div>
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
- <tr><td><%= key.name %></td><td><code><%= key.prefix %>…</code></td><td><%= key.allowed_models.empty? ? "All models" : key.allowed_models.join(", ") %></td><td><%= key.expires_at || "Never" %></td><td><%= key.revoked_at ? "Revoked" : key.expires_at&.past? ? "Expired" : "Active" %></td><td><%= button_to "Revoke #{key.name}", gateway_key_path(key), method: :delete, class: "danger" unless key.revoked_at %></td></tr>
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("%Y-%m-%d %H:%M:%S %Z") %></td><td><%= log.model %></td><td><%= log.status || "Pending" %></td><td><%= log.duration_ms %> ms</td><td><%= log.usage&.fetch("total_tokens", nil) || "Not reported" %></td><td><details><summary>Attempts and request ID</summary><code><%= log.request_id %></code><pre><%= JSON.pretty_generate(log.attempts) %></pre></details></td></tr>
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
- <h1>Gateway key created</h1>
2
- <p>Copy this token now. Only its digest is stored; it cannot be displayed again.</p>
3
- <label for="new-token"><%= @key.name %></label>
4
- <textarea id="new-token" readonly rows="3" autocomplete="off" spellcheck="false"><%= @token %></textarea>
5
- <p><%= link_to "Back to gateway", admin_path %></p>
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 "rails_ai_gateway:install:migrations"
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
- result = [status, headers, [body]]
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, error: { message: message, type: "gateway_error", code: 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
@@ -1,3 +1,3 @@
1
1
  module RailsAiGateway
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  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.1.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