Establish secure account and managed AI backend
Provide the production foundation for Apple identity, immutable credits, referrals, integrity checks, managed providers, and hardened Docker deployment.
This commit is contained in:
@@ -0,0 +1,576 @@
|
||||
package com.osglab.account.features.credits
|
||||
|
||||
import com.osglab.account.features.credits.domain.CreditCostCalculator
|
||||
import com.osglab.account.features.credits.domain.CreditConflict
|
||||
import com.osglab.account.features.credits.domain.CreditRateVersion
|
||||
import com.osglab.account.features.credits.domain.InsufficientCredits
|
||||
import com.osglab.account.features.credits.domain.InvalidCreditRequest
|
||||
import com.osglab.account.features.credits.domain.LedgerEntryType
|
||||
import com.osglab.account.features.credits.domain.ReservationStatus
|
||||
import com.osglab.account.features.credits.domain.ReservationStateRules
|
||||
import com.osglab.account.features.credits.domain.UsageKind
|
||||
import com.osglab.account.features.credits.domain.UsageMeasurement
|
||||
import com.osglab.account.features.credits.domain.externalIdempotencyKey
|
||||
import com.osglab.account.features.credits.services.CreditService
|
||||
import com.osglab.account.features.credits.services.ReferralRewardConfig
|
||||
import com.osglab.account.features.referrals.domain.ReferralBinding
|
||||
import com.osglab.account.features.referrals.domain.ReferralCampaign
|
||||
import com.osglab.account.features.referrals.domain.ReferralCampaignBudget
|
||||
import com.osglab.account.features.referrals.domain.ReferralRewardStatus
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.longs.shouldBeExactly
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.shouldNotBe
|
||||
import io.kotest.matchers.types.shouldBeInstanceOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
import kotlin.random.Random
|
||||
|
||||
class CreditServiceTest : FunSpec({
|
||||
val now = Instant.parse("2026-08-15T00:00:00Z")
|
||||
|
||||
test("ASR and LLM rates round each billed dimension upward") {
|
||||
CreditCostCalculator.calculate(
|
||||
asrRate(now),
|
||||
UsageMeasurement.Asr(durationMillis = 101),
|
||||
) shouldBeExactly 2
|
||||
CreditCostCalculator.calculate(
|
||||
llmRate(now),
|
||||
UsageMeasurement.Llm(inputTokens = 1001, outputTokens = 1),
|
||||
) shouldBeExactly 4
|
||||
}
|
||||
|
||||
test("cost calculation avoids intermediate overflow and rejects an unrepresentable result") {
|
||||
CreditCostCalculator.calculate(
|
||||
asrRate(now).copy(
|
||||
asrCreditsNumerator = 2,
|
||||
asrMillisDenominator = 2,
|
||||
),
|
||||
UsageMeasurement.Asr(Long.MAX_VALUE),
|
||||
) shouldBeExactly Long.MAX_VALUE
|
||||
|
||||
shouldThrow<InvalidCreditRequest> {
|
||||
CreditCostCalculator.calculate(
|
||||
asrRate(now).copy(
|
||||
asrCreditsNumerator = 2,
|
||||
asrMillisDenominator = 1,
|
||||
),
|
||||
UsageMeasurement.Asr(Long.MAX_VALUE),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
test("signup grant is idempotent") {
|
||||
val store = storeWithRates(now)
|
||||
val service = service(store, now)
|
||||
val userId = UUID.randomUUID()
|
||||
|
||||
service.grantSignupTrial(userId, 100, "signup-key-001")
|
||||
service.grantSignupTrial(userId, 100, "signup-key-001")
|
||||
|
||||
store.balance(userId) shouldBeExactly 100
|
||||
store.ledger.filter { it.type == LedgerEntryType.SIGNUP_TRIAL } shouldHaveSize 1
|
||||
}
|
||||
|
||||
test("balance overflow rolls back without appending a ledger entry") {
|
||||
val store = storeWithRates(now)
|
||||
val service = service(store, now)
|
||||
val userId = UUID.randomUUID()
|
||||
service.grantSignupTrial(userId, Long.MAX_VALUE, "signup-max-key")
|
||||
|
||||
shouldThrow<InvalidCreditRequest> {
|
||||
service.grantSignupTrial(userId, 1, "signup-overflow-key")
|
||||
}
|
||||
|
||||
store.balance(userId) shouldBeExactly Long.MAX_VALUE
|
||||
store.ledger.filter { it.userId == userId } shouldHaveSize 1
|
||||
}
|
||||
|
||||
test("concurrent reservations cannot make the account negative") {
|
||||
val store = storeWithRates(now)
|
||||
val service = service(store, now)
|
||||
val userId = UUID.randomUUID()
|
||||
service.grantSignupTrial(userId, 100, "signup-key-002")
|
||||
|
||||
val results = coroutineScope {
|
||||
listOf("reserve-key-001", "reserve-key-002").map { key ->
|
||||
async(Dispatchers.Default) {
|
||||
runCatching {
|
||||
service.reserve(
|
||||
userId = userId,
|
||||
provider = "asr-provider",
|
||||
model = "asr-model",
|
||||
estimatedUsage = UsageMeasurement.Asr(6_000),
|
||||
managedCall = true,
|
||||
idempotencyKey = key,
|
||||
)
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
|
||||
results.count(Result<*>::isSuccess) shouldBe 1
|
||||
results.single(Result<*>::isFailure).exceptionOrNull()
|
||||
.shouldBeInstanceOf<InsufficientCredits>()
|
||||
store.balance(userId) shouldBeExactly 40
|
||||
}
|
||||
|
||||
test("first positive managed settlement rewards both users exactly once") {
|
||||
val store = storeWithRates(now)
|
||||
val service = service(store, now)
|
||||
val inviter = UUID.randomUUID()
|
||||
val invitee = UUID.randomUUID()
|
||||
val binding = ReferralBinding(
|
||||
id = UUID.randomUUID(),
|
||||
inviterUserId = inviter,
|
||||
inviteeUserId = invitee,
|
||||
codeId = UUID.randomUUID(),
|
||||
boundAt = now,
|
||||
rewardedAt = null,
|
||||
rewardSettlementId = null,
|
||||
)
|
||||
store.inTransaction { it.referrals.insertBindingIfAbsent(binding) }
|
||||
service.grantSignupTrial(invitee, 100, "signup-key-003")
|
||||
val reservation = service.reserve(
|
||||
userId = invitee,
|
||||
provider = "asr-provider",
|
||||
model = "asr-model",
|
||||
estimatedUsage = UsageMeasurement.Asr(1_000),
|
||||
managedCall = true,
|
||||
idempotencyKey = "reserve-key-003",
|
||||
)
|
||||
|
||||
val first = service.settle(
|
||||
invitee,
|
||||
reservation.id,
|
||||
UsageMeasurement.Asr(500),
|
||||
"settle-key-003",
|
||||
)
|
||||
val retried = service.settle(
|
||||
invitee,
|
||||
reservation.id,
|
||||
UsageMeasurement.Asr(500),
|
||||
"settle-key-003",
|
||||
)
|
||||
|
||||
first.status shouldBe ReservationStatus.SETTLED
|
||||
retried shouldBe first
|
||||
store.balance(invitee) shouldBeExactly 125
|
||||
store.balance(inviter) shouldBeExactly 30
|
||||
store.ledger.filter {
|
||||
it.type == LedgerEntryType.REFERRAL_INVITEE ||
|
||||
it.type == LedgerEntryType.REFERRAL_INVITER
|
||||
} shouldHaveSize 2
|
||||
store.usageRecords shouldHaveSize 1
|
||||
store.usageRecords.single().rateVersionId shouldBe reservation.rateVersionId
|
||||
store.bindings.getValue(invitee).rewardSettlementId shouldBe reservation.id
|
||||
}
|
||||
|
||||
test("zero-cost managed settlement does not consume first valid referral reward") {
|
||||
val store = storeWithRates(now)
|
||||
val service = service(store, now)
|
||||
val inviter = UUID.randomUUID()
|
||||
val invitee = UUID.randomUUID()
|
||||
store.inTransaction {
|
||||
it.referrals.insertBindingIfAbsent(
|
||||
ReferralBinding(
|
||||
id = UUID.randomUUID(),
|
||||
inviterUserId = inviter,
|
||||
inviteeUserId = invitee,
|
||||
codeId = UUID.randomUUID(),
|
||||
boundAt = now,
|
||||
rewardedAt = null,
|
||||
rewardSettlementId = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
service.grantSignupTrial(invitee, 100, "zero-signup-key")
|
||||
val zeroCost = service.reserve(
|
||||
invitee,
|
||||
"asr-provider",
|
||||
"asr-model",
|
||||
UsageMeasurement.Asr(1_000),
|
||||
managedCall = true,
|
||||
idempotencyKey = "zero-reserve-key",
|
||||
)
|
||||
service.settle(
|
||||
invitee,
|
||||
zeroCost.id,
|
||||
UsageMeasurement.Asr(0),
|
||||
"zero-settle-key",
|
||||
)
|
||||
store.bindings.getValue(invitee).rewardStatus shouldBe ReferralRewardStatus.PENDING
|
||||
|
||||
val qualifying = service.reserve(
|
||||
invitee,
|
||||
"asr-provider",
|
||||
"asr-model",
|
||||
UsageMeasurement.Asr(1_000),
|
||||
managedCall = true,
|
||||
idempotencyKey = "valid-reserve-key",
|
||||
)
|
||||
service.settle(
|
||||
invitee,
|
||||
qualifying.id,
|
||||
UsageMeasurement.Asr(1),
|
||||
"valid-settle-key",
|
||||
)
|
||||
|
||||
store.bindings.getValue(invitee).rewardSettlementId shouldBe qualifying.id
|
||||
store.ledger.count {
|
||||
it.type == LedgerEntryType.REFERRAL_INVITER ||
|
||||
it.type == LedgerEntryType.REFERRAL_INVITEE
|
||||
} shouldBe 2
|
||||
}
|
||||
|
||||
test("concurrent settlement replay grants both referral sides only once") {
|
||||
val store = storeWithRates(now)
|
||||
val service = service(store, now)
|
||||
val inviter = UUID.randomUUID()
|
||||
val invitee = UUID.randomUUID()
|
||||
store.inTransaction {
|
||||
it.referrals.insertBindingIfAbsent(
|
||||
ReferralBinding(
|
||||
id = UUID.randomUUID(),
|
||||
inviterUserId = inviter,
|
||||
inviteeUserId = invitee,
|
||||
codeId = UUID.randomUUID(),
|
||||
boundAt = now,
|
||||
rewardedAt = null,
|
||||
rewardSettlementId = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
service.grantSignupTrial(invitee, 100, "concurrent-signup-key")
|
||||
val reservation = service.reserve(
|
||||
userId = invitee,
|
||||
provider = "asr-provider",
|
||||
model = "asr-model",
|
||||
estimatedUsage = UsageMeasurement.Asr(1_000),
|
||||
managedCall = true,
|
||||
idempotencyKey = "concurrent-reserve-key",
|
||||
)
|
||||
|
||||
val results = coroutineScope {
|
||||
List(8) {
|
||||
async(Dispatchers.Default) {
|
||||
service.settle(
|
||||
userId = invitee,
|
||||
reservationId = reservation.id,
|
||||
actualUsage = UsageMeasurement.Asr(500),
|
||||
idempotencyKey = "concurrent-settle-key",
|
||||
)
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
|
||||
results.distinct() shouldHaveSize 1
|
||||
store.usageRecords shouldHaveSize 1
|
||||
store.ledger.count {
|
||||
it.type == LedgerEntryType.REFERRAL_INVITEE ||
|
||||
it.type == LedgerEntryType.REFERRAL_INVITER
|
||||
} shouldBe 2
|
||||
store.balance(inviter) shouldBeExactly 30
|
||||
store.balance(invitee) shouldBeExactly 125
|
||||
}
|
||||
|
||||
test("ledger projection remains non-negative across randomized terminal operations") {
|
||||
val store = storeWithRates(now)
|
||||
val service = service(store, now)
|
||||
val userId = UUID.randomUUID()
|
||||
service.grantSignupTrial(userId, 20_000, "property-signup-key")
|
||||
val random = Random(42)
|
||||
|
||||
repeat(100) { index ->
|
||||
val estimate = random.nextLong(1, 5_000)
|
||||
val reservation = service.reserve(
|
||||
userId,
|
||||
"asr-provider",
|
||||
"asr-model",
|
||||
UsageMeasurement.Asr(estimate),
|
||||
managedCall = false,
|
||||
idempotencyKey = "property-reserve-$index",
|
||||
)
|
||||
if (index % 3 == 0) {
|
||||
service.release(userId, reservation.id, "property-release-$index")
|
||||
} else {
|
||||
val actual = random.nextLong(0, estimate + 1)
|
||||
service.settle(
|
||||
userId,
|
||||
reservation.id,
|
||||
UsageMeasurement.Asr(actual),
|
||||
"property-settle-$index",
|
||||
)
|
||||
if (index % 5 == 0) {
|
||||
service.refund(userId, reservation.id, "property-refund-$index")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var projection = 0L
|
||||
store.ledger.filter { it.userId == userId }.forEach { entry ->
|
||||
projection = Math.addExact(projection, entry.amountDelta)
|
||||
entry.balanceAfter shouldBeExactly projection
|
||||
(projection >= 0) shouldBe true
|
||||
}
|
||||
store.balance(userId) shouldBeExactly projection
|
||||
}
|
||||
|
||||
test("campaign cap is consumed once and later qualification is not rewarded") {
|
||||
val store = storeWithRates(now)
|
||||
val service = service(store, now)
|
||||
val campaignId = UUID.randomUUID()
|
||||
store.campaigns[campaignId] = ReferralCampaign(
|
||||
id = campaignId,
|
||||
name = "One reward",
|
||||
startsAt = now.minusSeconds(60),
|
||||
endsAt = null,
|
||||
bindingWindowSeconds = 604_800,
|
||||
inviterRewardCredits = 7,
|
||||
inviteeRewardCredits = 5,
|
||||
maxRewardedBindings = 1,
|
||||
budgetCredits = 12,
|
||||
enabled = true,
|
||||
)
|
||||
store.campaignBudgets[campaignId] = ReferralCampaignBudget(campaignId, 0, 0, now)
|
||||
val inviter = UUID.randomUUID()
|
||||
val invitees = List(2) { UUID.randomUUID() }
|
||||
invitees.forEach { invitee ->
|
||||
store.inTransaction {
|
||||
it.referrals.insertBindingIfAbsent(
|
||||
ReferralBinding(
|
||||
id = UUID.randomUUID(),
|
||||
inviterUserId = inviter,
|
||||
inviteeUserId = invitee,
|
||||
codeId = UUID.randomUUID(),
|
||||
boundAt = now,
|
||||
rewardedAt = null,
|
||||
rewardSettlementId = null,
|
||||
campaignId = campaignId,
|
||||
),
|
||||
)
|
||||
}
|
||||
service.grantSignupTrial(invitee, 100, "campaign-signup-$invitee")
|
||||
val reservation = service.reserve(
|
||||
invitee,
|
||||
"asr-provider",
|
||||
"asr-model",
|
||||
UsageMeasurement.Asr(1_000),
|
||||
managedCall = true,
|
||||
idempotencyKey = "campaign-reserve-$invitee",
|
||||
)
|
||||
service.settle(
|
||||
invitee,
|
||||
reservation.id,
|
||||
UsageMeasurement.Asr(500),
|
||||
"campaign-settle-$invitee",
|
||||
)
|
||||
}
|
||||
|
||||
store.ledger.count {
|
||||
it.type == LedgerEntryType.REFERRAL_INVITER ||
|
||||
it.type == LedgerEntryType.REFERRAL_INVITEE
|
||||
} shouldBe 2
|
||||
store.campaignBudgets.getValue(campaignId).rewardedBindings shouldBeExactly 1
|
||||
store.bindings.getValue(invitees[1]).rewardStatus shouldBe
|
||||
ReferralRewardStatus.INELIGIBLE_BUDGET
|
||||
}
|
||||
|
||||
test("exhausted campaign counters fail closed without partial rewards") {
|
||||
val store = storeWithRates(now)
|
||||
val service = service(store, now)
|
||||
val campaignId = UUID.randomUUID()
|
||||
store.campaigns[campaignId] = ReferralCampaign(
|
||||
id = campaignId,
|
||||
name = "Exhausted",
|
||||
startsAt = now.minusSeconds(60),
|
||||
endsAt = null,
|
||||
bindingWindowSeconds = 604_800,
|
||||
inviterRewardCredits = 7,
|
||||
inviteeRewardCredits = 5,
|
||||
maxRewardedBindings = null,
|
||||
budgetCredits = null,
|
||||
enabled = true,
|
||||
)
|
||||
store.campaignBudgets[campaignId] = ReferralCampaignBudget(
|
||||
campaignId,
|
||||
Long.MAX_VALUE,
|
||||
Long.MAX_VALUE,
|
||||
now,
|
||||
)
|
||||
val inviter = UUID.randomUUID()
|
||||
val invitee = UUID.randomUUID()
|
||||
store.inTransaction {
|
||||
it.referrals.insertBindingIfAbsent(
|
||||
ReferralBinding(
|
||||
id = UUID.randomUUID(),
|
||||
inviterUserId = inviter,
|
||||
inviteeUserId = invitee,
|
||||
codeId = UUID.randomUUID(),
|
||||
boundAt = now,
|
||||
rewardedAt = null,
|
||||
rewardSettlementId = null,
|
||||
campaignId = campaignId,
|
||||
),
|
||||
)
|
||||
}
|
||||
service.grantSignupTrial(invitee, 100, "exhausted-signup-key")
|
||||
val reservation = service.reserve(
|
||||
invitee,
|
||||
"asr-provider",
|
||||
"asr-model",
|
||||
UsageMeasurement.Asr(1_000),
|
||||
managedCall = true,
|
||||
idempotencyKey = "exhausted-reserve-key",
|
||||
)
|
||||
|
||||
service.settle(
|
||||
invitee,
|
||||
reservation.id,
|
||||
UsageMeasurement.Asr(500),
|
||||
"exhausted-settle-key",
|
||||
)
|
||||
|
||||
store.bindings.getValue(invitee).rewardStatus shouldBe
|
||||
ReferralRewardStatus.INELIGIBLE_BUDGET
|
||||
store.ledger.none {
|
||||
it.type == LedgerEntryType.REFERRAL_INVITER ||
|
||||
it.type == LedgerEntryType.REFERRAL_INVITEE
|
||||
} shouldBe true
|
||||
}
|
||||
|
||||
test("release and refund restore only the corresponding debit") {
|
||||
val store = storeWithRates(now)
|
||||
val service = service(store, now)
|
||||
val userId = UUID.randomUUID()
|
||||
service.grantSignupTrial(userId, 100, "signup-key-004")
|
||||
val released = service.reserve(
|
||||
userId,
|
||||
"asr-provider",
|
||||
"asr-model",
|
||||
UsageMeasurement.Asr(1_000),
|
||||
managedCall = false,
|
||||
idempotencyKey = "reserve-key-004",
|
||||
)
|
||||
service.release(userId, released.id, "release-key-004")
|
||||
service.release(userId, released.id, "release-key-004")
|
||||
|
||||
val settled = service.reserve(
|
||||
userId,
|
||||
"asr-provider",
|
||||
"asr-model",
|
||||
UsageMeasurement.Asr(1_000),
|
||||
managedCall = false,
|
||||
idempotencyKey = "reserve-key-005",
|
||||
)
|
||||
service.settle(userId, settled.id, UsageMeasurement.Asr(500), "settle-key-005")
|
||||
service.refund(userId, settled.id, "refund-key-005")
|
||||
service.refund(userId, settled.id, "refund-key-005")
|
||||
|
||||
store.balance(userId) shouldBeExactly 100
|
||||
store.ledger.filter { it.type == LedgerEntryType.USAGE_RELEASE } shouldHaveSize 1
|
||||
store.ledger.filter { it.type == LedgerEntryType.USAGE_REFUND } shouldHaveSize 1
|
||||
}
|
||||
|
||||
test("settlement replay rejects different actual usage") {
|
||||
val store = storeWithRates(now)
|
||||
val service = service(store, now)
|
||||
val userId = UUID.randomUUID()
|
||||
service.grantSignupTrial(userId, 100, "signup-key-006")
|
||||
val reservation = service.reserve(
|
||||
userId,
|
||||
"asr-provider",
|
||||
"asr-model",
|
||||
UsageMeasurement.Asr(1_000),
|
||||
managedCall = false,
|
||||
idempotencyKey = "reserve-key-006",
|
||||
)
|
||||
service.settle(userId, reservation.id, UsageMeasurement.Asr(500), "settle-key-006")
|
||||
|
||||
shouldThrow<CreditConflict> {
|
||||
service.settle(userId, reservation.id, UsageMeasurement.Asr(600), "settle-key-006")
|
||||
}
|
||||
}
|
||||
|
||||
test("public idempotency values cannot occupy the internal reward namespace") {
|
||||
externalIdempotencyKey("internal:referral:known-binding:invitee") shouldNotBe
|
||||
"internal:referral:known-binding:invitee"
|
||||
}
|
||||
|
||||
test("reservation state rules allow only settle release and refund transitions") {
|
||||
ReservationStateRules.canTransition(
|
||||
ReservationStatus.RESERVED,
|
||||
ReservationStatus.SETTLED,
|
||||
) shouldBe true
|
||||
ReservationStateRules.canTransition(
|
||||
ReservationStatus.RESERVED,
|
||||
ReservationStatus.RELEASED,
|
||||
) shouldBe true
|
||||
ReservationStateRules.canTransition(
|
||||
ReservationStatus.SETTLED,
|
||||
ReservationStatus.REFUNDED,
|
||||
) shouldBe true
|
||||
|
||||
ReservationStatus.entries.forEach { target ->
|
||||
ReservationStateRules.canTransition(
|
||||
ReservationStatus.REFUNDED,
|
||||
target,
|
||||
) shouldBe false
|
||||
}
|
||||
ReservationStateRules.canTransition(
|
||||
ReservationStatus.RELEASED,
|
||||
ReservationStatus.SETTLED,
|
||||
) shouldBe false
|
||||
}
|
||||
})
|
||||
|
||||
private fun service(store: TestBillingStore, now: Instant) = CreditService(
|
||||
transactions = store,
|
||||
referralRewards = ReferralRewardConfig(inviterCredits = 30, inviteeCredits = 30),
|
||||
clock = Clock.fixed(now, ZoneOffset.UTC),
|
||||
)
|
||||
|
||||
private fun storeWithRates(now: Instant) = TestBillingStore().also {
|
||||
val asr = asrRate(now)
|
||||
val llm = llmRate(now)
|
||||
it.rates[asr.id] = asr
|
||||
it.rates[llm.id] = llm
|
||||
}
|
||||
|
||||
private fun asrRate(now: Instant) = CreditRateVersion(
|
||||
id = UUID.nameUUIDFromBytes("asr-rate".toByteArray()),
|
||||
kind = UsageKind.ASR,
|
||||
provider = "asr-provider",
|
||||
model = "asr-model",
|
||||
effectiveFrom = now.minusSeconds(60),
|
||||
effectiveUntil = null,
|
||||
asrCreditsNumerator = 1,
|
||||
asrMillisDenominator = 100,
|
||||
inputCreditsNumerator = null,
|
||||
inputTokensDenominator = null,
|
||||
outputCreditsNumerator = null,
|
||||
outputTokensDenominator = null,
|
||||
)
|
||||
|
||||
private fun llmRate(now: Instant) = CreditRateVersion(
|
||||
id = UUID.nameUUIDFromBytes("llm-rate".toByteArray()),
|
||||
kind = UsageKind.LLM,
|
||||
provider = "llm-provider",
|
||||
model = "llm-model",
|
||||
effectiveFrom = now.minusSeconds(60),
|
||||
effectiveUntil = null,
|
||||
asrCreditsNumerator = null,
|
||||
asrMillisDenominator = null,
|
||||
inputCreditsNumerator = 2,
|
||||
inputTokensDenominator = 1_000,
|
||||
outputCreditsNumerator = 3,
|
||||
outputTokensDenominator = 1_000,
|
||||
)
|
||||
Reference in New Issue
Block a user