Skip to content
Open
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
2 changes: 2 additions & 0 deletions apps/manager-server/internal/model/model_price.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ type ModelPrice struct {
CacheCreationConfigured bool `json:"cacheCreationConfigured,omitempty"`
Source string `json:"source,omitempty"`
SourceModelID string `json:"sourceModelId,omitempty"`
BillingUnit string `json:"billingUnit,omitempty"`
BillingRate string `json:"billingRate,omitempty"`
RawJSON string `json:"rawJson,omitempty"`
ContextTiers []ModelPriceContextTier `json:"contextTiers,omitempty"`
ServiceTiers []ModelPriceServiceTier `json:"serviceTiers,omitempty"`
Expand Down
22 changes: 16 additions & 6 deletions apps/manager-server/internal/repository/modelprice/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func (r *repository) LoadAll(ctx context.Context) (map[string]model.ModelPrice,
func (r *repository) LoadAllTx(ctx context.Context, tx *sql.Tx) (map[string]model.ModelPrice, error) {
rows, err := tx.QueryContext(ctx, `select
model, prompt_per_1m, completion_per_1m, cache_per_1m, cache_read_per_1m, cache_creation_per_1m,
prompt_configured, completion_configured, cache_read_configured, cache_creation_configured, source, source_model_id, raw_json,
prompt_configured, completion_configured, cache_read_configured, cache_creation_configured, source, source_model_id, billing_unit, billing_rate, raw_json,
updated_at_ms, synced_at_ms
from model_prices order by model`)
if err != nil {
Expand All @@ -53,7 +53,7 @@ func (r *repository) LoadAllTx(ctx context.Context, tx *sql.Tx) (map[string]mode
for rows.Next() {
var modelID string
var price model.ModelPrice
var source, sourceModelID, rawJSON sql.NullString
var source, sourceModelID, billingUnit, billingRate, rawJSON sql.NullString
var syncedAt sql.NullInt64
var promptConfigured, completionConfigured, cacheReadConfigured, cacheCreationConfigured int
if err := rows.Scan(
Expand All @@ -69,6 +69,8 @@ func (r *repository) LoadAllTx(ctx context.Context, tx *sql.Tx) (map[string]mode
&cacheCreationConfigured,
&source,
&sourceModelID,
&billingUnit,
&billingRate,
&rawJSON,
&price.UpdatedAtMS,
&syncedAt,
Expand All @@ -81,6 +83,8 @@ func (r *repository) LoadAllTx(ctx context.Context, tx *sql.Tx) (map[string]mode
price.CacheReadConfigured = cacheReadConfigured != 0
price.CacheCreationConfigured = cacheCreationConfigured != 0
price.SourceModelID = sourceModelID.String
price.BillingUnit = billingUnit.String
price.BillingRate = billingRate.String
price.RawJSON = rawJSON.String
if syncedAt.Valid {
value := syncedAt.Int64
Expand Down Expand Up @@ -234,8 +238,8 @@ func (r *repository) ReplaceAll(ctx context.Context, prices map[string]model.Mod
stmt, err := tx.PrepareContext(ctx, `insert into model_prices (
model, prompt_per_1m, completion_per_1m, cache_per_1m, cache_read_per_1m, cache_creation_per_1m,
prompt_configured, completion_configured, cache_read_configured, cache_creation_configured, source, source_model_id,
raw_json, updated_at_ms, synced_at_ms
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
billing_unit, billing_rate, raw_json, updated_at_ms, synced_at_ms
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
Expand Down Expand Up @@ -267,6 +271,8 @@ func (r *repository) ReplaceAll(ctx context.Context, prices map[string]model.Mod
price.CacheCreationConfigured,
nullString(price.Source),
nullString(price.SourceModelID),
nullString(price.BillingUnit),
nullString(price.BillingRate),
nullString(price.RawJSON),
now,
nullInt(price.SyncedAtMS),
Expand Down Expand Up @@ -298,8 +304,8 @@ func (r *repository) UpsertSynced(ctx context.Context, prices map[string]model.M
stmt, err := tx.PrepareContext(ctx, `insert into model_prices (
model, prompt_per_1m, completion_per_1m, cache_per_1m, cache_read_per_1m, cache_creation_per_1m,
prompt_configured, completion_configured, cache_read_configured, cache_creation_configured, source, source_model_id,
raw_json, updated_at_ms, synced_at_ms
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
billing_unit, billing_rate, raw_json, updated_at_ms, synced_at_ms
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
on conflict(model) do update set
prompt_per_1m = excluded.prompt_per_1m,
completion_per_1m = excluded.completion_per_1m,
Expand All @@ -312,6 +318,8 @@ func (r *repository) UpsertSynced(ctx context.Context, prices map[string]model.M
cache_creation_configured = excluded.cache_creation_configured,
source = excluded.source,
source_model_id = excluded.source_model_id,
billing_unit = excluded.billing_unit,
billing_rate = excluded.billing_rate,
raw_json = excluded.raw_json,
updated_at_ms = excluded.updated_at_ms,
synced_at_ms = excluded.synced_at_ms`)
Expand Down Expand Up @@ -379,6 +387,8 @@ func (r *repository) UpsertSynced(ctx context.Context, prices map[string]model.M
price.CacheCreationConfigured,
nullString(price.Source),
nullString(price.SourceModelID),
nullString(price.BillingUnit),
nullString(price.BillingRate),
nullString(price.RawJSON),
now,
now,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package modelprice

import (
"context"
"path/filepath"
"testing"

"github.com/seakee/cpa-manager-plus/apps/manager-server/internal/model"
"github.com/seakee/cpa-manager-plus/apps/manager-server/internal/repository/sqlite"
)

func TestRepositoryRoundTripsBillingMetadata(t *testing.T) {
db, err := sqlite.Open(filepath.Join(t.TempDir(), "model-prices.sqlite"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })

repository := New(db)
ctx := context.Background()
prices := map[string]model.ModelPrice{
"grok-imagine-image": {
Prompt: 0,
Completion: 0,
Cache: 0,
PromptConfigured: true,
CompletionConfigured: true,
Source: "xAI official",
SourceModelID: "$0.02/image",
BillingUnit: "image",
BillingRate: "$0.02/image",
},
}
if err := repository.ReplaceAll(ctx, prices); err != nil {
t.Fatalf("replace model prices: %v", err)
}

loaded, err := repository.LoadAll(ctx)
if err != nil {
t.Fatalf("load model prices: %v", err)
}
got, ok := loaded["grok-imagine-image"]
if !ok {
t.Fatalf("loaded prices missing grok-imagine-image")
}
if got.BillingUnit != "image" || got.BillingRate != "$0.02/image" {
t.Fatalf("billing metadata = %q / %q, want image / $0.02/image", got.BillingUnit, got.BillingRate)
}
if got.Source != "xAI official" || got.SourceModelID != "$0.02/image" {
t.Fatalf("source metadata = %q / %q, want xAI official / $0.02/image", got.Source, got.SourceModelID)
}
if got.PromptConfigured != true || got.CompletionConfigured != true {
t.Fatalf("configured flags = %t / %t, want true/true", got.PromptConfigured, got.CompletionConfigured)
}
}
4 changes: 4 additions & 0 deletions apps/manager-server/internal/repository/sqlite/migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,8 @@ func Migrate(db *sql.DB) error {
cache_creation_configured integer not null default 0,
source text,
source_model_id text,
billing_unit text,
billing_rate text,
raw_json text,
updated_at_ms integer not null,
synced_at_ms integer
Expand Down Expand Up @@ -2732,6 +2734,8 @@ func ensureModelPriceColumns(db *sql.DB) error {
{name: "completion_configured", definition: "integer not null default 0"},
{name: "cache_read_configured", definition: "integer not null default 0"},
{name: "cache_creation_configured", definition: "integer not null default 0"},
{name: "billing_unit", definition: "text"},
{name: "billing_rate", definition: "text"},
}
added := map[string]bool{}
for _, column := range columns {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2907,6 +2907,14 @@ func TestEnsureModelPriceColumnsPreservesLegacyZeroBasePrices(t *testing.T) {
if promptConfigured != 1 || completionConfigured != 1 || cacheReadConfigured != 0 || cacheCreationConfigured != 0 {
t.Fatalf("configured flags = %d/%d/%d/%d", promptConfigured, completionConfigured, cacheReadConfigured, cacheCreationConfigured)
}
var billingUnit, billingRate sql.NullString
if err := db.QueryRow(`select billing_unit, billing_rate
from model_prices where model = 'gpt-5.6-sol'`).Scan(&billingUnit, &billingRate); err != nil {
t.Fatalf("read migrated billing metadata: %v", err)
}
if billingUnit.Valid || billingRate.Valid {
t.Fatalf("billing metadata unexpectedly populated: %q / %q", billingUnit.String, billingRate.String)
}
}

func TestMigrateCreatesModelPriceServiceTierTableWithCascade(t *testing.T) {
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/features/monitoring/ModelPricesPage.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,13 @@
padding-right: 9px;
}

.billingFields {
display: grid;
grid-template-columns: repeat(2, minmax(140px, 1fr));
gap: 8px;
min-width: 0;
}

.compactEditorActions {
flex-wrap: nowrap;
justify-content: flex-end;
Expand Down Expand Up @@ -374,6 +381,11 @@
font: inherit;
}

.billingRate {
color: var(--pricing-accent-strong);
font-weight: 700;
}

.actionsCell {
width: 88px;
min-width: 88px;
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/features/monitoring/ModelPricesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,22 @@ export function ModelPricesPage() {
placeholder={t('model_prices.optional_price_placeholder')}
step="0.0001"
/>
<div className={styles.billingFields}>
<Input
label={t('model_prices.billing_unit')}
className={styles.compactInput}
value={draft.billingUnit}
onChange={(event) => setDraftField('billingUnit', event.target.value)}
placeholder="image"
/>
<Input
label={t('model_prices.billing_rate')}
className={styles.compactInput}
value={draft.billingRate}
onChange={(event) => setDraftField('billingRate', event.target.value)}
placeholder="$0.02/image"
/>
</div>
</div>
<div className={styles.compactEditorActions}>
<Button
Expand Down Expand Up @@ -494,6 +510,15 @@ export function ModelPricesPage() {
{row.price.sourceModelId ? (
<small>{row.price.sourceModelId}</small>
) : null}
{row.price.billingRate ? (
<small className={styles.billingRate}>
{row.price.billingRate}
</small>
) : row.price.billingUnit ? (
<small className={styles.billingRate}>
{row.price.billingUnit}
</small>
) : null}
</div>
) : selectedCandidate ? (
<div className={styles.sourceContent}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
buildModelPriceRows,
buildModelPriceSummary,
buildSyncPriceModelsFromSummary,
createPriceDraft,
filterModelPriceRows,
formatContextThreshold,
formatServiceTierRule,
Expand Down Expand Up @@ -117,6 +118,55 @@ describe('modelPricesPageModel', () => {
});
});

it('round-trips non-token billing metadata into a manual price draft', () => {
expect(
createPriceDraft('grok-imagine-image', {
prompt: 0,
completion: 0,
cache: 0,
cacheRead: 0,
cacheCreation: 0,
billingUnit: 'image',
billingRate: '$0.02/image',
})
).toMatchObject({
billingUnit: 'image',
billingRate: '$0.02/image',
});
});

it('preserves billing metadata and omits blank metadata from built prices', () => {
expect(
buildPriceFromDraft({
model: 'grok-imagine-video',
prompt: '0',
completion: '0',
cache: '',
cacheRead: '',
cacheCreation: '',
billingUnit: 'video second',
billingRate: '$0.05/s (480p) · $0.07/s (720p)',
})
).toMatchObject({
billingUnit: 'video second',
billingRate: '$0.05/s (480p) · $0.07/s (720p)',
});
const plain = buildPriceFromDraft({
model: 'gpt-5.6-sol',
prompt: '0',
completion: '0',
cache: '',
cacheRead: '',
cacheCreation: '',
billingUnit: ' ',
billingRate: '',
});
if (!plain) {
throw new Error('expected built price');
}
expect('billingUnit' in plain).toBe(false);
expect('billingRate' in plain).toBe(false);
});
it('keeps identical source model IDs distinct and groups candidates by source', () => {
const candidates = [
{
Expand Down Expand Up @@ -151,6 +201,8 @@ describe('modelPricesPageModel', () => {
cache: '',
cacheRead: '',
cacheCreation: '',
billingUnit: '',
billingRate: '',
})
).toMatchObject({
prompt: 1,
Expand All @@ -175,6 +227,8 @@ describe('modelPricesPageModel', () => {
cache: '',
cacheRead: '0',
cacheCreation: '0',
billingUnit: '',
billingRate: '',
})
).toMatchObject({
prompt: 0,
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/features/monitoring/model/modelPricesPageModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export type PriceDraft = {
cache: string;
cacheRead: string;
cacheCreation: string;
billingUnit: string;
billingRate: string;
};

export type ModelPriceRow = {
Expand Down Expand Up @@ -45,6 +47,8 @@ export const createEmptyPriceDraft = (): PriceDraft => ({
cache: '',
cacheRead: '',
cacheCreation: '',
billingUnit: '',
billingRate: '',
});

const createConfiguredDraftValue = (value: number | undefined, configured?: boolean): string =>
Expand All @@ -59,6 +63,8 @@ export const createPriceDraft = (model: string, price?: ModelPrice): PriceDraft
cacheCreation: price
? createConfiguredDraftValue(price.cacheCreation, price.cacheCreationConfigured)
: '',
billingUnit: price?.billingUnit ?? '',
billingRate: price?.billingRate ?? '',
});

export const parsePriceValue = (value: string) => {
Expand All @@ -72,6 +78,8 @@ export const buildPriceFromDraft = (draft: PriceDraft): ModelPrice | null => {
const prompt = parsePriceValue(draft.prompt);
const completion = parsePriceValue(draft.completion);
const cache = draft.cache.trim() === '' ? prompt : parsePriceValue(draft.cache);
const billingUnit = draft.billingUnit.trim();
const billingRate = draft.billingRate.trim();
return {
prompt,
completion,
Expand All @@ -83,6 +91,8 @@ export const buildPriceFromDraft = (draft: PriceDraft): ModelPrice | null => {
cacheReadConfigured: draft.cacheRead.trim() !== '',
cacheCreationConfigured: draft.cacheCreation.trim() !== '',
source: 'manual',
...(billingUnit ? { billingUnit } : {}),
...(billingRate ? { billingRate } : {}),
contextTiers: [],
serviceTiers: [],
};
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2125,6 +2125,8 @@
"filter_saved": "Saved",
"calls": "Calls",
"source": "Source",
"billing_unit": "Billing unit",
"billing_rate": "Billing rate",
"pricing_rules": "Pricing rules",
"manual_clears_pricing_rules": "Saving a manual price removes {{count}} synchronized context or service-tier rule(s).",
"needs_confirmation": "Candidate confirmation needed",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -2125,6 +2125,8 @@
"filter_saved": "Сохранённые",
"calls": "Вызовы",
"source": "Источник",
"billing_unit": "Единица тарификации",
"billing_rate": "Тариф",
"pricing_rules": "Правила тарификации",
"manual_clears_pricing_rules": "Сохранение ручной цены удалит {{count}} синхронизированных контекстных правил или правил уровня обслуживания.",
"needs_confirmation": "Нужно подтвердить цену",
Expand Down
Loading