@read-frog/api-contract 0.12.5 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@read-frog/api-contract",
3
3
  "type": "module",
4
- "version": "0.12.5",
4
+ "version": "0.14.0",
5
5
  "private": false,
6
6
  "repository": {
7
7
  "type": "git",
@@ -48,6 +48,64 @@ export const hostedAiContract = {
48
48
  .output(eventIterator(HostedAiStreamPartSchema))
49
49
  .errors(hostedAiStreamErrors),
50
50
  },
51
+ videoSubtitles: {
52
+ // Subtitle lines and the video summary share this route: both are short
53
+ // text and both bill against `videoSubtitles`.
54
+ streamText: oc
55
+ .route({
56
+ method: "POST",
57
+ path: "/hosted-ai/video-subtitles/stream-text",
58
+ summary: "Stream hosted subtitle translation or video summary text output",
59
+ tags: ["Hosted AI"],
60
+ })
61
+ .input(HostedAiStreamTextInputSchema)
62
+ .output(eventIterator(HostedAiStreamPartSchema))
63
+ .errors(hostedAiStreamErrors),
64
+ /**
65
+ * Segmentation shares the `videoSubtitles` feature but not its route,
66
+ * because `endpointMaxOutputTokens` sizes the credit reservation: emitting
67
+ * a WebVTT block needs several times a subtitle line's budget, and folding
68
+ * both into one route would make every line reserve — and, near the end of
69
+ * a credit window, fail to afford — what only segmentation needs.
70
+ */
71
+ streamSegmentation: oc
72
+ .route({
73
+ method: "POST",
74
+ path: "/hosted-ai/video-subtitles/stream-segmentation",
75
+ summary: "Stream hosted subtitle segmentation output",
76
+ tags: ["Hosted AI"],
77
+ })
78
+ .input(HostedAiStreamTextInputSchema)
79
+ .output(eventIterator(HostedAiStreamPartSchema))
80
+ .errors(hostedAiStreamErrors),
81
+ },
82
+ inputTranslation: {
83
+ streamText: oc
84
+ .route({
85
+ method: "POST",
86
+ path: "/hosted-ai/input-translation/stream-text",
87
+ summary: "Stream hosted input box translation text output",
88
+ tags: ["Hosted AI"],
89
+ })
90
+ .input(HostedAiStreamTextInputSchema)
91
+ .output(eventIterator(HostedAiStreamPartSchema))
92
+ .errors(hostedAiStreamErrors),
93
+ },
94
+ languageDetection: {
95
+ // Detection wants one short string back, not a stream, but the quota
96
+ // reserve/settle path is stream-shaped end to end. Reusing it keeps one
97
+ // metering implementation; the client collects the deltas.
98
+ streamText: oc
99
+ .route({
100
+ method: "POST",
101
+ path: "/hosted-ai/language-detection/stream-text",
102
+ summary: "Stream hosted language detection output",
103
+ tags: ["Hosted AI"],
104
+ })
105
+ .input(HostedAiStreamTextInputSchema)
106
+ .output(eventIterator(HostedAiStreamPartSchema))
107
+ .errors(hostedAiStreamErrors),
108
+ },
51
109
  customAction: {
52
110
  streamStructuredObject: oc
53
111
  .route({
@@ -10,7 +10,12 @@ import {
10
10
 
11
11
  export const videoTranscriptContract = {
12
12
  create: oc
13
- .errors(pickPublicErrorMap("VIDEO_TRANSCRIPTION_QUOTA_EXCEEDED"))
13
+ .errors(pickPublicErrorMap(
14
+ "VIDEO_TRANSCRIPTION_QUOTA_EXCEEDED",
15
+ "VIDEO_TRANSCRIPTION_SUBSCRIPTION_REQUIRED",
16
+ "VIDEO_TRANSCRIPTION_PAYMENT_REQUIRED",
17
+ "VIDEO_TRANSCRIPTION_UNSUPPORTED_LENGTH",
18
+ ))
14
19
  .route({
15
20
  method: "POST",
16
21
  path: "/video-transcripts",
@@ -28,7 +33,7 @@ export const videoTranscriptContract = {
28
33
  })
29
34
  .output(videoTranscriptUsageSchema),
30
35
  get: oc
31
- .errors(pickPublicErrorMap("VIDEO_TRANSCRIPT_NOT_FOUND"))
36
+ .errors(pickPublicErrorMap("VIDEO_TRANSCRIPT_NOT_FOUND", "VIDEO_TRANSCRIPTION_SUBSCRIPTION_REQUIRED"))
32
37
  .route({
33
38
  method: "GET",
34
39
  path: "/video-transcripts/{id}",
@@ -38,7 +43,7 @@ export const videoTranscriptContract = {
38
43
  .input(videoTranscriptGetInputSchema)
39
44
  .output(videoTranscriptResourceSchema),
40
45
  getSubtitles: oc
41
- .errors(pickPublicErrorMap("VIDEO_TRANSCRIPT_NOT_FOUND", "VIDEO_TRANSCRIPT_NOT_READY"))
46
+ .errors(pickPublicErrorMap("VIDEO_TRANSCRIPT_NOT_FOUND", "VIDEO_TRANSCRIPT_NOT_READY", "VIDEO_TRANSCRIPTION_SUBSCRIPTION_REQUIRED"))
42
47
  .route({
43
48
  method: "GET",
44
49
  path: "/video-transcripts/{id}/subtitles",
@@ -73,10 +73,26 @@ export const PUBLIC_APP_ERROR_DEFS = {
73
73
  message: "Notebase not found",
74
74
  status: 404,
75
75
  },
76
+ // Retired at launch (2026-08-14) but kept defined for one release so old
77
+ // clients' error maps keep parsing; the server no longer emits it. Remove
78
+ // together with the beta feature key in the follow-up cleanup.
76
79
  VIDEO_TRANSCRIPTION_BETA_RESTRICTED: {
77
80
  message: "Video transcription is currently in beta for selected accounts",
78
81
  status: 403,
79
82
  },
83
+ VIDEO_TRANSCRIPTION_SUBSCRIPTION_REQUIRED: {
84
+ message: "AI subtitles require a Pro or Ultra subscription",
85
+ status: 403,
86
+ },
87
+ // Dunning (`past_due` / `paused`), not cancellation: the account keeps
88
+ // reading subtitles it already paid to generate, but cannot start new work
89
+ // that spends upstream money until the payment method is fixed. Distinct
90
+ // from SUBSCRIPTION_REQUIRED so the client can say "update your card"
91
+ // instead of "subscribe".
92
+ VIDEO_TRANSCRIPTION_PAYMENT_REQUIRED: {
93
+ message: "Update your payment method to generate new AI subtitles",
94
+ status: 402,
95
+ },
80
96
  VIDEO_TRANSCRIPT_NOT_FOUND: {
81
97
  message: "Video transcript not found",
82
98
  status: 404,
@@ -89,6 +105,13 @@ export const PUBLIC_APP_ERROR_DEFS = {
89
105
  message: "Monthly transcription quota exceeded",
90
106
  status: 429,
91
107
  },
108
+ // A property of the video, not of the account: upgrading or waiting for the
109
+ // quota to reset changes nothing. Kept apart from QUOTA_EXCEEDED so the
110
+ // client does not answer it with an upgrade wall.
111
+ VIDEO_TRANSCRIPTION_UNSUPPORTED_LENGTH: {
112
+ message: "This video is longer than AI subtitles support",
113
+ status: 422,
114
+ },
92
115
  NOTEBASE_COLUMN_NOT_FOUND: {
93
116
  message: "Notebase column not found",
94
117
  status: 404,
@@ -53,7 +53,13 @@ export const billingStatusSchema = z.object({
53
53
  status: billingSubscriptionStatusSchema.nullable(),
54
54
  interval: billingIntervalSchema.nullable(),
55
55
  currentPeriodEnd: z.coerce.date().nullable(),
56
- cancelAtPeriodEnd: z.boolean(),
56
+ /**
57
+ * When the subscription terminates, or null if it renews. Replaces the
58
+ * `cancelAtPeriodEnd` boolean: Stripe deprecated its counterpart and the
59
+ * Customer Portal no longer sets it, and the termination date is not always
60
+ * `currentPeriodEnd` — a stacked pass pushes that past the cancellation.
61
+ */
62
+ cancelAt: z.coerce.date().nullable(),
57
63
  accessSource: billingAccessSourceSchema.nullable(),
58
64
  scheduledChange: billingScheduledChangeSchema.nullable(),
59
65
  unlimitedNotebaseNotes: z.boolean(),
@@ -9,14 +9,28 @@ export const HostedAiOutputFieldSchema = z.strictObject({
9
9
 
10
10
  const HostedAiInstructionsSchema = z.string().trim().min(1).max(16000)
11
11
 
12
- export const HostedAiModelTierSchema = z.enum(["normal", "ultra"])
12
+ /**
13
+ * Which hosted model a request runs on. Deliberately NOT named after a billing
14
+ * plan: which plans may reach which tier is policy (see `access-policy.ts`),
15
+ * not identity, so a future plan can be sold with any tier without a rename.
16
+ */
17
+ export const HostedAiModelTierSchema = z.enum(["normal", "advance"])
18
+ /**
19
+ * The billing/access subject of a hosted request, not a route id. Sub-calls
20
+ * bill against the feature they serve: the page summary is `pageTranslation`,
21
+ * and the video summary and subtitle segmentation are `videoSubtitles`.
22
+ *
23
+ * Appended last on purpose: the db pgEnum mirrors this order, and appending
24
+ * keeps drizzle from emitting the ADD VALUE ... BEFORE variant.
25
+ */
13
26
  export const HostedAiFeatureSchema = z.enum([
14
27
  "pageTranslation",
15
28
  "customAction",
16
29
  "noteSuggestion",
17
- // Appended last on purpose: the db pgEnum mirrors this order, and appending
18
- // keeps drizzle from emitting the ADD VALUE ... BEFORE variant.
19
30
  "selectionTranslation",
31
+ "videoSubtitles",
32
+ "inputTranslation",
33
+ "languageDetection",
20
34
  ])
21
35
  export const HostedAiRequestIdSchema = z.uuid()
22
36
 
@@ -210,7 +224,7 @@ export const HostedAiTierStatusSchema = z.strictObject({
210
224
 
211
225
  export const HostedAiFeatureStatusSchema = z.strictObject({
212
226
  normal: HostedAiTierStatusSchema,
213
- ultra: HostedAiTierStatusSchema,
227
+ advance: HostedAiTierStatusSchema,
214
228
  })
215
229
 
216
230
  export const HostedAiStatusOutputSchema = z.strictObject({
@@ -220,6 +234,9 @@ export const HostedAiStatusOutputSchema = z.strictObject({
220
234
  customAction: HostedAiFeatureStatusSchema,
221
235
  noteSuggestion: HostedAiFeatureStatusSchema,
222
236
  selectionTranslation: HostedAiFeatureStatusSchema,
237
+ videoSubtitles: HostedAiFeatureStatusSchema,
238
+ inputTranslation: HostedAiFeatureStatusSchema,
239
+ languageDetection: HostedAiFeatureStatusSchema,
223
240
  }),
224
241
  })
225
242
 
@@ -1,5 +1,6 @@
1
1
  import { getVideoIdFromUrl, transcriptSegmentSchema } from "@read-frog/definitions"
2
2
  import { z } from "zod"
3
+ import { billingPlanSchema } from "./billing"
3
4
 
4
5
  export const videoTranscriptStatusSchema = z.enum([
5
6
  "pending",
@@ -35,6 +36,12 @@ export type VideoTranscriptSubtitles = z.infer<typeof videoTranscriptSubtitlesSc
35
36
 
36
37
  export const videoTranscriptCreateInputSchema = z.object({
37
38
  url: z.url().refine(url => getVideoIdFromUrl(url) !== null, { message: "url must be a supported video URL" }),
39
+ // The player's own duration, read from the media element. REQUIRED, and an
40
+ // untrusted UX hint only: admission uses it to reject an over-quota request
41
+ // instantly, but every reservation and charge is made against the
42
+ // server-measured duration in the worker — lying here changes nothing about
43
+ // what gets billed.
44
+ durationSec: z.number().int().positive(),
38
45
  }).strict()
39
46
  export type VideoTranscriptCreateInput = z.infer<typeof videoTranscriptCreateInputSchema>
40
47
 
@@ -43,10 +50,31 @@ export const videoTranscriptGetInputSchema = z.object({
43
50
  }).strict()
44
51
  export type VideoTranscriptGetInput = z.infer<typeof videoTranscriptGetInputSchema>
45
52
 
46
- // The caller's monthly transcription usage against the beta quota.
53
+ // One quota pool as shown to the user. `resetAt` is null for a one-time grant
54
+ // (it never resets); `expiresAt` is null for a monthly pool (its end is the
55
+ // reset). Minutes are display units: `usedMinutes` rounds up and
56
+ // `limitMinutes` rounds down, so the UI can never show headroom that
57
+ // admission (which compares raw seconds) would deny.
58
+ export const videoTranscriptUsagePoolSchema = z.object({
59
+ id: z.string(),
60
+ usedMinutes: z.number().int(),
61
+ limitMinutes: z.number().int(),
62
+ remainingMinutes: z.number().int(),
63
+ resetAt: z.iso.datetime().nullable(),
64
+ expiresAt: z.iso.datetime().nullable(),
65
+ })
66
+ export type VideoTranscriptUsagePool = z.infer<typeof videoTranscriptUsagePoolSchema>
67
+
68
+ // The caller's transcription quota usage. Totals aggregate across pools;
69
+ // `remainingMinutes` nets out in-flight reservations (committed), while each
70
+ // pool's `usedMinutes` shows settled spend only, so a progress bar cannot run
71
+ // forward and then backwards when a job is released. Deliberately a non-strict
72
+ // object: old clients strip the keys they don't know.
47
73
  export const videoTranscriptUsageSchema = z.object({
48
74
  usedMinutes: z.number().int(),
49
75
  limitMinutes: z.number().int(),
50
76
  remainingMinutes: z.number().int(),
77
+ plan: billingPlanSchema,
78
+ pools: z.array(videoTranscriptUsagePoolSchema),
51
79
  })
52
80
  export type VideoTranscriptUsage = z.infer<typeof videoTranscriptUsageSchema>