Skip to content

Commit d4c74d2

Browse files
feat: add inference service type for AI gateway configuration
Add service_type discriminator and inference config map to services, enabling provider-aware token limiting, cost attribution, model routing, guardrails, streaming, and cross-provider fallback in generated KDL.
1 parent 862f638 commit d4c74d2

11 files changed

Lines changed: 562 additions & 9 deletions

lib/sentinel_cp/config_export.ex

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,8 @@ defmodule SentinelCp.ConfigExport do
199199
"route_path" => s.route_path,
200200
"upstream_url" => s.upstream_url,
201201
"enabled" => s.enabled,
202-
"position" => s.position
202+
"position" => s.position,
203+
"service_type" => s.service_type
203204
}
204205

205206
# Include non-default config maps
@@ -212,6 +213,7 @@ defmodule SentinelCp.ConfigExport do
212213
|> maybe_add("retry", s.retry, %{})
213214
|> maybe_add("compression", s.compression, %{})
214215
|> maybe_add("security", s.security, %{})
216+
|> maybe_add("inference", s.inference, %{})
215217
end)
216218
end
217219

@@ -291,7 +293,9 @@ defmodule SentinelCp.ConfigExport do
291293
route_path: svc_data["route_path"],
292294
upstream_url: svc_data["upstream_url"],
293295
enabled: Map.get(svc_data, "enabled", true),
294-
position: Map.get(svc_data, "position", 0)
296+
position: Map.get(svc_data, "position", 0),
297+
service_type: Map.get(svc_data, "service_type", "standard"),
298+
inference: Map.get(svc_data, "inference", %{})
295299
}
296300

297301
case SentinelCp.Services.create_service(attrs) do

lib/sentinel_cp/services.ex

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ defmodule SentinelCp.Services do
2525
query =
2626
Enum.reduce(opts, query, fn
2727
{:enabled, enabled}, q -> where(q, [s], s.enabled == ^enabled)
28+
{:service_type, type}, q -> where(q, [s], s.service_type == ^type)
2829
_, q -> q
2930
end)
3031

@@ -781,7 +782,8 @@ defmodule SentinelCp.Services do
781782
status: if(service.enabled, do: "enabled", else: "disabled"),
782783
metadata: %{
783784
route_path: service.route_path,
784-
upstream_url: service.upstream_url
785+
upstream_url: service.upstream_url,
786+
service_type: service.service_type
785787
}
786788
}
787789
end

lib/sentinel_cp/services/built_in_templates.ex

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,26 @@ defmodule SentinelCp.Services.BuiltInTemplates do
103103
},
104104
is_builtin: true,
105105
version: 1
106+
},
107+
%{
108+
name: "LLM Inference Gateway",
109+
category: "inference",
110+
description: "AI inference gateway with token rate limiting, cost tracking, and streaming.",
111+
template_data: %{
112+
"upstream_url" => "http://inference-backend:8080",
113+
"route_path" => "/v1/*",
114+
"service_type" => "inference",
115+
"timeout_seconds" => 300,
116+
"inference" => %{
117+
"provider" => "openai",
118+
"token_rate_limit" => %{"tokens_per_minute" => 100_000, "burst_allowance" => 1.5},
119+
"token_budget" => %{"period" => "monthly", "limit" => 10_000_000, "alert_threshold" => 0.8, "enforcement" => "block"},
120+
"cost_attribution" => %{"currency" => "USD", "models" => [%{"pattern" => "*", "input_cost_per_1k" => 0.01, "output_cost_per_1k" => 0.03}]},
121+
"streaming" => %{"enabled" => true, "format" => "sse"}
122+
}
123+
},
124+
is_builtin: true,
125+
version: 1
106126
}
107127
]
108128

lib/sentinel_cp/services/kdl_generator.ex

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ defmodule SentinelCp.Services.KdlGenerator do
6262
defp resolve_service_configs(services, project_id, environment) do
6363
config_fields = [:headers, :cors, :cache, :retry, :rate_limit, :health_check,
6464
:access_control, :compression, :security, :request_transform,
65-
:response_transform, :path_rewrite]
65+
:response_transform, :path_rewrite, :inference]
6666

6767
Enum.reduce_while(services, {:ok, []}, fn service, {:ok, acc} ->
6868
case resolve_service_config_maps(service, config_fields, project_id, environment) do
@@ -220,6 +220,13 @@ defmodule SentinelCp.Services.KdlGenerator do
220220
defp build_route(%Service{} = service, group_map, cert_map, auth_policy_map, middleware_chain) do
221221
lines = [" route #{inspect(service.route_path)} {"]
222222

223+
lines =
224+
if service.service_type && service.service_type != "standard" do
225+
lines ++ [" service-type #{inspect(service.service_type)}"]
226+
else
227+
lines
228+
end
229+
223230
lines =
224231
cond do
225232
service.upstream_url ->
@@ -259,6 +266,7 @@ defmodule SentinelCp.Services.KdlGenerator do
259266
lines = lines ++ build_request_transform_block(service.request_transform)
260267
lines = lines ++ build_response_transform_block(service.response_transform)
261268
lines = lines ++ build_traffic_split_block(service.traffic_split, group_map)
269+
lines = lines ++ build_inference_block(service.inference)
262270

263271
# Append middleware chain blocks (after inline fields)
264272
lines = lines ++ build_middleware_chain(middleware_chain)
@@ -466,6 +474,143 @@ defmodule SentinelCp.Services.KdlGenerator do
466474
end
467475
end
468476

477+
defp build_inference_block(inf) when inf == %{} or inf == nil, do: []
478+
479+
defp build_inference_block(inf) do
480+
lines = [" inference {"]
481+
482+
# Provider
483+
lines =
484+
case Map.get(inf, "provider") do
485+
nil -> lines
486+
provider -> lines ++ [" provider #{inspect(provider)}"]
487+
end
488+
489+
# Token rate limit
490+
lines = lines ++ build_inference_sub_block(Map.get(inf, "token_rate_limit"), "rate-limit")
491+
492+
# Token budget
493+
lines = lines ++ build_inference_sub_block(Map.get(inf, "token_budget"), "budget")
494+
495+
# Cost attribution
496+
lines = lines ++ build_cost_attribution_block(Map.get(inf, "cost_attribution"))
497+
498+
# Model routing
499+
lines = lines ++ build_model_routing_block(Map.get(inf, "model_routing"))
500+
501+
# Guardrails
502+
lines = lines ++ build_guardrails_block(Map.get(inf, "guardrails"))
503+
504+
# Fallback
505+
lines = lines ++ build_fallback_block(Map.get(inf, "fallback"))
506+
507+
# Streaming
508+
lines = lines ++ build_inference_sub_block(Map.get(inf, "streaming"), "streaming")
509+
510+
lines ++ [" }"]
511+
end
512+
513+
defp build_inference_sub_block(nil, _name), do: []
514+
defp build_inference_sub_block(map, _name) when map == %{}, do: []
515+
516+
defp build_inference_sub_block(map, name) do
517+
inner =
518+
map
519+
|> Enum.sort_by(fn {k, _} -> k end)
520+
|> Enum.map(fn {key, value} -> " #{key} #{format_value(value)}" end)
521+
522+
[" #{name} {"] ++ inner ++ [" }"]
523+
end
524+
525+
defp build_cost_attribution_block(nil), do: []
526+
defp build_cost_attribution_block(ca) when ca == %{}, do: []
527+
528+
defp build_cost_attribution_block(ca) do
529+
lines = [" cost-attribution {"]
530+
531+
lines =
532+
case Map.get(ca, "currency") do
533+
nil -> lines
534+
currency -> lines ++ [" currency #{inspect(currency)}"]
535+
end
536+
537+
models = Map.get(ca, "models", [])
538+
539+
model_lines =
540+
Enum.flat_map(models, fn model ->
541+
pattern = Map.get(model, "pattern", "*")
542+
inner =
543+
model
544+
|> Map.drop(["pattern"])
545+
|> Enum.sort_by(fn {k, _} -> k end)
546+
|> Enum.map(fn {key, value} -> " #{key} #{format_value(value)}" end)
547+
548+
[" model #{inspect(pattern)} {"] ++ inner ++ [" }"]
549+
end)
550+
551+
lines ++ model_lines ++ [" }"]
552+
end
553+
554+
defp build_model_routing_block(nil), do: []
555+
defp build_model_routing_block(mr) when mr == %{}, do: []
556+
557+
defp build_model_routing_block(mr) do
558+
lines = [" model-routing {"]
559+
560+
routes = Map.get(mr, "routes", [])
561+
562+
route_lines =
563+
Enum.flat_map(routes, fn route ->
564+
pattern = Map.get(route, "pattern", "*")
565+
inner =
566+
route
567+
|> Map.drop(["pattern"])
568+
|> Enum.sort_by(fn {k, _} -> k end)
569+
|> Enum.map(fn {key, value} -> " #{key} #{format_value(value)}" end)
570+
571+
[" route #{inspect(pattern)} {"] ++ inner ++ [" }"]
572+
end)
573+
574+
lines ++ route_lines ++ [" }"]
575+
end
576+
577+
defp build_guardrails_block(nil), do: []
578+
defp build_guardrails_block(gr) when gr == %{}, do: []
579+
580+
defp build_guardrails_block(gr) do
581+
lines = [" guardrails {"]
582+
583+
lines = lines ++ build_inference_sub_block(Map.get(gr, "prompt_injection"), "prompt-injection")
584+
lines = lines ++ build_inference_sub_block(Map.get(gr, "pii_detection"), "pii-detection")
585+
586+
lines ++ [" }"]
587+
end
588+
589+
defp build_fallback_block(nil), do: []
590+
defp build_fallback_block(fb) when fb == %{}, do: []
591+
592+
defp build_fallback_block(fb) do
593+
lines = [" fallback {"]
594+
595+
mappings = Map.get(fb, "model_mapping", [])
596+
597+
mapping_lines =
598+
Enum.map(mappings, fn mapping ->
599+
from = Map.get(mapping, "from", "")
600+
to = Map.get(mapping, "to", "")
601+
" model-mapping #{inspect(from)} #{inspect(to)}"
602+
end)
603+
604+
# Add other top-level fallback keys
605+
other_lines =
606+
fb
607+
|> Map.drop(["model_mapping"])
608+
|> Enum.sort_by(fn {k, _} -> k end)
609+
|> Enum.map(fn {key, value} -> " #{key} #{format_value(value)}" end)
610+
611+
lines ++ other_lines ++ mapping_lines ++ [" }"]
612+
end
613+
469614
defp build_middleware_chain([]), do: []
470615

471616
defp build_middleware_chain(service_middlewares) do

lib/sentinel_cp/services/service.ex

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ defmodule SentinelCp.Services.Service do
3434
field :request_transform, :map, default: %{}
3535
field :response_transform, :map, default: %{}
3636
field :traffic_split, :map, default: %{}
37+
field :service_type, :string, default: "standard"
38+
field :inference, :map, default: %{}
3739
field :redirect_url, :string
3840

3941
belongs_to :project, SentinelCp.Projects.Project
@@ -74,6 +76,8 @@ defmodule SentinelCp.Services.Service do
7476
:request_transform,
7577
:response_transform,
7678
:traffic_split,
79+
:service_type,
80+
:inference,
7781
:redirect_url,
7882
:upstream_group_id,
7983
:certificate_id,
@@ -86,6 +90,8 @@ defmodule SentinelCp.Services.Service do
8690
|> validate_length(:name, min: 1, max: 100)
8791
|> validate_route_path()
8892
|> validate_route_type()
93+
|> validate_inclusion(:service_type, ~w(standard inference))
94+
|> validate_inference_config()
8995
|> generate_slug()
9096
|> validate_slug()
9197
|> unique_constraint([:project_id, :slug], error_key: :slug)
@@ -117,6 +123,8 @@ defmodule SentinelCp.Services.Service do
117123
:request_transform,
118124
:response_transform,
119125
:traffic_split,
126+
:service_type,
127+
:inference,
120128
:redirect_url,
121129
:upstream_group_id,
122130
:certificate_id,
@@ -128,6 +136,34 @@ defmodule SentinelCp.Services.Service do
128136
|> validate_length(:name, min: 1, max: 100)
129137
|> validate_route_path()
130138
|> validate_route_type()
139+
|> validate_inclusion(:service_type, ~w(standard inference))
140+
|> validate_inference_config()
141+
end
142+
143+
defp validate_inference_config(changeset) do
144+
service_type = get_field(changeset, :service_type)
145+
inference = get_field(changeset, :inference)
146+
147+
case service_type do
148+
"inference" ->
149+
cond do
150+
not is_map(inference) or inference == %{} ->
151+
add_error(changeset, :inference, "is required when service_type is inference")
152+
153+
Map.get(inference, "provider") not in ~w(openai anthropic generic) ->
154+
add_error(changeset, :inference, "must include a valid provider (openai, anthropic, generic)")
155+
156+
true ->
157+
changeset
158+
end
159+
160+
_ ->
161+
if is_map(inference) and inference != %{} do
162+
add_error(changeset, :inference, "must be empty for standard services")
163+
else
164+
changeset
165+
end
166+
end
131167
end
132168

133169
defp validate_route_path(changeset) do

lib/sentinel_cp/services/service_template.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ defmodule SentinelCp.Services.ServiceTemplate do
1111
@primary_key {:id, :binary_id, autogenerate: true}
1212
@foreign_key_type :binary_id
1313

14-
@categories ~w(api web websocket static auth utility)
14+
@categories ~w(api web websocket static auth utility inference)
1515

1616
schema "service_templates" do
1717
field :name, :string
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
defmodule SentinelCp.Repo.Migrations.AddInferenceToServices do
2+
use Ecto.Migration
3+
4+
def change do
5+
alter table(:services) do
6+
add :service_type, :string, default: "standard"
7+
add :inference, :map, default: %{}
8+
end
9+
10+
create index(:services, [:project_id, :service_type])
11+
end
12+
end

0 commit comments

Comments
 (0)