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
1 change: 1 addition & 0 deletions platform/e2e/pages/models-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export function modelsPage(page: Page) {
})
await page.getByRole("button", { name: "Save provider", exact: true }).click()
await expect(page.getByRole("heading", { level: 1, name: input.name })).toBeVisible()
await expectToast(page, "Provider saved")
await expect(page.locator("body")).not.toHaveAttribute("data-key-error-shown")
},

Expand Down
120 changes: 120 additions & 0 deletions platform/e2e/specs/seeded/model-price-schedule.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import {
createSeedDatabase,
loadSeedEnvironment,
resolveSeedDatabaseUrl,
} from "@scripts/seed/database.ts"
import { SEED_ORGANIZATIONS, SEED_PASSWORD, SEED_USERS } from "@scripts/seed/fixtures.ts"
import { captureMilestone } from "@e2e/capture.ts"
import { expect, test } from "@e2e/fixtures.ts"
import { waitForHydration } from "@e2e/hydration.ts"

test("manual prices can be scheduled, edited and cancelled while viewers cannot manage prices", async ({
page,
models,
auth,
}) => {
test.setTimeout(90_000)
const org = SEED_ORGANIZATIONS[0]
const model = "gpt-6.1-sol"
const name = `Pricing schedule ${Date.now()}`
await page.goto(`/${org.slug}/models`)
await models.create({ name, modelId: model, apiKey: "synthetic-scheduling-key" })
const providerUrl = page.url()
await page
.getByRole("switch", { name: `Override catalog defaults for ${model}`, exact: true })
.check()
await page.getByLabel(`Input price for ${model}`, { exact: true }).fill("2")
await page.getByLabel(`Output price for ${model}`, { exact: true }).fill("8")
await models.save()
await page.reload()
await waitForHydration(page.locator("#model-provider-name"))
const schedule = page.getByRole("group", { name: `Price schedule for ${model}`, exact: true })
await schedule.getByRole("button", { name: "Schedule price change" }).click()
const future = new Date(Date.now() + 86_400_000)
const local = new Date(future.getTime() - future.getTimezoneOffset() * 60_000)
.toISOString()
.slice(0, 16)
await schedule.getByLabel("Effective from", { exact: true }).fill(local)
await schedule.getByLabel(/Input price/).fill("3")
await schedule.getByLabel(/Output price/).fill("12")
await models.save()
await page.reload()
await waitForHydration(page.locator("#model-provider-name"))
await expect(page.getByLabel(`Input price for ${model}`, { exact: true })).toHaveValue("2")
await schedule.locator("summary").filter({ hasText: "Scheduled" }).click()
await expect(schedule.getByLabel("Effective from", { exact: true })).toHaveValue(local)
await expect(schedule.getByLabel(/Input price/)).toHaveValue("3")
await schedule.scrollIntoViewIfNeeded()
await captureMilestone(page, "01-saved-price-schedule")
await schedule.getByLabel(/Input price/).fill("4")
await models.save()
await page.reload()
await waitForHydration(page.locator("#model-provider-name"))
await schedule.locator("summary").filter({ hasText: "Scheduled" }).click()
await expect(schedule.getByLabel(/Input price/)).toHaveValue("4")
await page.setViewportSize({ width: 390, height: 844 })
await schedule.scrollIntoViewIfNeeded()
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(
true,
)
await captureMilestone(page, "02-edited-price-schedule-mobile")
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto("/auth/sign-out")
await page.waitForURL(/\/auth\/sign-in/)
await auth.signIn(SEED_USERS[2].email, SEED_PASSWORD)
await page.waitForURL((url) => !url.pathname.startsWith("/auth/"))
await page.goto(providerUrl)
await expect(page).toHaveURL(new RegExp(`/${org.slug}/?$`))
await expect(schedule).toHaveCount(0)
await expect(page.getByRole("button", { name: "Save provider", exact: true })).toHaveCount(0)
await captureMilestone(page, "03-viewer-cannot-manage-prices")
await page.goto("/auth/sign-out")
await page.waitForURL(/\/auth\/sign-in/)
await auth.signIn(SEED_USERS[0].email, SEED_PASSWORD)
await page.waitForURL((url) => !url.pathname.startsWith("/auth/"))
await page.goto(providerUrl)
await waitForHydration(page.locator("#model-provider-name"))
await schedule.locator("summary").filter({ hasText: "Scheduled" }).click()
await schedule.getByRole("button", { name: "Cancel scheduled price" }).click()
await models.save()
await page.reload()
await waitForHydration(page.locator("#model-provider-name"))
await expect(schedule.locator("details")).toHaveCount(0)
await expect(page.getByLabel(`Input price for ${model}`, { exact: true })).toHaveValue("2")
await schedule.scrollIntoViewIfNeeded()
await captureMilestone(page, "04-price-schedule-cancelled")
await schedule.getByRole("button", { name: "Schedule price change" }).click()
await schedule.getByLabel("Effective from", { exact: true }).fill(local)
await schedule.getByLabel(/Input price/).fill("4")
await models.save()
loadSeedEnvironment()
const { pool } = createSeedDatabase(resolveSeedDatabaseUrl().url)
try {
// Activate this test's saved schedule without changing the provider's lock version.
const activated = await pool.query(
`update provider_model
set pricing_history = jsonb_set(pricing_history, '{-1,effectiveFrom}', to_jsonb($4::text)),
updated_at = now()
where organization_id = $1 and model_provider_id = $2 and model_id = $3`,
[
org.id,
new URL(providerUrl).pathname.split("/").filter(Boolean).at(-1),
model,
new Date(Date.now() - 1).toISOString(),
],
)
expect(activated.rowCount).toBe(1)
} finally {
await pool.end()
}
await page.getByLabel(`Input price for ${model}`, { exact: true }).fill("5")
await page.getByRole("button", { name: "Save provider", exact: true }).click()
await expect(page.getByRole("button", { name: "Save provider", exact: true })).toBeDisabled()
await page.getByRole("button", { name: "Reload latest", exact: true }).click()
await expect(page.getByLabel(`Input price for ${model}`, { exact: true })).toHaveValue("4")
await waitForHydration(page.locator("#model-provider-name"))
await expect(schedule.locator("details")).toHaveCount(0)
await models.save()
await captureMilestone(page, "05-activated-price-reloaded")
await models.requestDelete()
})
9 changes: 7 additions & 2 deletions platform/scripts/check-binary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import packageJson from "../package.json" with { type: "json" }

const BINARY_CHECK_START_TIMEOUT_MS = 20_000
const BINARY_CHECK_FETCH_TIMEOUT_MS = 2_000
// Shutdown has separate five-second HTTP drain and resource cleanup budgets.
const BINARY_CHECK_SHUTDOWN_TIMEOUT_MS = 11_000
const runBinaryCheckCommand = promisify(execFile)

async function reserveBinaryCheckPort() {
Expand Down Expand Up @@ -151,7 +153,10 @@ async function stopBinaryCheckProcess(binaryProcess: ChildProcess, status: Promi
const processId = binaryProcess.pid
if (processId === undefined) throw new Error("Binary process did not start")
process.kill(processId, "SIGTERM")
const stopped = await Promise.race([status.then(() => true), delay(5_000, false)])
const stopped = await Promise.race([
status.then(() => true),
delay(BINARY_CHECK_SHUTDOWN_TIMEOUT_MS, false),
])
if (stopped) {
if (binaryProcess.exitCode !== 0) {
throw new Error("Binary did not exit cleanly after SIGTERM")
Expand All @@ -160,7 +165,7 @@ async function stopBinaryCheckProcess(binaryProcess: ChildProcess, status: Promi
}
process.kill(processId, "SIGKILL")
await status
throw new Error("Binary did not exit within 5 seconds of SIGTERM")
throw new Error("Binary did not exit within 11 seconds of SIGTERM")
}

async function runBinaryCheck() {
Expand Down
7 changes: 7 additions & 0 deletions platform/scripts/seed/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ export async function seedModelProviders(
modelProviderId,
modelId: SEED_MODEL_PROVIDER.modelId,
name: SEED_MODEL_PROVIDER.modelName,
pricingHistory: [
{
id: crypto.randomUUID(),
effectiveFrom: new Date().toISOString(),
policy: { kind: "catalog" },
},
],
})
.returning({ id: providerModel.id })
const assigned = await transaction
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
ALTER TABLE "chat_message" ADD COLUMN "request_accounting" jsonb;--> statement-breakpoint
ALTER TABLE "provider_model" ADD COLUMN "pricing_history" jsonb DEFAULT '[]' NOT NULL;--> statement-breakpoint
UPDATE provider_model SET pricing_history = jsonb_build_array(jsonb_build_object(
'id', uuidv7()::text,
'effectiveFrom', to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
'rates', jsonb_build_object('currency', configuration->'currency', 'pricingSource', configuration->'pricingSource', 'prices', configuration->'prices', 'contextTiers', configuration->'contextTiers')
)) WHERE configuration->'pricingSource'->>'kind' = 'manual';

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
UPDATE provider_model SET
pricing_history = CASE WHEN jsonb_array_length(pricing_history) = 0 THEN
jsonb_build_array(jsonb_build_object('id', uuidv7()::text,
'effectiveFrom', to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
'policy', CASE WHEN configuration->'pricingSource'->>'kind' = 'manual'
THEN jsonb_build_object('kind', 'manual', 'prices', configuration->'prices')
ELSE jsonb_build_object('kind', 'catalog') END))
ELSE (SELECT jsonb_agg(jsonb_build_object('id', entry->'id', 'effectiveFrom', entry->'effectiveFrom',
'policy', CASE WHEN entry->'rates' = 'null'::jsonb THEN jsonb_build_object('kind', 'catalog')
ELSE jsonb_build_object('kind', 'manual', 'prices', entry->'rates'->'prices') END) ORDER BY ordinal)
FROM jsonb_array_elements(pricing_history) WITH ORDINALITY AS changes(entry, ordinal)) END,
configuration = CASE WHEN configuration->'pricingSource'->>'kind' = 'manual'
THEN configuration - 'currency' - 'pricingSource' - 'prices' - 'contextTiers'
ELSE NULL END,
updated_at = now();

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions platform/src/db/schema/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "drizzle-orm/pg-core"

import { ApiUuidSchema } from "@/lib/tenants/schemas.ts"
import { ModelRequestSnapshotSchema } from "@/lib/model-providers/schemas.ts"
import {
deferrableForeignKey,
schemaJsonb,
Expand Down Expand Up @@ -224,6 +225,12 @@ export const chatMessage = snakeCase.table(
// Versioned, validated provenance, provider continuation, and input tool declarations. The current invocation ID fences producer writes.
// Credentials are excluded. Visible content belongs in message parts, and provider context is derived separately.
metadata: schemaJsonb(ChatMessageMetadataSchema).notNull(),
requestAccounting: schemaJsonb(
Schema.Struct({
version: Schema.Literals([1, 2]),
requests: Schema.Array(ModelRequestSnapshotSchema),
}).check(boundedChatJson),
),
// Lifecycle on the initiating user message. Its accepted content remains complete throughout execution.
turnState: chatMessageTurnStateEnum(),
...timestamps(),
Expand Down
6 changes: 4 additions & 2 deletions platform/src/db/schema/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ import {
} from "@/lib/sandboxes/schemas.ts"
import {
ModelProviderCredentialsPayloadSchema,
ModelConfigurationSchema,
ModelLimitsSchema,
ModelPriceHistorySchema,
type ModelProviderType,
type ModelProviderApi,
} from "@/lib/model-providers/schemas.ts"
Expand Down Expand Up @@ -179,7 +180,8 @@ export const providerModel = snakeCase.table(
modelProviderId: uuid().notNull(),
modelId: text().notNull(),
name: text().notNull(),
configuration: schemaJsonb(ModelConfigurationSchema),
configuration: schemaJsonb(ModelLimitsSchema),
pricingHistory: schemaJsonb(ModelPriceHistorySchema).default([]).notNull(),
...timestamps(),
},
(table) => [
Expand Down
2 changes: 2 additions & 0 deletions platform/src/lib/chat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ Optional fields also preserve input tool declarations, opaque `modelMessages`, p

Assistant provenance usage includes a decimal-string `costUsd` when a reported cost or resolved token rate is available. This snapshots the generation-time cost for retained history. Reported usage from failed model calls is retained and recorded once per model phase, including partial Anthropic input/cache usage. One-hour cache writes are priced as a subset of total cache writes. Directory totals exclude drafts and temporarily count unpriceable settled replies as zero.

Assistant messages also retain internal `request_accounting` rate snapshots before provider dispatch. Each attempt has its own ID, UTC start and rate card, including retries and native-web continuations. Received terminal usage updates that attempt's charge using its frozen rates, preferring provider-reported cost. Writer claims fence these writes, and interruption preserves snapshots even without visible output. Version 2 accounting supplies directory totals by request start time. Older messages retain their saved aggregate charge and original message-date attribution. A snapshot alone is not confirmed billable usage. Recovery of usage omitted by providers and reporting incompleteness remain follow-up work.

`initiatorTenantUserId` identifies the actor of this foreground invocation. A participant resolving the last browser response can start a continuation, so that actor can differ from the original user-message author.

### Part payloads
Expand Down
Loading
Loading