From b702da28d0ea467f77005114b28ae2945b36b1d4 Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Mon, 12 Oct 2026 19:05:54 +1100 Subject: [PATCH 1/5] feat(signage-ai): text model, MAP_EDIT job kind, kind-aware sums PPT-2833. The signage AI provider rows will also serve AI map editing in Concierge, which calls a text/vision model rather than an image model, so a row gains a nullable text_model column (nil means the adapter's default). Map edits reuse the job table as a third kind, MAP_EDIT, which the kind CHECK now admits; one vendor call per job, so the quota unit stays "candidates". sum_candidates and sum_candidates_for_authority take an optional kind so the map quotas are counted apart from the image quotas; usage is grouped by kind as well and UsageRow carries it, with an optional kind filter for the map usage route. An index on (kind, created_at) serves both. Migration 20261014100500000, numbered above the newest merged migration (20261013100500000); renumber before merging if another lands first. Co-Authored-By: Claude Fable 5.1 --- ...014100500000_alter_signage_ai_for_maps.sql | 26 ++++++ spec/generator.cr | 13 ++- spec/signage_ai_job_spec.cr | 51 ++++++++++ spec/signage_ai_provider_spec.cr | 11 +++ src/placeos-models/signage_ai_job.cr | 92 ++++++++++++++----- src/placeos-models/signage_ai_provider.cr | 7 +- 6 files changed, 171 insertions(+), 29 deletions(-) create mode 100644 migration/db/migrations/20261014100500000_alter_signage_ai_for_maps.sql diff --git a/migration/db/migrations/20261014100500000_alter_signage_ai_for_maps.sql b/migration/db/migrations/20261014100500000_alter_signage_ai_for_maps.sql new file mode 100644 index 00000000..77a62ee9 --- /dev/null +++ b/migration/db/migrations/20261014100500000_alter_signage_ai_for_maps.sql @@ -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; diff --git a/spec/generator.cr b/spec/generator.cr index 1671c559..0bea5719 100644 --- a/spec/generator.cr +++ b/spec/generator.cr @@ -751,6 +751,7 @@ 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 @@ -758,6 +759,7 @@ module PlaceOS::Model 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) @@ -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 diff --git a/spec/signage_ai_job_spec.cr b/spec/signage_ai_job_spec.cr index 188f16f1..f5f16a2a 100644 --- a/spec/signage_ai_job_spec.cr +++ b/spec/signage_ai_job_spec.cr @@ -69,6 +69,36 @@ 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 + 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! @@ -108,6 +138,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 @@ -116,6 +147,26 @@ 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") + 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 } diff --git a/spec/signage_ai_provider_spec.cr b/spec/signage_ai_provider_spec.cr index 13460a91..88a24e96 100644 --- a/spec/signage_ai_provider_spec.cr +++ b/spec/signage_ai_provider_spec.cr @@ -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 diff --git a/src/placeos-models/signage_ai_job.cr b/src/placeos-models/signage_ai_job.cr index 91c14851..b793d457 100644 --- a/src/placeos-models/signage_ai_job.cr +++ b/src/placeos-models/signage_ai_job.cr @@ -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). @@ -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 @@ -217,29 +228,54 @@ module PlaceOS::Model # 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? = nil) : Int32 ::PgORM::Database.connection do |db| - db.query_one(<<-SQL, user_id, since, as: Int64) - SELECT COALESCE(SUM(candidates), 0)::bigint - FROM signage_ai_jobs - WHERE user_id = $1 - AND created_at >= $2 - SQL + if kind + db.query_one(<<-SQL, user_id, since, kind_label(kind), as: Int64) + SELECT COALESCE(SUM(candidates), 0)::bigint + FROM signage_ai_jobs + WHERE user_id = $1 + AND created_at >= $2 + AND kind = $3 + SQL + else + db.query_one(<<-SQL, user_id, since, as: Int64) + SELECT COALESCE(SUM(candidates), 0)::bigint + FROM signage_ai_jobs + WHERE user_id = $1 + AND created_at >= $2 + SQL + end 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? = nil) : Int32 ::PgORM::Database.connection do |db| - db.query_one(<<-SQL, authority_id, since, as: Int64) - SELECT COALESCE(SUM(candidates), 0)::bigint - FROM signage_ai_jobs - WHERE authority_id = $1 - AND created_at >= $2 - SQL + if kind + db.query_one(<<-SQL, authority_id, since, kind_label(kind), as: Int64) + SELECT COALESCE(SUM(candidates), 0)::bigint + FROM signage_ai_jobs + WHERE authority_id = $1 + AND created_at >= $2 + AND kind = $3 + SQL + else + db.query_one(<<-SQL, authority_id, since, as: Int64) + SELECT COALESCE(SUM(candidates), 0)::bigint + FROM signage_ai_jobs + WHERE authority_id = $1 + AND created_at >= $2 + SQL + end 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 + # Housekeeping, run as each new job is created. Does nothing unless an # operator has set a retention window. # @@ -313,17 +349,24 @@ 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? = nil) : Array(UsageRow) rows = [] of UsageRow + args = [authority_id, from, to] of ::PgORM::Value + filter = "" + if kind + args << kind_label(kind) + filter = "AND kind = $4" + end ::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, @@ -331,12 +374,13 @@ module PlaceOS::Model 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), diff --git a/src/placeos-models/signage_ai_provider.cr b/src/placeos-models/signage_ai_provider.cr index b6a06f6d..dcae5915 100644 --- a/src/placeos-models/signage_ai_provider.cr +++ b/src/placeos-models/signage_ai_provider.cr @@ -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 @@ -51,12 +51,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 @@ -118,6 +120,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, From 84e8c7591e89920269ff3f03f16014c27bde250a Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Mon, 12 Oct 2026 20:23:44 +1100 Subject: [PATCH 2/5] feat(signage-ai): sum candidates and usage by a list of kinds `sum_candidates`, `sum_candidates_for_authority` and `usage` take a kind, a list of kinds or nil. The image quota in rest-api asks for both image kinds at once, which a single kind could not express without two queries. The IN clause is built with one placeholder per label; nil still sums every kind, so the existing call sites are unchanged. Co-Authored-By: Claude Fable 5.1 --- spec/signage_ai_job_spec.cr | 9 ++++ src/placeos-models/signage_ai_job.cr | 80 ++++++++++++++-------------- 2 files changed, 49 insertions(+), 40 deletions(-) diff --git a/spec/signage_ai_job_spec.cr b/spec/signage_ai_job_spec.cr index f5f16a2a..6fac15d8 100644 --- a/spec/signage_ai_job_spec.cr +++ b/spec/signage_ai_job_spec.cr @@ -82,6 +82,12 @@ module PlaceOS::Model 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 @@ -165,6 +171,9 @@ module PlaceOS::Model 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 diff --git a/src/placeos-models/signage_ai_job.cr b/src/placeos-models/signage_ai_job.cr index b793d457..2b7dad42 100644 --- a/src/placeos-models/signage_ai_job.cr +++ b/src/placeos-models/signage_ai_job.cr @@ -224,50 +224,39 @@ 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, kind : Kind? = nil) : 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| - if kind - db.query_one(<<-SQL, user_id, since, kind_label(kind), as: Int64) - SELECT COALESCE(SUM(candidates), 0)::bigint - FROM signage_ai_jobs - WHERE user_id = $1 - AND created_at >= $2 - AND kind = $3 - SQL - else - db.query_one(<<-SQL, user_id, since, as: Int64) - SELECT COALESCE(SUM(candidates), 0)::bigint - FROM signage_ai_jobs - WHERE user_id = $1 - AND created_at >= $2 - SQL - end + 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, kind : Kind? = nil) : 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| - if kind - db.query_one(<<-SQL, authority_id, since, kind_label(kind), as: Int64) - SELECT COALESCE(SUM(candidates), 0)::bigint - FROM signage_ai_jobs - WHERE authority_id = $1 - AND created_at >= $2 - AND kind = $3 - SQL - else - db.query_one(<<-SQL, authority_id, since, as: Int64) - SELECT COALESCE(SUM(candidates), 0)::bigint - FROM signage_ai_jobs - WHERE authority_id = $1 - AND created_at >= $2 - SQL - end + 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 @@ -276,6 +265,20 @@ module PlaceOS::Model 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. # @@ -355,14 +358,11 @@ module PlaceOS::Model include JSON::Serializable end - def self.usage(authority_id : String, from : Time, to : Time, kind : Kind? = nil) : 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 - filter = "" - if kind - args << kind_label(kind) - filter = "AND kind = $4" - end + args.concat(labels) ::PgORM::Database.connection do |db| db.query(<<-SQL, args: args) do |rs| SELECT From 0c485715f64d30aed44a36e07bfa371f3c48600a Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Tue, 13 Oct 2026 16:15:40 +1100 Subject: [PATCH 3/5] feat(signage_ai_provider): ANTHROPIC provider Provider rows may point at Anthropic: the check constraint takes the new value and the enum answers anthropic?. Co-Authored-By: Claude Fable 5.1 --- ...00000_alter_signage_ai_providers_anthropic.sql | 15 +++++++++++++++ src/placeos-models/signage_ai_provider.cr | 5 +++++ 2 files changed, 20 insertions(+) create mode 100644 migration/db/migrations/20261015100500000_alter_signage_ai_providers_anthropic.sql diff --git a/migration/db/migrations/20261015100500000_alter_signage_ai_providers_anthropic.sql b/migration/db/migrations/20261015100500000_alter_signage_ai_providers_anthropic.sql new file mode 100644 index 00000000..790e3dcf --- /dev/null +++ b/migration/db/migrations/20261015100500000_alter_signage_ai_providers_anthropic.sql @@ -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')); diff --git a/src/placeos-models/signage_ai_provider.cr b/src/placeos-models/signage_ai_provider.cr index dcae5915..131f3357 100644 --- a/src/placeos-models/signage_ai_provider.cr +++ b/src/placeos-models/signage_ai_provider.cr @@ -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 From 6d3958b8a9ccb0c42b5fa7470b2adc117ed1756a Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Wed, 14 Oct 2026 00:59:28 +1100 Subject: [PATCH 4/5] test(signage-ai): use distinct names for default provider fixtures --- spec/signage_ai_provider_spec.cr | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/signage_ai_provider_spec.cr b/spec/signage_ai_provider_spec.cr index 88a24e96..e038894b 100644 --- a/spec/signage_ai_provider_spec.cr +++ b/spec/signage_ai_provider_spec.cr @@ -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 From 16b84053d92745372fb746b768c5dc0cd7527d72 Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Fri, 16 Oct 2026 00:08:54 +1100 Subject: [PATCH 5/5] fix(migrations): give the AI map editing migrations unused versions 20261014100500000 was taken twice: by alter_signage_ai_for_maps on this branch and by master's alter_groups_allow_multiple_roots. micrate records migrations by version, so whichever ran first stopped the other, and with master merged the multiple-roots migration was skipped and the group specs failed. The two AI map editing migrations move to 20261016100500000 and 20261016110500000. Both are idempotent, so a database that already ran them under the old versions is unaffected. Co-Authored-By: Claude Opus 5.5 --- ...r_maps.sql => 20261016100500000_alter_signage_ai_for_maps.sql} | 0 ...=> 20261016110500000_alter_signage_ai_providers_anthropic.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename migration/db/migrations/{20261014100500000_alter_signage_ai_for_maps.sql => 20261016100500000_alter_signage_ai_for_maps.sql} (100%) rename migration/db/migrations/{20261015100500000_alter_signage_ai_providers_anthropic.sql => 20261016110500000_alter_signage_ai_providers_anthropic.sql} (100%) diff --git a/migration/db/migrations/20261014100500000_alter_signage_ai_for_maps.sql b/migration/db/migrations/20261016100500000_alter_signage_ai_for_maps.sql similarity index 100% rename from migration/db/migrations/20261014100500000_alter_signage_ai_for_maps.sql rename to migration/db/migrations/20261016100500000_alter_signage_ai_for_maps.sql diff --git a/migration/db/migrations/20261015100500000_alter_signage_ai_providers_anthropic.sql b/migration/db/migrations/20261016110500000_alter_signage_ai_providers_anthropic.sql similarity index 100% rename from migration/db/migrations/20261015100500000_alter_signage_ai_providers_anthropic.sql rename to migration/db/migrations/20261016110500000_alter_signage_ai_providers_anthropic.sql