Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
-- +micrate Up
-- SQL in section 'Up' is executed when this migration is applied

-- The text/vision model a provider row uses for map editing. NULL means the
-- adapter's default for that vendor.
ALTER TABLE "signage_ai_providers" ADD COLUMN IF NOT EXISTS text_model TEXT;

-- MAP_EDIT jobs share the table with the image kinds: one vendor call each,
-- the operations land in result->'ops'.
ALTER TABLE "signage_ai_jobs" DROP CONSTRAINT IF EXISTS signage_ai_jobs_kind_check;
ALTER TABLE "signage_ai_jobs" ADD CONSTRAINT signage_ai_jobs_kind_check
CHECK (kind IN ('GENERATE', 'EDIT', 'MAP_EDIT'));

CREATE INDEX IF NOT EXISTS signage_ai_jobs_kind_created_index
ON "signage_ai_jobs" USING BTREE (kind, created_at);

-- +micrate Down
-- SQL section 'Down' is executed when this migration is rolled back

DROP INDEX IF EXISTS signage_ai_jobs_kind_created_index;

ALTER TABLE "signage_ai_jobs" DROP CONSTRAINT IF EXISTS signage_ai_jobs_kind_check;
ALTER TABLE "signage_ai_jobs" ADD CONSTRAINT signage_ai_jobs_kind_check
CHECK (kind IN ('GENERATE', 'EDIT'));

ALTER TABLE "signage_ai_providers" DROP COLUMN IF EXISTS text_model;
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- +micrate Up
-- SQL in section 'Up' is executed when this migration is applied

-- Anthropic joins the vendors a provider row can point at; its rows carry an
-- api_key in credentials and a text_model, and cannot generate images.
ALTER TABLE "signage_ai_providers" DROP CONSTRAINT IF EXISTS signage_ai_providers_provider_check;
ALTER TABLE "signage_ai_providers" ADD CONSTRAINT signage_ai_providers_provider_check
CHECK (provider IN ('OPENAI', 'AZURE_OPENAI', 'GOOGLE_VERTEX', 'ANTHROPIC'));

-- +micrate Down
-- SQL section 'Down' is executed when this migration is rolled back

ALTER TABLE "signage_ai_providers" DROP CONSTRAINT IF EXISTS signage_ai_providers_provider_check;
ALTER TABLE "signage_ai_providers" ADD CONSTRAINT signage_ai_providers_provider_check
CHECK (provider IN ('OPENAI', 'AZURE_OPENAI', 'GOOGLE_VERTEX'));
13 changes: 10 additions & 3 deletions spec/generator.cr
Original file line number Diff line number Diff line change
Expand Up @@ -751,13 +751,15 @@ module PlaceOS::Model
is_default : Bool = false,
enabled : Bool = true,
quotas : Hash(String, JSON::Any) = {} of String => JSON::Any,
text_model : String? = nil,
)
row = SignageAIProvider.new
row.name = name || Faker::Hacker.noun
row.provider = provider
row.authority_id = authority.try(&.id.as(String))
row.credentials = credentials
row.default_model = provider.google? ? "gemini-3.1-flash-image" : "gpt-image-2"
row.text_model = text_model
row.is_default = is_default
row.enabled = enabled
row.quotas = JSON::Any.new(quotas)
Expand Down Expand Up @@ -788,12 +790,17 @@ module PlaceOS::Model
job.state = state
job.candidates = candidates
job.parent_job_id = parent_job_id
job.prompt = "a poster for the office party"
job.result = JSON::Any.new({"images" => JSON::Any.new(Array(JSON::Any).new(candidates) { JSON::Any.new(nil) })})
if kind.map_edit?
job.prompt = "recolour the boardroom light blue"
job.result = JSON::Any.new({"ops" => JSON::Any.new([] of JSON::Any)})
else
job.prompt = "a poster for the office party"
job.result = JSON::Any.new({"images" => JSON::Any.new(Array(JSON::Any).new(candidates) { JSON::Any.new(nil) })})
end
if provider
job.provider_id = provider.id.as(UUID)
job.provider_type = provider.provider.to_s
job.model = provider.default_model
job.model = kind.map_edit? ? (provider.text_model || "gpt-5.4-mini") : provider.default_model
end
job
end
Expand Down
60 changes: 60 additions & 0 deletions spec/signage_ai_job_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,42 @@ module PlaceOS::Model
SignageAIJob.sum_candidates(user.id.as(String), 1.minute.from_now).should eq 0
end

it "filters the quota sums by kind" do
authority = Generator.localhost_authority
user = Generator.user(authority: authority).save!
Generator.signage_ai_job(authority: authority, user: user, candidates: 3).save!
Generator.signage_ai_job(authority: authority, user: user, candidates: 1, kind: SignageAIJob::Kind::MapEdit).save!
Generator.signage_ai_job(authority: authority, user: user, candidates: 1, kind: SignageAIJob::Kind::MapEdit, state: SignageAIJob::State::Failed).save!

since = 1.day.ago
SignageAIJob.sum_candidates(user.id.as(String), since).should eq 5
SignageAIJob.sum_candidates(user.id.as(String), since, SignageAIJob::Kind::MapEdit).should eq 2
SignageAIJob.sum_candidates(user.id.as(String), since, SignageAIJob::Kind::Generate).should eq 3
SignageAIJob.sum_candidates_for_authority(authority.id.as(String), since, SignageAIJob::Kind::MapEdit).should eq 2
SignageAIJob.sum_candidates_for_authority(authority.id.as(String), since, SignageAIJob::Kind::Edit).should eq 0

# a list of kinds, as the image quota asks for
SignageAIJob.sum_candidates(user.id.as(String), since, SignageAIJob::Kind.image).should eq 3
SignageAIJob.sum_candidates_for_authority(authority.id.as(String), since, SignageAIJob::Kind.image).should eq 3
SignageAIJob.sum_candidates(user.id.as(String), since, [SignageAIJob::Kind::Generate, SignageAIJob::Kind::MapEdit]).should eq 5
SignageAIJob.sum_candidates(user.id.as(String), since, [] of SignageAIJob::Kind).should eq 5
end

it "stores a map edit and reads it back" do
job = Generator.signage_ai_job(kind: SignageAIJob::Kind::MapEdit, candidates: 1).save!
found = SignageAIJob.find!(job.id.as(UUID))
found.kind.should eq SignageAIJob::Kind::MapEdit
found.kind.to_s.should eq "map_edit"
found.kind.image?.should be_false
SignageAIJob::Kind::Edit.image?.should be_true
SignageAIJob::Kind.image.should eq [SignageAIJob::Kind::Generate, SignageAIJob::Kind::Edit]
SignageAIJob.kind_label(SignageAIJob::Kind::MapEdit).should eq "MAP_EDIT"

rendered = found.to_json
rendered.should contain %("kind":"map_edit")
SignageAIJob.from_trusted_json(rendered).kind.should eq SignageAIJob::Kind::MapEdit
end

it "walks a refine chain oldest first" do
first = Generator.signage_ai_job.save!
second = Generator.signage_ai_job(parent_job_id: first.id.as(UUID)).save!
Expand Down Expand Up @@ -108,6 +144,7 @@ module PlaceOS::Model
rows = SignageAIJob.usage(authority.id.as(String), 1.day.ago, 1.day.from_now)
rows.size.should eq 1
row = rows.first
row.kind.should eq "generate"
row.provider.should eq "OPENAI"
row.model.should eq "gpt-image-2"
row.jobs.should eq 2
Expand All @@ -116,6 +153,29 @@ module PlaceOS::Model
row.cost_units.should eq 1.0
end

it "separates map edits from image jobs in the usage report" do
authority = Generator.localhost_authority
provider = Generator.signage_ai_provider(authority: authority, text_model: "gpt-5.4-mini").save!
Generator.signage_ai_job(authority: authority, provider: provider, candidates: 2).save!
map_job = Generator.signage_ai_job(authority: authority, provider: provider, candidates: 1, kind: SignageAIJob::Kind::MapEdit).save!
map_job.cost_units = 4321.0
map_job.save!

rows = SignageAIJob.usage(authority.id.as(String), 1.day.ago, 1.day.from_now)
rows.map(&.kind).should eq ["generate", "map_edit"]
rows.last.model.should eq "gpt-5.4-mini"
rows.last.candidates.should eq 1
rows.last.images_produced.should eq 0
rows.last.cost_units.should eq 4321.0

only_maps = SignageAIJob.usage(authority.id.as(String), 1.day.ago, 1.day.from_now, SignageAIJob::Kind::MapEdit)
only_maps.map(&.kind).should eq ["map_edit"]
only_maps.first.to_json.should contain %("kind":"map_edit")

images = SignageAIJob.usage(authority.id.as(String), 1.day.ago, 1.day.from_now, SignageAIJob::Kind.image)
images.map(&.kind).should eq ["generate"]
end

describe "automatic cleanup" do
# cleanup ships off, so every example that exercises it has to turn it on
before_each { SignageAIJob.cleanup_after = 90.days }
Expand Down
17 changes: 14 additions & 3 deletions spec/signage_ai_provider_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ module PlaceOS::Model
authority = Generator.localhost_authority
other = Generator.authority(domain: "http://default-#{UUID.random}.test").save!

first = Generator.signage_ai_provider(authority: authority, is_default: true).save!
elsewhere = Generator.signage_ai_provider(authority: other, is_default: true).save!
second = Generator.signage_ai_provider(authority: authority, is_default: true).save!
first = Generator.signage_ai_provider(name: "first", authority: authority, is_default: true).save!
elsewhere = Generator.signage_ai_provider(name: "elsewhere", authority: other, is_default: true).save!
second = Generator.signage_ai_provider(name: "second", authority: authority, is_default: true).save!

SignageAIProvider.find!(first.id.as(UUID)).is_default.should be_false
SignageAIProvider.find!(second.id.as(UUID)).is_default.should be_true
Expand Down Expand Up @@ -72,6 +72,17 @@ module PlaceOS::Model
SignageAIProvider.default_for(authority.id.as(String)).try(&.id).should eq shared.id
end

it "stores the text model and renders it" do
row = Generator.signage_ai_provider(text_model: "gpt-5.4-mini").save!
found = SignageAIProvider.find!(row.id.as(UUID))
found.text_model.should eq "gpt-5.4-mini"
found.as_json[:text_model].should eq "gpt-5.4-mini"

bare = Generator.signage_ai_provider.save!
SignageAIProvider.find!(bare.id.as(UUID)).text_model.should be_nil
bare.as_json[:text_model].should be_nil
end

it "reads quotas" do
row = Generator.signage_ai_provider(quotas: {"user_per_day" => JSON::Any.new(12_i64)}).save!
SignageAIProvider.find!(row.id.as(UUID)).quota("user_per_day").should eq 12
Expand Down
72 changes: 58 additions & 14 deletions src/placeos-models/signage_ai_job.cr
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ require "./signage_ai_provider"
require "./user"

module PlaceOS::Model
# One image generation or edit request.
# One image generation or edit request, or one map edit request.
#
# Candidates are written into `result["images"]` one at a time by the runner as
# each vendor call finishes. Every write bumps `version`, which is what the long
# each vendor call finishes. A map edit is a single vendor call whose operations
# land in `result["ops"]`. Every write bumps `version`, which is what the long
# polling endpoint compares against so a client can tell what it has already seen
# (`updated_at` only has whole second resolution and cannot order two writes in
# the same second).
Expand Down Expand Up @@ -51,9 +52,19 @@ module PlaceOS::Model
enum Kind
Generate
Edit
MapEdit

def to_s : String
super.downcase
super.underscore
end

# the kinds that produce images
def image? : Bool
generate? || edit?
end

def self.image : Array(Kind)
[Generate, Edit]
end
end

Expand Down Expand Up @@ -213,33 +224,61 @@ module PlaceOS::Model
end

# Candidates requested by a user since a point in time, for the per user quota.
# `kind` narrows the sum to one kind or a list of kinds; nil sums every kind.
#
# Failed jobs count. Most of them reached the vendor and were billed, and
# exempting them meant a caller whose requests kept failing had no limit at
# all, which is the one case where a limit matters most.
def self.sum_candidates(user_id : String, since : Time) : Int32
def self.sum_candidates(user_id : String, since : Time, kind : Kind | Array(Kind) | Nil = nil) : Int32
filter, labels = kind_filter(kind, 3)
args = [user_id, since] of ::PgORM::Value
args.concat(labels)
::PgORM::Database.connection do |db|
db.query_one(<<-SQL, user_id, since, as: Int64)
db.query_one(<<-SQL, args: args, as: Int64)
SELECT COALESCE(SUM(candidates), 0)::bigint
FROM signage_ai_jobs
WHERE user_id = $1
AND created_at >= $2
#{filter}
SQL
end.to_i32
end

# Candidates requested across a domain since a point in time, for the domain quota.
def self.sum_candidates_for_authority(authority_id : String, since : Time) : Int32
def self.sum_candidates_for_authority(authority_id : String, since : Time, kind : Kind | Array(Kind) | Nil = nil) : Int32
filter, labels = kind_filter(kind, 3)
args = [authority_id, since] of ::PgORM::Value
args.concat(labels)
::PgORM::Database.connection do |db|
db.query_one(<<-SQL, authority_id, since, as: Int64)
db.query_one(<<-SQL, args: args, as: Int64)
SELECT COALESCE(SUM(candidates), 0)::bigint
FROM signage_ai_jobs
WHERE authority_id = $1
AND created_at >= $2
#{filter}
SQL
end.to_i32
end

# the label `PGEnumConverter` stores for a kind, for raw SQL
def self.kind_label(kind : Kind) : String
kind.to_s.underscore.upcase
end

# An `AND kind IN (...)` clause with its bound labels, placeholders numbered
# from `first`; empty for nil.
private def self.kind_filter(kind : Kind | Array(Kind) | Nil, first : Int32) : Tuple(String, Array(::PgORM::Value))
kinds = case kind
in Kind then [kind]
in Array(Kind) then kind
in Nil then [] of Kind
end
return {"", [] of ::PgORM::Value} if kinds.empty?

placeholders = kinds.map_with_index { |_, index| "$#{first + index}" }.join(", ")
{"AND kind IN (#{placeholders})", kinds.map { |entry| kind_label(entry).as(::PgORM::Value) }}
end

# Housekeeping, run as each new job is created. Does nothing unless an
# operator has set a retention window.
#
Expand Down Expand Up @@ -313,30 +352,35 @@ module PlaceOS::Model
where("state = 'RUNNING' AND started_at < ?", older_than).to_a
end

# Usage per provider and model, for the Backoffice usage tab.
record UsageRow, provider : String, model : String, jobs : Int64, candidates : Int64,
# Usage per kind, provider and model, for the Backoffice usage tab.
record UsageRow, kind : String, provider : String, model : String, jobs : Int64, candidates : Int64,
images_produced : Int64, cost_units : Float64 do
include JSON::Serializable
end

def self.usage(authority_id : String, from : Time, to : Time) : Array(UsageRow)
def self.usage(authority_id : String, from : Time, to : Time, kind : Kind | Array(Kind) | Nil = nil) : Array(UsageRow)
rows = [] of UsageRow
filter, labels = kind_filter(kind, 4)
args = [authority_id, from, to] of ::PgORM::Value
args.concat(labels)
::PgORM::Database.connection do |db|
db.query(<<-SQL, authority_id, from, to) do |rs|
db.query(<<-SQL, args: args) do |rs|
SELECT
LOWER(kind) AS kind,
COALESCE(provider_type, 'unknown') AS provider,
COALESCE(model, 'unknown') AS model,
COUNT(*)::bigint AS jobs,
COALESCE(SUM(candidates), 0)::bigint AS candidates,
COALESCE(SUM(images_produced), 0)::bigint AS images_produced,
COALESCE(SUM(cost_units), 0)::double precision AS cost_units
FROM signage_ai_jobs
WHERE authority_id = $1 AND created_at >= $2 AND created_at < $3
GROUP BY 1, 2
ORDER BY 1, 2
WHERE authority_id = $1 AND created_at >= $2 AND created_at < $3 #{filter}
GROUP BY 1, 2, 3
ORDER BY 1, 2, 3
SQL
rs.each do
rows << UsageRow.new(
kind: rs.read(String),
provider: rs.read(String),
model: rs.read(String),
jobs: rs.read(Int64),
Expand Down
12 changes: 10 additions & 2 deletions src/placeos-models/signage_ai_provider.cr
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ require "./authority"
require "./utilities/encryption"

module PlaceOS::Model
# Vendor credentials used to generate or edit signage artwork.
# Vendor credentials used to generate or edit signage artwork and to edit maps.
#
# A row with a `nil` authority_id is the shared fallback, the same arrangement
# `Storage` uses. Credentials are a JSON object whose shape depends on the
Expand All @@ -26,11 +26,16 @@ module PlaceOS::Model
OPENAI
AZURE_OPENAI
GOOGLE_VERTEX
ANTHROPIC

def google?
self == GOOGLE_VERTEX
end

def anthropic?
self == ANTHROPIC
end

# OpenAI and Azure OpenAI speak the same request and response shape
def openai_compatible?
self == OPENAI || self == AZURE_OPENAI
Expand All @@ -51,12 +56,14 @@ module PlaceOS::Model
attribute endpoint : String? = nil, sanitize: :text
attribute location : String? = nil, sanitize: :text
attribute default_model : String? = nil, sanitize: :text
# the text/vision model used for map editing, nil for the adapter's default
attribute text_model : String? = nil, sanitize: :text
attribute allowed_models : Array(String) = [] of String, es_type: "keyword"

attribute enabled : Bool = true
attribute is_default : Bool = false

# {"user_per_day": 60, "domain_per_month": 2000}
# {"user_per_day": 60, "domain_per_month": 2000, "map_user_per_day": 40, "map_domain_per_month": 1000}
attribute quotas : JSON::Any = JSON::Any.new({} of String => JSON::Any), es_ignore: true

validates :name, presence: true
Expand Down Expand Up @@ -118,6 +125,7 @@ module PlaceOS::Model
endpoint: endpoint,
location: location,
default_model: default_model,
text_model: text_model,
allowed_models: allowed_models,
enabled: enabled,
is_default: is_default,
Expand Down
Loading