feat(memory): implement stability stage
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -13,12 +13,17 @@ interface DraftSession {
|
||||
generated_world?: { title?: string; premise?: string } | null
|
||||
updated_at: string
|
||||
}
|
||||
interface UsageSummary {
|
||||
quota: { usedToday: number; dailyRequestLimit: number; dailyTokenLimit: number }
|
||||
totals: { requests: number; inputTokens: number; outputTokens: number; costUsd: number; averageLatencyMs: number }
|
||||
}
|
||||
|
||||
const { api } = useDngApi()
|
||||
const auth = useDngAuth()
|
||||
const filter = ref<'all' | 'active' | 'drafts'>('all')
|
||||
const campaigns = ref<CampaignRow[]>([])
|
||||
const drafts = ref<DraftSession[]>([])
|
||||
const usage = ref<UsageSummary | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const renamingCampaignId = ref<string | null>(null)
|
||||
@@ -43,6 +48,10 @@ function draftSummary(draft: DraftSession) {
|
||||
|| 'Continue your conversation with the coauthor.'
|
||||
}
|
||||
|
||||
function compactNumber(value: number) {
|
||||
return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(value)
|
||||
}
|
||||
|
||||
function openRename(campaign: CampaignRow) {
|
||||
renamingCampaignId.value = campaign.id
|
||||
newName.value = campaign.title
|
||||
@@ -74,12 +83,14 @@ async function requestDelete(campaign: CampaignRow) {
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await auth.restore()
|
||||
const [campaignResult, draftResult] = await Promise.all([
|
||||
const [campaignResult, draftResult, usageResult] = await Promise.all([
|
||||
api<{ campaigns: CampaignRow[] }>('/api/v1/campaigns'),
|
||||
api<{ sessions: DraftSession[] }>('/api/v1/coauthor/sessions'),
|
||||
api<UsageSummary>('/api/v1/usage').catch(() => null),
|
||||
])
|
||||
campaigns.value = campaignResult.campaigns
|
||||
drafts.value = draftResult.sessions
|
||||
usage.value = usageResult
|
||||
} catch (cause) {
|
||||
const value = cause as { data?: { statusMessage?: string }; message?: string }
|
||||
error.value = value.data?.statusMessage ?? value.message ?? 'Could not load your campaigns.'
|
||||
@@ -97,6 +108,13 @@ onMounted(async () => {
|
||||
<NuxtLink to="/worlds/new" class="create-button"><span>+</span> CREATE A UNIVERSE</NuxtLink>
|
||||
</section>
|
||||
|
||||
<section v-if="usage" class="usage-deck" aria-label="AI usage dashboard">
|
||||
<div><small>AI REQUESTS / TODAY</small><b>{{ usage.quota.usedToday }}<em>/ {{ usage.quota.dailyRequestLimit }}</em></b></div>
|
||||
<div><small>TOKENS / 30 DAYS</small><b>{{ compactNumber(usage.totals.inputTokens + usage.totals.outputTokens) }}</b></div>
|
||||
<div><small>EST. COST / 30 DAYS</small><b>${{ usage.totals.costUsd.toFixed(3) }}</b></div>
|
||||
<div><small>AVG. AI LATENCY</small><b>{{ (usage.totals.averageLatencyMs / 1000).toFixed(1) }}s</b></div>
|
||||
</section>
|
||||
|
||||
<div class="filter-row">
|
||||
<button v-for="item in ['all','active','drafts']" :key="item" :class="{active:filter===item}" @click="filter=item as typeof filter">{{ item }}</button>
|
||||
<span>{{ campaigns.length }} CAMPAIGNS · {{ drafts.length }} DRAFTS</span>
|
||||
@@ -152,6 +170,7 @@ onMounted(async () => {
|
||||
.dash-head p{color:var(--muted)}
|
||||
.create-button{display:flex;align-items:center;gap:18px;padding:18px 22px;background:var(--acid);color:#090909;text-decoration:none;font:600 10px var(--mono);letter-spacing:.12em}
|
||||
.create-button span{font-size:20px}
|
||||
.usage-deck{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin-top:34px;border:1px solid var(--line);background:#0d0d0c}.usage-deck>div{display:grid;gap:10px;padding:18px 20px;border-right:1px solid var(--line)}.usage-deck>div:last-child{border-right:0}.usage-deck small{font:500 7px var(--mono);letter-spacing:.14em;color:var(--muted)}.usage-deck b{font:600 20px var(--mono);color:var(--acid)}.usage-deck em{margin-left:4px;color:var(--muted);font:500 9px var(--mono);font-style:normal}
|
||||
.filter-row{display:flex;align-items:center;gap:10px;margin:58px 0 24px;border-bottom:1px solid var(--line)}
|
||||
.filter-row button{padding:0 4px 16px;margin-right:18px;background:none;border:0;color:var(--muted);font:500 9px var(--mono);text-transform:uppercase;letter-spacing:.14em}
|
||||
.filter-row button.active{color:var(--ink);border-bottom:2px solid var(--acid)}
|
||||
@@ -188,6 +207,6 @@ onMounted(async () => {
|
||||
.rename-panel input:focus{border-color:var(--acid)}
|
||||
.rename-actions{display:flex;justify-content:flex-end;gap:10px}
|
||||
@media(max-width:1050px){.world-grid{grid-template-columns:1fr}}
|
||||
@media(max-width:850px){.dash-head{align-items:start;flex-direction:column}.active-world{grid-template-columns:1fr}.world-art{min-height:280px}.system-strip{gap:16px;flex-wrap:wrap}}
|
||||
@media(max-width:850px){.dash-head{align-items:start;flex-direction:column}.usage-deck{grid-template-columns:repeat(2,1fr)}.usage-deck>div:nth-child(2){border-right:0}.usage-deck>div:nth-child(-n+2){border-bottom:1px solid var(--line)}.active-world{grid-template-columns:1fr}.world-art{min-height:280px}.system-strip{gap:16px;flex-wrap:wrap}}
|
||||
@media(max-width:520px){.dash-head h1{font-size:clamp(34px,12vw,52px)}.create-button{width:100%;justify-content:center}.filter-row>span{display:none}.world-info,.new-card{padding:28px}.active-world{min-height:580px}.world-info div{align-items:flex-start;flex-direction:column}.system-strip{flex-direction:column}}
|
||||
</style>
|
||||
|
||||
@@ -40,7 +40,7 @@ export default defineEventHandler(async (event) => {
|
||||
const visibleMembers = members.map(member => ({ ...member, profile: profileById.get(String(member.user_id)) ?? null }))
|
||||
const visibleRounds = rounds.slice().reverse()
|
||||
const visibleRoundIds = visibleRounds.map(item => String(item.id))
|
||||
const [intents, events, diceRolls] = await Promise.all([
|
||||
const [intents, events, diceRolls, sceneStates, questStates, relationships, memories, storySummaries, auditEntries] = await Promise.all([
|
||||
round ? stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`player_intents?select=id,round_id,member_id,character_id,action,ready,created_at,updated_at&round_id=eq.${String(round.id)}&order=created_at.asc`,
|
||||
) : Promise.resolve([]),
|
||||
@@ -50,8 +50,42 @@ export default defineEventHandler(async (event) => {
|
||||
visibleRoundIds.length ? stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`dice_rolls?select=id,round_id,actor_id,target_id,check_kind,formula,rolls,kept,modifier,total,difficulty,success,created_at&round_id=in.(${visibleRoundIds.join(',')})&order=created_at.asc`,
|
||||
) : Promise.resolve([]),
|
||||
stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`scene_states?select=campaign_id,location_id,summary,active_entity_ids,tags,through_round,updated_at&campaign_id=eq.${campaignId}&limit=1`,
|
||||
),
|
||||
stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`quest_states?select=campaign_id,quest_entity_id,status,summary,tags,through_round,updated_at&campaign_id=eq.${campaignId}&order=updated_at.desc`,
|
||||
),
|
||||
stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`relationships?select=source_entity_id,target_entity_id,score,notes&campaign_id=eq.${campaignId}&order=score.desc&limit=30`,
|
||||
),
|
||||
stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`memories?select=id,round_id,summary,importance,tags,entity_ids,created_at&campaign_id=eq.${campaignId}&order=importance.desc,created_at.desc&limit=20`,
|
||||
),
|
||||
stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`story_summaries?select=id,through_round,summary,created_at&campaign_id=eq.${campaignId}&order=through_round.desc&limit=1`,
|
||||
),
|
||||
access.owner ? stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`audit_entries?select=id,actor_id,action,entity_type,entity_id,before_state,after_state,created_at&campaign_id=eq.${campaignId}&order=created_at.desc&limit=50`,
|
||||
) : Promise.resolve([]),
|
||||
])
|
||||
return { campaign, world: worlds[0] ?? null, members: visibleMembers, characters, round, rounds: visibleRounds, intents, events: events.slice().reverse(), diceRolls }
|
||||
return {
|
||||
campaign,
|
||||
world: worlds[0] ?? null,
|
||||
members: visibleMembers,
|
||||
characters,
|
||||
round,
|
||||
rounds: visibleRounds,
|
||||
intents,
|
||||
events: events.slice().reverse(),
|
||||
diceRolls,
|
||||
sceneState: sceneStates[0] ?? null,
|
||||
activeGoals: questStates.filter(quest => quest.status === 'active'),
|
||||
relationships,
|
||||
memories,
|
||||
storySummary: storySummaries[0] ?? null,
|
||||
auditEntries,
|
||||
}
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
requireCampaignAccess,
|
||||
requireStageTwoSafeText,
|
||||
requireStageTwoUser,
|
||||
stageTwoApiError,
|
||||
stageTwoRpc,
|
||||
stageTwoUuid,
|
||||
} from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const BodySchema = z.object({
|
||||
hp: z.number().int().min(0).optional(),
|
||||
inventory: z.array(z.string().trim().min(1).max(120)).max(50).optional(),
|
||||
statuses: z.array(z.string().trim().min(1).max(80)).max(12).optional(),
|
||||
reason: z.string().trim().min(3).max(500),
|
||||
}).strict().refine(body => body.hp !== undefined || body.inventory !== undefined || body.statuses !== undefined, {
|
||||
message: 'At least one state field is required.',
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const campaignId = stageTwoUuid(getRouterParam(event, 'id'), 'campaign id')
|
||||
const characterId = stageTwoUuid(getRouterParam(event, 'characterId'), 'character id')
|
||||
await requireCampaignAccess(campaignId, user.id, true)
|
||||
const body = BodySchema.parse(await readBody(event))
|
||||
requireStageTwoSafeText([body.reason, ...(body.inventory ?? []), ...(body.statuses ?? [])].join('\n'))
|
||||
const { reason, ...patch } = body
|
||||
const state = await stageTwoRpc<Record<string, unknown>>('stage_six_adjust_character_state', {
|
||||
p_campaign_id: campaignId,
|
||||
p_owner_id: user.id,
|
||||
p_character_id: characterId,
|
||||
p_patch: patch,
|
||||
p_reason: reason,
|
||||
})
|
||||
return { characterId, state }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
requireCampaignAccess,
|
||||
requireStageTwoSafeText,
|
||||
requireStageTwoUser,
|
||||
stageSixConsumeAiQuota,
|
||||
stageSixRecordAiUsage,
|
||||
stageTwoApiError,
|
||||
stageTwoDatabase,
|
||||
stageTwoUuid,
|
||||
@@ -48,8 +50,10 @@ export default defineEventHandler(async (event) => {
|
||||
jsonSchema: CharacterDraftSchema.toJSONSchema(),
|
||||
})
|
||||
|
||||
await stageSixConsumeAiQuota(user.id, campaignId, 'character-draft')
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 30_000)
|
||||
const startedAt = Date.now()
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(completion.endpoint, {
|
||||
@@ -62,10 +66,14 @@ export default defineEventHandler(async (event) => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
if (!response.ok) throw createError({ statusCode: 502, statusMessage: 'The character coauthor is temporarily unavailable.' })
|
||||
const payload = await response.json()
|
||||
await stageSixRecordAiUsage({
|
||||
userId: user.id, campaignId, requestKind: 'character-draft', completion, payload, latencyMs: Date.now() - startedAt,
|
||||
}).catch(error => console.error('[web] could not record character draft usage:', error))
|
||||
|
||||
let character
|
||||
try {
|
||||
character = CharacterDraftSchema.parse(parseJsonCompletion(await response.json()))
|
||||
character = CharacterDraftSchema.parse(parseJsonCompletion(payload))
|
||||
} catch {
|
||||
throw createError({ statusCode: 502, statusMessage: 'The coauthor returned an invalid character draft.' })
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { requireCampaignAccess, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
import { requireCampaignAccess, requireStageTwoUser, stageSixConsumeAiQuota, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
@@ -11,6 +11,7 @@ export default defineEventHandler(async (event) => {
|
||||
)
|
||||
if (!rounds[0]) throw createError({ statusCode: 404, statusMessage: 'Round not found.' })
|
||||
if (rounds[0].status !== 'failed') throw createError({ statusCode: 409, statusMessage: 'Only a failed round can be retried.' })
|
||||
await stageSixConsumeAiQuota(user.id, campaignId, 'retry-round')
|
||||
const jobId = await stageTwoRpc<string>('stage_two_retry_failed_round', {
|
||||
p_round_id: roundId,
|
||||
p_owner_id: user.id,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
import { buildJsonCompletion, parseJsonCompletion } from '~/server/utils/ai-provider'
|
||||
import { CoauthorQuestionSchema, StoredCoauthorQuestionSchema } from '~/server/utils/coauthor-question'
|
||||
import { requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
import { requireStageTwoSafeText, requireStageTwoUser, stageSixConsumeAiQuota, stageSixRecordAiUsage, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const MessageSchema = z.object({ role: z.enum(['user', 'assistant']), content: z.string().min(1).max(5000) })
|
||||
|
||||
@@ -34,8 +34,10 @@ export default defineEventHandler(async (event) => {
|
||||
schemaName: 'coauthor_question',
|
||||
jsonSchema: CoauthorQuestionSchema.toJSONSchema(),
|
||||
})
|
||||
await stageSixConsumeAiQuota(user.id, null, 'coauthor-question')
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 30_000)
|
||||
const startedAt = Date.now()
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(completion.endpoint, {
|
||||
@@ -45,8 +47,12 @@ export default defineEventHandler(async (event) => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
if (!response.ok) throw createError({ statusCode: 502, statusMessage: 'The coauthor is temporarily unavailable.' })
|
||||
const payload = await response.json()
|
||||
await stageSixRecordAiUsage({
|
||||
userId: user.id, campaignId: null, requestKind: 'coauthor-question', completion, payload, latencyMs: Date.now() - startedAt,
|
||||
}).catch(error => console.error('[web] could not record coauthor question usage:', error))
|
||||
try {
|
||||
const parsed = CoauthorQuestionSchema.safeParse(parseJsonCompletion(await response.json()))
|
||||
const parsed = CoauthorQuestionSchema.safeParse(parseJsonCompletion(payload))
|
||||
if (parsed.success) question = parsed.data
|
||||
} catch {
|
||||
// Retry once when the provider ignores structured-output requirements.
|
||||
|
||||
61
apps/web/server/api/v1/usage.get.ts
Normal file
61
apps/web/server/api/v1/usage.get.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { requireStageTwoUser, stageTwoApiError, stageTwoDatabase } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
interface UsageRow {
|
||||
campaign_id: string | null
|
||||
request_kind: string
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cost_usd: number | string
|
||||
latency_ms: number | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const since = new Date()
|
||||
since.setUTCDate(since.getUTCDate() - 30)
|
||||
const today = new Date()
|
||||
today.setUTCHours(0, 0, 0, 0)
|
||||
const [usage, quotaEvents] = await Promise.all([
|
||||
stageTwoDatabase<UsageRow[]>(
|
||||
`ai_usage?select=campaign_id,request_kind,input_tokens,output_tokens,cost_usd,latency_ms,created_at&user_id=eq.${user.id}&created_at=gte.${encodeURIComponent(since.toISOString())}&order=created_at.desc&limit=1000`,
|
||||
),
|
||||
stageTwoDatabase<Array<{ created_at: string }>>(
|
||||
`ai_quota_events?select=created_at&user_id=eq.${user.id}&created_at=gte.${encodeURIComponent(today.toISOString())}`,
|
||||
),
|
||||
])
|
||||
const campaignIds = [...new Set(usage.flatMap(row => row.campaign_id ? [row.campaign_id] : []))]
|
||||
const campaigns = campaignIds.length
|
||||
? await stageTwoDatabase<Array<{ id: string; title: string }>>(`campaigns?select=id,title&id=in.(${campaignIds.join(',')})`)
|
||||
: []
|
||||
const campaignNames = new Map(campaigns.map(campaign => [campaign.id, campaign.title]))
|
||||
const summarize = (rows: UsageRow[]) => {
|
||||
const latencies = rows.flatMap(row => row.latency_ms === null ? [] : [Number(row.latency_ms)])
|
||||
return {
|
||||
requests: rows.length,
|
||||
inputTokens: rows.reduce((sum, row) => sum + Number(row.input_tokens), 0),
|
||||
outputTokens: rows.reduce((sum, row) => sum + Number(row.output_tokens), 0),
|
||||
costUsd: rows.reduce((sum, row) => sum + Number(row.cost_usd), 0),
|
||||
averageLatencyMs: latencies.length ? Math.round(latencies.reduce((sum, value) => sum + value, 0) / latencies.length) : 0,
|
||||
}
|
||||
}
|
||||
const grouped = new Map<string, UsageRow[]>()
|
||||
for (const row of usage) {
|
||||
const key = row.campaign_id ?? 'coauthor'
|
||||
grouped.set(key, [...(grouped.get(key) ?? []), row])
|
||||
}
|
||||
return {
|
||||
periodDays: 30,
|
||||
quota: { usedToday: quotaEvents.length, dailyRequestLimit: 60, dailyTokenLimit: 250000 },
|
||||
totals: summarize(usage),
|
||||
groups: [...grouped].map(([id, rows]) => ({
|
||||
id,
|
||||
label: id === 'coauthor' ? 'World coauthor' : campaignNames.get(id) ?? 'Private campaign',
|
||||
...summarize(rows),
|
||||
})).sort((left, right) => right.costUsd - left.costUsd),
|
||||
}
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
@@ -1,9 +1,9 @@
|
||||
import { CreateWorldRequestSchema, WorldStarterSchema, moderate13Plus } from '@dng/shared'
|
||||
import { buildJsonCompletion, parseJsonCompletion } from '../../utils/ai-provider'
|
||||
import { requireStageTwoUser } from '../../utils/stage-two-supabase'
|
||||
import { requireStageTwoUser, stageSixConsumeAiQuota, stageSixRecordAiUsage } from '../../utils/stage-two-supabase'
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
await requireStageTwoUser(event)
|
||||
const user = await requireStageTwoUser(event)
|
||||
const raw = await readBody(event)
|
||||
const prompt = typeof raw?.prompt === 'string' ? raw.prompt : ''
|
||||
const answers = raw?.answers && typeof raw.answers === 'object' ? raw.answers : {}
|
||||
@@ -24,15 +24,21 @@ export default defineEventHandler(async event => {
|
||||
throw createError({ statusCode: 500, statusMessage: error instanceof Error ? error.message : 'Invalid AI provider configuration.' })
|
||||
}
|
||||
|
||||
await stageSixConsumeAiQuota(user.id, null, 'world-preview')
|
||||
const startedAt = Date.now()
|
||||
const response = await fetch(completion.endpoint, {
|
||||
method: 'POST',
|
||||
headers: completion.headers,
|
||||
body: JSON.stringify(completion.body),
|
||||
})
|
||||
if (!response.ok) throw createError({ statusCode: 502, statusMessage: 'The coauthor is temporarily unavailable.' })
|
||||
const payload = await response.json()
|
||||
await stageSixRecordAiUsage({
|
||||
userId: user.id, campaignId: null, requestKind: 'world-preview', completion, payload, latencyMs: Date.now() - startedAt,
|
||||
}).catch(error => console.error('[web] could not record world preview usage:', error))
|
||||
let world
|
||||
try {
|
||||
world = WorldStarterSchema.parse(parseJsonCompletion(await response.json()))
|
||||
world = WorldStarterSchema.parse(parseJsonCompletion(payload))
|
||||
} catch {
|
||||
throw createError({ statusCode: 502, statusMessage: 'The coauthor returned an invalid world structure.' })
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildJsonCompletion, parseJsonCompletion, resolveAiProvider } from './ai-provider'
|
||||
import { buildJsonCompletion, parseAiUsage, parseJsonCompletion, resolveAiProvider } from './ai-provider'
|
||||
|
||||
const request = { system: 'Return JSON.', messages: [{ role: 'user' as const, content: 'Create a world' }], schemaName: 'world', jsonSchema: { type: 'object' } }
|
||||
|
||||
@@ -22,4 +22,11 @@ describe('AI provider adapter', () => {
|
||||
expect(() => parseJsonCompletion({ choices: [] })).toThrow()
|
||||
expect(parseJsonCompletion({ choices: [{ message: { content: '{"ok":true}' } }] })).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('normalizes provider usage telemetry', () => {
|
||||
expect(parseAiUsage({
|
||||
choices: [{ message: { content: '{}' } }],
|
||||
usage: { prompt_tokens: 120, completion_tokens: 30, cost: 0.0042 },
|
||||
})).toEqual({ inputTokens: 120, outputTokens: 30, costUsd: 0.0042 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,14 @@ const CompletionResponseSchema = z.object({
|
||||
choices: z.array(z.object({
|
||||
message: z.object({ content: z.string().min(1) }),
|
||||
})).min(1),
|
||||
usage: z.object({
|
||||
prompt_tokens: z.number().nonnegative().nullish(),
|
||||
completion_tokens: z.number().nonnegative().nullish(),
|
||||
input_tokens: z.number().nonnegative().nullish(),
|
||||
output_tokens: z.number().nonnegative().nullish(),
|
||||
cost: z.number().nonnegative().nullish(),
|
||||
total_cost: z.number().nonnegative().nullish(),
|
||||
}).optional(),
|
||||
})
|
||||
|
||||
export type AiProvider = z.infer<typeof ProviderSchema>
|
||||
@@ -26,10 +34,18 @@ export interface JsonCompletionRequest {
|
||||
jsonSchema: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface JsonCompletion {
|
||||
export interface JsonCompletion {
|
||||
endpoint: string
|
||||
headers: Record<string, string>
|
||||
body: Record<string, unknown>
|
||||
provider: AiProvider
|
||||
model: string
|
||||
}
|
||||
|
||||
export interface AiUsageSnapshot {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
costUsd: number
|
||||
}
|
||||
|
||||
const nonEmptyString = (value: unknown, name: string) => {
|
||||
@@ -73,6 +89,8 @@ export function buildJsonCompletion(config: AiRuntimeConfig, request: JsonComple
|
||||
if (provider.provider === 'deepseek') {
|
||||
return {
|
||||
endpoint: provider.endpoint,
|
||||
provider: provider.provider,
|
||||
model: provider.model,
|
||||
headers: { Authorization: `Bearer ${provider.apiKey}`, 'Content-Type': 'application/json' },
|
||||
body: {
|
||||
...common,
|
||||
@@ -88,6 +106,8 @@ export function buildJsonCompletion(config: AiRuntimeConfig, request: JsonComple
|
||||
|
||||
return {
|
||||
endpoint: provider.endpoint,
|
||||
provider: provider.provider,
|
||||
model: provider.model,
|
||||
headers: { Authorization: `Bearer ${provider.apiKey}`, 'Content-Type': 'application/json', 'X-Title': 'Dungeons & Ground' },
|
||||
body: {
|
||||
...common,
|
||||
@@ -101,3 +121,12 @@ export function parseJsonCompletion(payload: unknown): unknown {
|
||||
const completion = CompletionResponseSchema.parse(payload)
|
||||
return JSON.parse(completion.choices[0]!.message.content)
|
||||
}
|
||||
|
||||
export function parseAiUsage(payload: unknown): AiUsageSnapshot {
|
||||
const completion = CompletionResponseSchema.parse(payload)
|
||||
return {
|
||||
inputTokens: Math.max(0, Math.trunc(completion.usage?.prompt_tokens ?? completion.usage?.input_tokens ?? 0)),
|
||||
outputTokens: Math.max(0, Math.trunc(completion.usage?.completion_tokens ?? completion.usage?.output_tokens ?? 0)),
|
||||
costUsd: Math.max(0, completion.usage?.cost ?? completion.usage?.total_cost ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { z } from 'zod'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { H3Event } from 'h3'
|
||||
import { moderate13Plus } from '@dng/shared'
|
||||
import { parseAiUsage, type JsonCompletion } from './ai-provider'
|
||||
|
||||
const UuidSchema = z.string().uuid()
|
||||
const AuthEmailSchema = z.preprocess(
|
||||
@@ -87,6 +89,41 @@ export function stageTwoRpc<T>(name: string, body: Record<string, unknown>): Pro
|
||||
return stageTwoDatabase<T>(`rpc/${name}`, { method: 'POST', body: JSON.stringify(body) })
|
||||
}
|
||||
|
||||
export function stageSixConsumeAiQuota(userId: string, campaignId: string | null, requestKind: string) {
|
||||
return stageTwoRpc<{ userRemaining: number; campaignRemaining: number | null; tokenRemaining: number }>('stage_six_consume_ai_quota', {
|
||||
p_user_id: userId,
|
||||
p_campaign_id: campaignId,
|
||||
p_request_kind: requestKind,
|
||||
})
|
||||
}
|
||||
|
||||
export async function stageSixRecordAiUsage(input: {
|
||||
userId: string
|
||||
campaignId: string | null
|
||||
requestKind: string
|
||||
completion: JsonCompletion
|
||||
payload: unknown
|
||||
latencyMs: number
|
||||
}): Promise<void> {
|
||||
const usage = parseAiUsage(input.payload)
|
||||
await stageTwoDatabase('ai_usage', {
|
||||
method: 'POST',
|
||||
prefer: 'return=minimal',
|
||||
body: JSON.stringify({
|
||||
usage_key: `${input.requestKind}:${randomUUID()}`,
|
||||
user_id: input.userId,
|
||||
campaign_id: input.campaignId,
|
||||
provider: input.completion.provider,
|
||||
request_kind: input.requestKind,
|
||||
model: input.completion.model,
|
||||
input_tokens: usage.inputTokens,
|
||||
output_tokens: usage.outputTokens,
|
||||
cost_usd: usage.costUsd,
|
||||
latency_ms: Math.max(0, Math.trunc(input.latencyMs)),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function requireStageTwoUser(event: H3Event): Promise<StageTwoUser> {
|
||||
const authorization = getHeader(event, 'authorization')
|
||||
if (!authorization?.startsWith('Bearer ') || authorization.length <= 7) {
|
||||
@@ -159,7 +196,8 @@ export function stageTwoApiError(error: unknown): never {
|
||||
})
|
||||
}
|
||||
const conflict = /duplicate key|already exists|not claimable/i.test(error.message)
|
||||
throw createError({ statusCode: conflict ? 409 : error.status >= 500 ? 502 : 400, statusMessage: error.message })
|
||||
const quota = /AI (?:rate limit|.*quota)/i.test(error.message)
|
||||
throw createError({ statusCode: quota ? 429 : conflict ? 409 : error.status >= 500 ? 502 : 400, statusMessage: error.message })
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
throw createError({ statusCode: 400, statusMessage: error.issues.map(issue => issue.message).join('; ') })
|
||||
|
||||
Reference in New Issue
Block a user