@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/dist/index.d.ts +350 -38
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +94 -11
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/contracts/hosted-ai.ts +58 -0
- package/src/contracts/video-transcript.ts +8 -3
- package/src/public-errors.ts +23 -0
- package/src/schemas/billing.ts +7 -1
- package/src/schemas/hosted-ai.ts +21 -4
- package/src/schemas/video-transcript.ts +29 -1
package/package.json
CHANGED
|
@@ -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(
|
|
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",
|
package/src/public-errors.ts
CHANGED
|
@@ -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,
|
package/src/schemas/billing.ts
CHANGED
|
@@ -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
|
-
|
|
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(),
|
package/src/schemas/hosted-ai.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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>
|