Skip to content

Commit bcd26ff

Browse files
committed
[owl] Add image output columns plus image‑gen usage/billing & image token quotas (#872)
1) Gen Table image output columns New ImageGenConfig enables image outputs for Gen Tables. Each output cell stores exactly one image URI (e.g. s3://...). Supports image_gen (text → image) and image_edit (image(s) + text → image). Image generation itself is non‑streaming; SSE emits one chunk when the URI is ready. 2) Model types & LM routing Added ModelType.IMAGE_GEN and ModelCapability.IMAGE_OUT. Capability rules: image_gen requires IMAGE_OUT image_edit requires IMAGE_OUT + IMAGE LM routing supports: Standard image endpoints (aimage_generation / aimage_edit) Gemini preview allowlist routed via acompletion(..., modalities=["image","text"]) to obtain correct usage 3) Usage & billing telemetry (image‑gen) New ClickHouse table image_gen_usage for image‑gen token/cost breakdown: text_input_token, text_output_token, image_input_token, image_output_token Cost aggregation updates: CostTable union now includes image‑gen cost categories: image_text_input / image_text_output / image_image_input / image_image_output LLM usage tables remain pure; image‑gen does not write to llm_usage. 4) Image token metrics & meters Image token usage recorded to VictoriaMetrics via image_token_usage counter. Metrics query path added via type=image and metricId=image in meters APIs. 5) Quota enforcement + org schema Org fields: image_tokens_quota_mtok image_tokens_usage_mtok Enforced in cloud (ELLM only; BYOK exempt): Image tokens consume image quota Image‑gen text tokens still consume LLM quota API/Behavior Notes Image breakdown fields (text_tokens, image_tokens) appear only when set.
1 parent 768e76a commit bcd26ff

File tree

25 files changed

+2533
-184
lines changed

25 files changed

+2533
-184
lines changed

clients/python/src/jamaibase/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3386,7 +3386,7 @@ class _MeterClientAsync(_ClientAsync):
33863386

33873387
async def get_usage_metrics(
33883388
self,
3389-
type: Literal["llm", "embedding", "reranking"],
3389+
type: Literal["llm", "embedding", "reranking", "image"],
33903390
from_: datetime,
33913391
window_size: str,
33923392
org_ids: list[str] | None = None,

clients/python/src/jamaibase/types/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
EgressUsageData,
1010
EmbedUsageData,
1111
FileStorageUsageData,
12+
ImageGenUsageData,
1213
LlmUsageData,
1314
RerankUsageData,
1415
UsageData,
@@ -155,6 +156,7 @@
155156
DiscriminatedGenConfig,
156157
EmbedGenConfig,
157158
GenConfigUpdateRequest,
159+
ImageGenConfig,
158160
KnowledgeTableSchemaCreate,
159161
LLMGenConfig,
160162
MultiRowAddRequest,

clients/python/src/jamaibase/types/billing.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,36 @@ class LlmUsageData(_BaseUsageData):
5050
)
5151

5252

53+
class ImageGenUsageData(_BaseUsageData):
54+
model: str = Field(
55+
description="Model used.",
56+
)
57+
text_input_token: int = Field(
58+
description="Number of text input tokens used.",
59+
)
60+
text_output_token: int = Field(
61+
description="Number of text output tokens used.",
62+
)
63+
image_input_token: int = Field(
64+
description="Number of image input tokens used.",
65+
)
66+
image_output_token: int = Field(
67+
description="Number of image output tokens used.",
68+
)
69+
text_input_cost: float = Field(
70+
description="Cost in USD per million text input tokens.",
71+
)
72+
text_output_cost: float = Field(
73+
description="Cost in USD per million text output tokens.",
74+
)
75+
image_input_cost: float = Field(
76+
description="Cost in USD per million image input tokens.",
77+
)
78+
image_output_cost: float = Field(
79+
description="Cost in USD per million image output tokens.",
80+
)
81+
82+
5383
class EmbedUsageData(_BaseUsageData):
5484
model: str = Field(
5585
description="Model used.",
@@ -94,6 +124,7 @@ class DBStorageUsageData(_BaseUsageData):
94124

95125
class UsageData(BaseModel):
96126
llm_usage: list[LlmUsageData] = []
127+
image_gen_usage: list[ImageGenUsageData] = []
97128
embed_usage: list[EmbedUsageData] = []
98129
rerank_usage: list[RerankUsageData] = []
99130
egress_usage: list[EgressUsageData] = []
@@ -105,6 +136,7 @@ def as_list_by_type(self) -> dict[str, list[list]]:
105136
"""Returns a dictionary of lists, where each key is a usage type and the value is a list of lists."""
106137
return {
107138
"llm_usage": [usage.as_list() for usage in self.llm_usage],
139+
"image_gen_usage": [usage.as_list() for usage in self.image_gen_usage],
108140
"embed_usage": [usage.as_list() for usage in self.embed_usage],
109141
"rerank_usage": [usage.as_list() for usage in self.rerank_usage],
110142
"egress_usage": [usage.as_list() for usage in self.egress_usage],
@@ -117,6 +149,7 @@ def total_usage_events(self) -> int:
117149
"""Returns the total number of usage events across all types."""
118150
return (
119151
len(self.llm_usage)
152+
+ len(self.image_gen_usage)
120153
+ len(self.embed_usage)
121154
+ len(self.rerank_usage)
122155
+ len(self.egress_usage)
@@ -128,6 +161,7 @@ def __add__(self, other: "UsageData") -> "UsageData":
128161
"""Overload the + operator to combine two UsageData objects."""
129162
combined = UsageData()
130163
combined.llm_usage = self.llm_usage + other.llm_usage
164+
combined.image_gen_usage = self.image_gen_usage + other.image_gen_usage
131165
combined.embed_usage = self.embed_usage + other.embed_usage
132166
combined.rerank_usage = self.rerank_usage + other.rerank_usage
133167
combined.egress_usage = self.egress_usage + other.egress_usage

clients/python/src/jamaibase/types/db.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,9 @@ class Products(BaseModel):
168168
llm_tokens: Product = Field(
169169
description="LLM token quota to this plan or tier.",
170170
)
171+
image_tokens: Product = Field(
172+
description="Image token quota to this plan or tier.",
173+
)
171174
embedding_tokens: Product = Field(
172175
description="Embedding token quota to this plan or tier.",
173176
)
@@ -188,6 +191,7 @@ class Products(BaseModel):
188191
def null(cls):
189192
return cls(
190193
llm_tokens=Product.null("ELLM tokens", "Million Tokens"),
194+
image_tokens=Product.null("Image tokens", "Million Tokens"),
191195
embedding_tokens=Product.null("Embedding tokens", "Million Tokens"),
192196
reranker_searches=Product.null("Reranker searches", "Thousand Searches"),
193197
db_storage=Product.null("Database storage", "GiB"),
@@ -199,6 +203,7 @@ def null(cls):
199203
def unlimited(cls, unit_cost: float = 0.0):
200204
return cls(
201205
llm_tokens=Product.unlimited("ELLM tokens", "Million Tokens", unit_cost),
206+
image_tokens=Product.unlimited("Image tokens", "Million Tokens", unit_cost),
202207
embedding_tokens=Product.unlimited("Embedding tokens", "Million Tokens", unit_cost),
203208
reranker_searches=Product.unlimited(
204209
"Reranker searches", "Thousand Searches", unit_cost
@@ -213,6 +218,7 @@ def unlimited(cls, unit_cost: float = 0.0):
213218
credit=("credit",),
214219
credit_grant=("credit_grant",),
215220
llm_tokens=("llm_tokens_quota_mtok", "llm_tokens_usage_mtok"),
221+
image_tokens=("image_tokens_quota_mtok", "image_tokens_usage_mtok"),
216222
embedding_tokens=(
217223
"embedding_tokens_quota_mtok",
218224
"embedding_tokens_usage_mtok",
@@ -228,6 +234,7 @@ class ProductType(StrEnum):
228234
CREDIT = "credit"
229235
CREDIT_GRANT = "credit_grant"
230236
LLM_TOKENS = "llm_tokens"
237+
IMAGE_TOKENS = "image_tokens"
231238
EMBEDDING_TOKENS = "embedding_tokens"
232239
RERANKER_SEARCHES = "reranker_searches"
233240
DB_STORAGE = "db_storage"
@@ -308,6 +315,12 @@ def free(
308315
tiers=[],
309316
unit="Million Tokens",
310317
),
318+
image_tokens=Product(
319+
name="Image tokens",
320+
included=PriceTier(unit_cost=0.5, up_to=0.75),
321+
tiers=[],
322+
unit="Million Tokens",
323+
),
311324
embedding_tokens=Product(
312325
name="Embedding tokens",
313326
included=PriceTier(unit_cost=0.5, up_to=0.75),
@@ -514,6 +527,7 @@ def status(self) -> str:
514527
class ModelType(StrEnum):
515528
COMPLETION = "completion"
516529
LLM = "llm"
530+
IMAGE_GEN = "image_gen"
517531
EMBED = "embed"
518532
RERANK = "rerank"
519533

@@ -527,6 +541,7 @@ class ModelCapability(StrEnum):
527541
CHAT = "chat"
528542
TOOL = "tool"
529543
IMAGE = "image" # TODO: Maybe change to "image_in" & "image_out"
544+
IMAGE_OUT = "image_out"
530545
AUDIO = "audio"
531546
EMBED = "embed"
532547
RERANK = "rerank"
@@ -548,7 +563,7 @@ class ModelInfo(_BaseModel):
548563
)
549564
type: _ModelType = Field(
550565
"",
551-
description="Model type. Can be completion, llm, embed, or rerank.",
566+
description="Model type. Can be completion, llm, image_gen, embed, or rerank.",
552567
examples=[ModelType.LLM],
553568
)
554569
name: SanitisedNonEmptyStr = Field(
@@ -645,6 +660,15 @@ class ModelConfigUpdate(ModelInfo):
645660
-1.0,
646661
description="Cost in USD per million (mega) output / completion token.",
647662
)
663+
# --- Image generation models --- #
664+
image_input_cost_per_mtoken: float = Field(
665+
-1.0,
666+
description="Cost in USD per million (mega) image input tokens.",
667+
)
668+
image_output_cost_per_mtoken: float = Field(
669+
-1.0,
670+
description="Cost in USD per million (mega) image output tokens.",
671+
)
648672
# --- Embedding models --- #
649673
embedding_size: PositiveNonZeroInt | None = Field(
650674
None,
@@ -703,6 +727,14 @@ def check_chat_cost_per_mtoken(self) -> Self:
703727
self.llm_output_cost_per_mtoken = 0.600
704728
return self
705729

730+
@model_validator(mode="after")
731+
def check_image_cost_per_mtoken(self) -> Self:
732+
if self.image_input_cost_per_mtoken < 0:
733+
self.image_input_cost_per_mtoken = 0.0
734+
if self.image_output_cost_per_mtoken < 0:
735+
self.image_output_cost_per_mtoken = 0.0
736+
return self
737+
706738
@model_validator(mode="after")
707739
def check_embed_cost_per_mtoken(self) -> Self:
708740
# OpenAI text-embedding-3-small pricing (2024-09-09)
@@ -729,7 +761,7 @@ class ModelConfigCreate(ModelConfigUpdate):
729761
),
730762
)
731763
type: _ModelType = Field(
732-
description="Model type. Can be completion, llm, embed, or rerank.",
764+
description="Model type. Can be completion, llm, image_gen, embed, or rerank.",
733765
)
734766
name: SanitisedNonEmptyStr = Field(
735767
max_length=255,
@@ -1087,6 +1119,12 @@ class Organization_(OrganizationCreate, _TableBase):
10871119
llm_tokens_usage_mtok: float = Field(
10881120
description="LLM token usage in millions of tokens.",
10891121
)
1122+
image_tokens_quota_mtok: float | None = Field(
1123+
description="Image token quota in millions of tokens.",
1124+
)
1125+
image_tokens_usage_mtok: float = Field(
1126+
description="Image token usage in millions of tokens.",
1127+
)
10901128
embedding_tokens_quota_mtok: float | None = Field(
10911129
description="Embedding token quota in millions of tokens.",
10921130
)

clients/python/src/jamaibase/types/gen_table.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
ChatRequestBase,
2626
References,
2727
)
28+
from jamaibase.utils.exceptions import BadInputError
2829
from jamaibase.utils.types import StrEnum
2930

3031

@@ -130,6 +131,34 @@ def compat(cls, data: dict[str, Any] | BaseModel) -> dict[str, Any]:
130131
return data
131132

132133

134+
class ImageGenConfig(BaseModel):
135+
object: Literal["gen_config.image"] = Field(
136+
"gen_config.image",
137+
description='The object type, which is always "gen_config.image".',
138+
examples=["gen_config.image"],
139+
)
140+
model: str = Field(
141+
"",
142+
description='ID of the model to use. Defaults to "".',
143+
)
144+
prompt: str = Field(
145+
"",
146+
description="Prompt for the image generation/edit model.",
147+
)
148+
size: Literal["auto", "1024x1024", "1536x1024", "1024x1536"] | None = Field(
149+
None,
150+
description="Image size/aspect ratio hint. Defaults to None (provider default).",
151+
)
152+
quality: Literal["low", "medium", "high", "auto"] | None = Field(
153+
None,
154+
description="Image quality hint. Defaults to None (provider default).",
155+
)
156+
style: str | None = Field(
157+
None,
158+
description="Image style hint. Generation-only; ignored for edits.",
159+
)
160+
161+
133162
class EmbedGenConfig(BaseModel):
134163
object: Literal["gen_config.embed"] = Field(
135164
"gen_config.embed",
@@ -179,6 +208,8 @@ def _gen_config_discriminator(x: Any) -> str | None:
179208
if isinstance(x, dict):
180209
if "object" in x:
181210
return x["object"]
211+
if any(k in x for k in ("size", "quality", "style")):
212+
raise BadInputError('ImageGenConfig requires explicit `object="gen_config.image"`.')
182213
if "embedding_model" in x:
183214
return "gen_config.embed"
184215
if "source_column" in x:
@@ -196,6 +227,7 @@ def _gen_config_discriminator(x: Any) -> str | None:
196227
Annotated[PythonGenConfig, Tag("gen_config.python")],
197228
Annotated[LLMGenConfig, Tag("gen_config.llm")],
198229
Annotated[LLMGenConfig, Tag("gen_config.chat")],
230+
Annotated[ImageGenConfig, Tag("gen_config.image")],
199231
Annotated[EmbedGenConfig, Tag("gen_config.embed")],
200232
],
201233
Discriminator(_gen_config_discriminator),

clients/python/src/jamaibase/types/lm.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
ConfigDict,
1010
Field,
1111
field_validator,
12+
model_serializer,
1213
model_validator,
1314
)
1415

@@ -376,6 +377,14 @@ def _none_to_zero(v: int | None) -> int:
376377

377378

378379
class PromptUsageDetails(BaseModel):
380+
text_tokens: int | None = Field(
381+
None,
382+
description="Text tokens present in the prompt.",
383+
)
384+
image_tokens: int | None = Field(
385+
None,
386+
description="Image tokens present in the prompt.",
387+
)
379388
cached_tokens: ZeroIfNoneInt = Field(
380389
0,
381390
description="Cached tokens present in the prompt.",
@@ -385,8 +394,25 @@ class PromptUsageDetails(BaseModel):
385394
description="Audio input tokens present in the prompt or generated by the model.",
386395
)
387396

397+
@model_serializer(mode="wrap")
398+
def _omit_optional_breakdown(self, handler):
399+
data = handler(self)
400+
if data.get("text_tokens") is None:
401+
data.pop("text_tokens", None)
402+
if data.get("image_tokens") is None:
403+
data.pop("image_tokens", None)
404+
return data
405+
388406

389407
class CompletionUsageDetails(BaseModel):
408+
text_tokens: int | None = Field(
409+
None,
410+
description="Text tokens present in the completion.",
411+
)
412+
image_tokens: int | None = Field(
413+
None,
414+
description="Image tokens present in the completion.",
415+
)
390416
audio_tokens: ZeroIfNoneInt = Field(
391417
0,
392418
description="Audio input tokens present in the prompt or generated by the model.",
@@ -404,6 +430,15 @@ class CompletionUsageDetails(BaseModel):
404430
description="When using Predicted Outputs, the number of tokens in the prediction that did not appear in the completion.",
405431
)
406432

433+
@model_serializer(mode="wrap")
434+
def _omit_optional_breakdown(self, handler):
435+
data = handler(self)
436+
if data.get("text_tokens") is None:
437+
data.pop("text_tokens", None)
438+
if data.get("image_tokens") is None:
439+
data.pop("image_tokens", None)
440+
return data
441+
407442

408443
class ToolUsageDetails(BaseModel):
409444
web_search_calls: ZeroIfNoneInt = Field(

docker/ch_configs/create_ch_prom_db.sh

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,28 @@ ENGINE=MergeTree
1919
PARTITION BY toYYYYMM(timestamp)
2020
ORDER BY (org_id, timestamp, model)"
2121

22+
clickhouse-client --query="CREATE TABLE IF NOT EXISTS jamaibase_owl.image_gen_usage
23+
(
24+
\`id\` UUID,
25+
\`org_id\` String,
26+
\`proj_id\` String,
27+
\`user_id\` String,
28+
\`timestamp\` DateTime64(6, 'UTC'),
29+
\`model\` String,
30+
\`text_input_token\` UInt32,
31+
\`text_output_token\` UInt32,
32+
\`image_input_token\` UInt32,
33+
\`image_output_token\` UInt32,
34+
\`text_input_cost\` Decimal128(12),
35+
\`text_output_cost\` Decimal128(12),
36+
\`image_input_cost\` Decimal128(12),
37+
\`image_output_cost\` Decimal128(12),
38+
\`cost\` Decimal128(12)
39+
)
40+
ENGINE=MergeTree
41+
PARTITION BY toYYYYMM(timestamp)
42+
ORDER BY (org_id, timestamp, model)"
43+
2244
clickhouse-client --query="CREATE TABLE IF NOT EXISTS jamaibase_owl.embed_usage
2345
(
2446
\`id\` UUID,
@@ -179,4 +201,4 @@ clickhouse-client --query="ALTER TABLE jamaibase_owl.llm_usage MODIFY COLUMN out
179201
clickhouse-client --query="ALTER TABLE jamaibase_owl.embed_usage MODIFY COLUMN cost Decimal128(12)"
180202
clickhouse-client --query="ALTER TABLE jamaibase_owl.rerank_usage MODIFY COLUMN cost Decimal128(12)"
181203
clickhouse-client --query="ALTER TABLE jamaibase_owl.egress_usage MODIFY COLUMN cost Decimal128(12)"
182-
clickhouse-client --query="ALTER TABLE jamaibase_owl.egress_usage MODIFY COLUMN amount_gib Decimal128(12)"
204+
clickhouse-client --query="ALTER TABLE jamaibase_owl.egress_usage MODIFY COLUMN amount_gib Decimal128(12)"

0 commit comments

Comments
 (0)