@glassly/cloud-protocol 0.1.0-dev.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/README.md +45 -0
- package/package.json +40 -0
- package/src/audio.ts +134 -0
- package/src/camera.ts +157 -0
- package/src/control.ts +13 -0
- package/src/envelope.ts +48 -0
- package/src/errors.ts +27 -0
- package/src/handshake.ts +60 -0
- package/src/index.ts +19 -0
- package/src/languages.ts +209 -0
- package/src/llm.ts +279 -0
- package/src/maps.ts +199 -0
- package/src/messages.ts +82 -0
package/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# @glassly/cloud-protocol
|
|
2
|
+
|
|
3
|
+
The Glassly phone ↔ cloud wire protocol: message schemas, envelopes, and
|
|
4
|
+
error types shared by everything that speaks to the Glassly cloud. Pure
|
|
5
|
+
TypeScript + [zod](https://github.com/colinhacks/zod) — no server or native
|
|
6
|
+
dependencies.
|
|
7
|
+
|
|
8
|
+
This is a **leaf package**: [`@glassly/cloud-client`](https://www.npmjs.com/package/@glassly/cloud-client)
|
|
9
|
+
and [`@glassly/engine`](https://www.npmjs.com/package/@glassly/engine) depend on
|
|
10
|
+
it, and the Glassly cloud runtime consumes the same schemas server-side, so
|
|
11
|
+
both ends of the wire validate against one definition.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
You normally get this package automatically as a dependency of
|
|
16
|
+
`@glassly/cloud-client` or a peer of `@glassly/engine`. For direct use:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npm install @glassly/cloud-protocol@dev
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
> Currently published on the `dev` dist-tag (prerelease channel).
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import {envelopeSchema, PROTOCOL_MAJOR, type Envelope} from "@glassly/cloud-protocol";
|
|
28
|
+
|
|
29
|
+
const message: Envelope = envelopeSchema.parse(JSON.parse(raw));
|
|
30
|
+
|
|
31
|
+
// or per-module subpaths:
|
|
32
|
+
import {normalizePhotoSizeTier} from "@glassly/cloud-protocol/camera";
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Modules: `envelope`, `messages`, `handshake`, `control`, `camera`, `audio`,
|
|
36
|
+
`maps`, `errors` — each importable as `@glassly/cloud-protocol/<module>`.
|
|
37
|
+
|
|
38
|
+
The package ships TypeScript source (`main: ./src/index.ts`) and targets
|
|
39
|
+
consumers that compile TS themselves (Metro / bundlers / tsc); it is not
|
|
40
|
+
precompiled for plain Node `require`.
|
|
41
|
+
|
|
42
|
+
## Part of Glassly
|
|
43
|
+
|
|
44
|
+
Source lives in the [Glassly monorepo](https://github.com/tetramo-labs/glassly)
|
|
45
|
+
under `cloud-v2/packages/protocol`. Issues and contributions welcome there.
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@glassly/cloud-protocol",
|
|
3
|
+
"version": "0.1.0-dev.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./src/index.ts",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./*": "./src/*.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"!src/**/*.test.ts"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "bun test"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"zod": "^3.24.1"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/tetramo-labs/glassly.git",
|
|
26
|
+
"directory": "cloud-v2/packages/protocol"
|
|
27
|
+
},
|
|
28
|
+
"description": "Glassly phone-cloud wire protocol — message schemas, envelopes and error types (zod)",
|
|
29
|
+
"keywords": [
|
|
30
|
+
"glassly",
|
|
31
|
+
"glassly",
|
|
32
|
+
"protocol",
|
|
33
|
+
"schemas",
|
|
34
|
+
"zod"
|
|
35
|
+
],
|
|
36
|
+
"homepage": "https://github.com/tetramo-labs/glassly/tree/dev/cloud-v2/packages/protocol#readme",
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/tetramo-labs/glassly/issues"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/audio.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Canonical audio wire types for the Glassly Runtime protocol.
|
|
3
|
+
*
|
|
4
|
+
* zod schemas + inferred TS types for the audio service's subscription model
|
|
5
|
+
* and result types. These are the single source of truth; `src/audio.types.ts`
|
|
6
|
+
* re-exports the subscription types from here and adds `subscriptionKey()`.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors docs/issues/002-cloud-runtime/audio/spec.md
|
|
9
|
+
* ("Subscription model" and "Result types"). Pure + isomorphic: no server
|
|
10
|
+
* imports, safe to bundle into the RN client.
|
|
11
|
+
*/
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
|
|
14
|
+
// --- Subscription model -----------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export const languageSourceSchema = z.discriminatedUnion("mode", [
|
|
17
|
+
z.object({ mode: z.literal("specific"), code: z.string() }),
|
|
18
|
+
z.object({ mode: z.literal("auto"), hints: z.array(z.string()).optional() }),
|
|
19
|
+
]);
|
|
20
|
+
export type LanguageSource = z.infer<typeof languageSourceSchema>;
|
|
21
|
+
|
|
22
|
+
export const transcriptionSubscriptionSchema = z.object({
|
|
23
|
+
kind: z.literal("transcription"),
|
|
24
|
+
language: languageSourceSchema,
|
|
25
|
+
});
|
|
26
|
+
export type TranscriptionSubscription = z.infer<
|
|
27
|
+
typeof transcriptionSubscriptionSchema
|
|
28
|
+
>;
|
|
29
|
+
|
|
30
|
+
export const translationSubscriptionSchema = z.object({
|
|
31
|
+
kind: z.literal("translation"),
|
|
32
|
+
source: languageSourceSchema,
|
|
33
|
+
target: z.string(),
|
|
34
|
+
});
|
|
35
|
+
export type TranslationSubscription = z.infer<
|
|
36
|
+
typeof translationSubscriptionSchema
|
|
37
|
+
>;
|
|
38
|
+
|
|
39
|
+
export const audioSubscriptionSchema = z.discriminatedUnion("kind", [
|
|
40
|
+
transcriptionSubscriptionSchema,
|
|
41
|
+
translationSubscriptionSchema,
|
|
42
|
+
]);
|
|
43
|
+
export type AudioSubscription = z.infer<typeof audioSubscriptionSchema>;
|
|
44
|
+
|
|
45
|
+
// --- UDP liveness -----------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
export const UDP_LIVENESS_PROBE_PREFIX = "glassly:udp-probe:";
|
|
48
|
+
|
|
49
|
+
export const udpLivenessAckPayloadSchema = z.object({
|
|
50
|
+
sessionId: z.string(),
|
|
51
|
+
sessionTag: z.number().int(),
|
|
52
|
+
probeId: z.string(),
|
|
53
|
+
receivedAt: z.number().int(),
|
|
54
|
+
});
|
|
55
|
+
export type UdpLivenessAckPayload = z.infer<typeof udpLivenessAckPayloadSchema>;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Push: a metered feature was blocked (or unblocked) by the account's plan
|
|
59
|
+
* quota. `blocked: true` fires when the cloud closes/refuses transcription
|
|
60
|
+
* because the free-tier allowance ran out mid-session; `false` when a later
|
|
61
|
+
* check clears it (upgrade, new billing month). The client uses this to tell
|
|
62
|
+
* the wearer WHY transcription went quiet — without it the lens just stops.
|
|
63
|
+
*/
|
|
64
|
+
export const quotaStatusPayloadSchema = z.object({
|
|
65
|
+
feature: z.enum(["transcription"]),
|
|
66
|
+
blocked: z.boolean(),
|
|
67
|
+
});
|
|
68
|
+
export type QuotaStatusPayload = z.infer<typeof quotaStatusPayloadSchema>;
|
|
69
|
+
|
|
70
|
+
// --- Result types -----------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
export const transcriptionTokenSchema = z.object({
|
|
73
|
+
text: z.string(),
|
|
74
|
+
startMs: z.number(),
|
|
75
|
+
endMs: z.number(),
|
|
76
|
+
confidence: z.number(),
|
|
77
|
+
isFinal: z.boolean(),
|
|
78
|
+
speaker: z.string().optional(),
|
|
79
|
+
// per-token; mid-sentence language switches are possible
|
|
80
|
+
detectedLanguage: z.string().optional(),
|
|
81
|
+
});
|
|
82
|
+
export type TranscriptionToken = z.infer<typeof transcriptionTokenSchema>;
|
|
83
|
+
|
|
84
|
+
export const transcriptionDataSchema = z.object({
|
|
85
|
+
userId: z.string(),
|
|
86
|
+
subscription: transcriptionSubscriptionSchema,
|
|
87
|
+
|
|
88
|
+
// Aggregated for simple consumers
|
|
89
|
+
text: z.string(),
|
|
90
|
+
isFinal: z.boolean(),
|
|
91
|
+
utteranceId: z.string().optional(),
|
|
92
|
+
speakerId: z.string().optional(),
|
|
93
|
+
startMs: z.number(),
|
|
94
|
+
endMs: z.number(),
|
|
95
|
+
durationMs: z.number().optional(),
|
|
96
|
+
confidence: z.number().optional(),
|
|
97
|
+
|
|
98
|
+
// Language resolution
|
|
99
|
+
resolvedLanguage: z.string(),
|
|
100
|
+
languageDetected: z.boolean(),
|
|
101
|
+
|
|
102
|
+
// Per-token detail for consumers that need it
|
|
103
|
+
tokens: z.array(transcriptionTokenSchema),
|
|
104
|
+
|
|
105
|
+
provider: z.string(),
|
|
106
|
+
timestamp: z.number(),
|
|
107
|
+
});
|
|
108
|
+
export type TranscriptionData = z.infer<typeof transcriptionDataSchema>;
|
|
109
|
+
|
|
110
|
+
export const translationDataSchema = z.object({
|
|
111
|
+
userId: z.string(),
|
|
112
|
+
subscription: translationSubscriptionSchema,
|
|
113
|
+
|
|
114
|
+
text: z.string(), // translated
|
|
115
|
+
originalText: z.string().optional(), // source-language text
|
|
116
|
+
isFinal: z.boolean(),
|
|
117
|
+
utteranceId: z.string().optional(),
|
|
118
|
+
speakerId: z.string().optional(),
|
|
119
|
+
startMs: z.number(),
|
|
120
|
+
endMs: z.number(),
|
|
121
|
+
durationMs: z.number().optional(),
|
|
122
|
+
confidence: z.number().optional(),
|
|
123
|
+
|
|
124
|
+
source: z.object({
|
|
125
|
+
language: z.string(), // resolved (specified or detected)
|
|
126
|
+
detected: z.boolean(),
|
|
127
|
+
confidence: z.number().optional(),
|
|
128
|
+
}),
|
|
129
|
+
target: z.object({ language: z.string() }),
|
|
130
|
+
|
|
131
|
+
provider: z.string(),
|
|
132
|
+
timestamp: z.number(),
|
|
133
|
+
});
|
|
134
|
+
export type TranslationData = z.infer<typeof translationDataSchema>;
|
package/src/camera.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Canonical camera wire types: managed photo + managed stream.
|
|
3
|
+
*
|
|
4
|
+
* zod schemas + inferred TS types for the camera service. The request/result
|
|
5
|
+
* shapes ride REST; `photo.ready` / `photo.error` are WebSocket push events
|
|
6
|
+
* registered into the message union (messages.ts). Pure + isomorphic: no server
|
|
7
|
+
* imports, safe to bundle into the client.
|
|
8
|
+
*
|
|
9
|
+
* Mirrors docs/issues/002-cloud-runtime/camera/spec.md.
|
|
10
|
+
*/
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
// --- Managed photo ----------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/** Device-canonical photo size tier (matches miniapp SDK and ASG). */
|
|
16
|
+
export const photoSizeCanonicalSchema = z.enum(["low", "medium", "high", "max"]);
|
|
17
|
+
export type PhotoSizeTier = z.infer<typeof photoSizeCanonicalSchema>;
|
|
18
|
+
|
|
19
|
+
/** Accepted on the wire: canonical names plus legacy cloud aliases. */
|
|
20
|
+
const photoSizeInputSchema = z.enum([
|
|
21
|
+
"low",
|
|
22
|
+
"medium",
|
|
23
|
+
"high",
|
|
24
|
+
"max",
|
|
25
|
+
"small",
|
|
26
|
+
"large",
|
|
27
|
+
"full",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Normalize a photo size string to the device-canonical tier.
|
|
32
|
+
* Legacy aliases: small→low, large→high, full→max.
|
|
33
|
+
*/
|
|
34
|
+
export function normalizePhotoSizeTier(value: string): PhotoSizeTier {
|
|
35
|
+
switch (value) {
|
|
36
|
+
case "small":
|
|
37
|
+
return "low";
|
|
38
|
+
case "large":
|
|
39
|
+
return "high";
|
|
40
|
+
case "full":
|
|
41
|
+
return "max";
|
|
42
|
+
case "low":
|
|
43
|
+
case "medium":
|
|
44
|
+
case "high":
|
|
45
|
+
case "max":
|
|
46
|
+
return value;
|
|
47
|
+
default:
|
|
48
|
+
throw new Error(`invalid photo size: ${value}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const photoCompressInputSchema = z.enum(["none", "low", "medium", "high", "heavy"]);
|
|
53
|
+
|
|
54
|
+
/** Normalize compression aliases to the cloud wire enum. */
|
|
55
|
+
export function normalizePhotoCompress(
|
|
56
|
+
value: string,
|
|
57
|
+
): "none" | "medium" | "heavy" {
|
|
58
|
+
switch (value) {
|
|
59
|
+
case "low":
|
|
60
|
+
case "medium":
|
|
61
|
+
return "medium";
|
|
62
|
+
case "high":
|
|
63
|
+
case "heavy":
|
|
64
|
+
return "heavy";
|
|
65
|
+
case "none":
|
|
66
|
+
return "none";
|
|
67
|
+
default:
|
|
68
|
+
throw new Error(`invalid photo compress: ${value}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const photoOptionsSchema = z.object({
|
|
73
|
+
size: photoSizeInputSchema
|
|
74
|
+
.optional()
|
|
75
|
+
.transform((value) => (value === undefined ? undefined : normalizePhotoSizeTier(value))),
|
|
76
|
+
compress: photoCompressInputSchema
|
|
77
|
+
.optional()
|
|
78
|
+
.transform((value) => (value === undefined ? undefined : normalizePhotoCompress(value))),
|
|
79
|
+
saveToGallery: z.boolean().optional(),
|
|
80
|
+
sound: z.boolean().optional(),
|
|
81
|
+
});
|
|
82
|
+
export type PhotoOptions = z.infer<typeof photoOptionsSchema>;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The REST response to a photo request. The capture happens out of band (the
|
|
86
|
+
* glasses upload to `uploadUrl`); `readUrl` is the presigned URL the finished
|
|
87
|
+
* photo will be readable at, returned up front so the client has it before the
|
|
88
|
+
* `photo.ready` push confirms the upload landed.
|
|
89
|
+
*/
|
|
90
|
+
export const photoRequestResultSchema = z.object({
|
|
91
|
+
requestId: z.string(),
|
|
92
|
+
uploadUrl: z.string(),
|
|
93
|
+
readUrl: z.string(),
|
|
94
|
+
});
|
|
95
|
+
export type PhotoRequestResult = z.infer<typeof photoRequestResultSchema>;
|
|
96
|
+
|
|
97
|
+
/** `photo.ready` push payload: the capture+upload completed. */
|
|
98
|
+
export const photoReadyPayloadSchema = z.object({
|
|
99
|
+
requestId: z.string(),
|
|
100
|
+
readUrl: z.string(),
|
|
101
|
+
});
|
|
102
|
+
export type PhotoReady = z.infer<typeof photoReadyPayloadSchema>;
|
|
103
|
+
|
|
104
|
+
/** `photo.error` push payload: the capture+upload failed. */
|
|
105
|
+
export const photoErrorPayloadSchema = z.object({
|
|
106
|
+
requestId: z.string(),
|
|
107
|
+
reason: z.string(),
|
|
108
|
+
});
|
|
109
|
+
export type PhotoError = z.infer<typeof photoErrorPayloadSchema>;
|
|
110
|
+
|
|
111
|
+
// --- Managed stream ---------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/** A restream destination: re-publish the ingest to an external RTMP target. */
|
|
114
|
+
export const restreamDestinationSchema = z.union([
|
|
115
|
+
z.string(),
|
|
116
|
+
z.object({ url: z.string(), name: z.string().optional() }),
|
|
117
|
+
]);
|
|
118
|
+
export type RestreamDestination = z.infer<typeof restreamDestinationSchema>;
|
|
119
|
+
|
|
120
|
+
export const streamOptionsSchema = z.object({
|
|
121
|
+
/** Region hint so the cloud provisions a nearby ingest endpoint. */
|
|
122
|
+
region: z.string().optional(),
|
|
123
|
+
/** External RTMP targets the provider re-publishes the ingest to. */
|
|
124
|
+
restreamDestinations: z.array(restreamDestinationSchema).optional(),
|
|
125
|
+
});
|
|
126
|
+
export type StreamOptions = z.infer<typeof streamOptionsSchema>;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A provisioned stream. `ingest` is where the device pushes frames; `playback`
|
|
130
|
+
* is where viewers watch. Both are left as open records because the provider
|
|
131
|
+
* (Cloudflare Stream by default) is swappable per region and its exact field
|
|
132
|
+
* shapes are not finalized. The Cloudflare provider populates ingest
|
|
133
|
+
* `{protocol, url, streamKey, rtmpUrl, srtUrl?, webrtcPublishUrl?}` (the last
|
|
134
|
+
* three ready-to-publish with credentials embedded) and playback
|
|
135
|
+
* `{hls, dash, webrtc?}`.
|
|
136
|
+
*/
|
|
137
|
+
export const managedStreamSchema = z.object({
|
|
138
|
+
streamId: z.string(),
|
|
139
|
+
ingest: z.record(z.unknown()),
|
|
140
|
+
playback: z.record(z.unknown()),
|
|
141
|
+
});
|
|
142
|
+
export type ManagedStream = z.infer<typeof managedStreamSchema>;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* `GET /api/camera/stream/:id` result: the provider's view of the ingest.
|
|
146
|
+
* `isConnected` is the portable signal (is the device's push arriving?);
|
|
147
|
+
* the rest is provider detail for debugging.
|
|
148
|
+
*/
|
|
149
|
+
export const streamStatusResultSchema = z.object({
|
|
150
|
+
streamId: z.string(),
|
|
151
|
+
isConnected: z.boolean(),
|
|
152
|
+
state: z.string().nullable(),
|
|
153
|
+
connectedAt: z.string().optional(),
|
|
154
|
+
lastSeenAt: z.string().optional(),
|
|
155
|
+
reason: z.string().optional(),
|
|
156
|
+
});
|
|
157
|
+
export type StreamStatusResult = z.infer<typeof streamStatusResultSchema>;
|
package/src/control.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Control payloads: control.ping and control.pong.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors docs/issues/002-cloud-runtime/protocol.md ("Control"). Liveness and
|
|
5
|
+
* RTT, separate from the WebSocket ping frame. Pure + isomorphic.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
export const controlPingPayloadSchema = z.object({}).strict();
|
|
10
|
+
export type ControlPing = z.infer<typeof controlPingPayloadSchema>;
|
|
11
|
+
|
|
12
|
+
export const controlPongPayloadSchema = z.object({}).strict();
|
|
13
|
+
export type ControlPong = z.infer<typeof controlPongPayloadSchema>;
|
package/src/envelope.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The transport envelope shared by every WebSocket message.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors docs/issues/002-cloud-runtime/protocol.md ("Envelope"). Pure +
|
|
5
|
+
* isomorphic: no server imports.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
/** Protocol major. A mismatched major is rejected at the handshake. */
|
|
10
|
+
export const PROTOCOL_MAJOR = 2 as const;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Wraps a payload schema in the protocol envelope with a literal `type`, so a
|
|
14
|
+
* set of message schemas can be combined into a discriminated union on `type`.
|
|
15
|
+
*
|
|
16
|
+
* const connectionAck = envelope("connection.ack", connectionAckPayloadSchema)
|
|
17
|
+
*/
|
|
18
|
+
export function envelope<Type extends string, Payload extends z.ZodTypeAny>(
|
|
19
|
+
type: Type,
|
|
20
|
+
payload: Payload,
|
|
21
|
+
) {
|
|
22
|
+
return z.object({
|
|
23
|
+
v: z.literal(PROTOCOL_MAJOR),
|
|
24
|
+
type: z.literal(type),
|
|
25
|
+
// present only when a message needs request/ack correlation
|
|
26
|
+
id: z.string().optional(),
|
|
27
|
+
// epoch milliseconds (Unix time)
|
|
28
|
+
timestamp: z.number().int(),
|
|
29
|
+
payload,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The structural envelope, payload left open. Useful for a first-pass parse. */
|
|
34
|
+
export const envelopeSchema = z.object({
|
|
35
|
+
v: z.literal(PROTOCOL_MAJOR),
|
|
36
|
+
type: z.string(),
|
|
37
|
+
id: z.string().optional(),
|
|
38
|
+
timestamp: z.number().int(),
|
|
39
|
+
payload: z.unknown(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export interface Envelope<T = unknown> {
|
|
43
|
+
v: typeof PROTOCOL_MAJOR;
|
|
44
|
+
type: string;
|
|
45
|
+
id?: string;
|
|
46
|
+
timestamp: number;
|
|
47
|
+
payload: T;
|
|
48
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The error payload and transport-level error codes.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors docs/issues/002-cloud-runtime/protocol.md ("Errors"). Services may
|
|
5
|
+
* define additional codes for their own payloads (documented in the service
|
|
6
|
+
* doc). Pure + isomorphic.
|
|
7
|
+
*/
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
/** Transport-level error codes shared by every runtime service. */
|
|
11
|
+
export const PROTOCOL_ERROR_CODES = {
|
|
12
|
+
AUTH_FAILED: { fatal: true }, // token missing or invalid
|
|
13
|
+
AUTH_EXPIRED: { fatal: true }, // token expired; client should refresh and reopen
|
|
14
|
+
UNSUPPORTED_VERSION: { fatal: true }, // protocol major mismatch
|
|
15
|
+
BAD_REQUEST: { fatal: false }, // malformed payload for a known type
|
|
16
|
+
UNKNOWN_TYPE: { fatal: false }, // unrecognized `type`
|
|
17
|
+
INTERNAL: { fatal: false }, // server-side failure
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
export type ProtocolErrorCode = keyof typeof PROTOCOL_ERROR_CODES;
|
|
21
|
+
|
|
22
|
+
export const protocolErrorPayloadSchema = z.object({
|
|
23
|
+
code: z.string(), // a ProtocolErrorCode or a service-defined code
|
|
24
|
+
message: z.string(),
|
|
25
|
+
fatal: z.boolean(), // if true, the server closes the socket after sending
|
|
26
|
+
});
|
|
27
|
+
export type ProtocolError = z.infer<typeof protocolErrorPayloadSchema>;
|
package/src/handshake.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Handshake payloads: connection.init and connection.ack.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors docs/issues/002-cloud-runtime/protocol.md ("Auth and handshake").
|
|
5
|
+
* Service-scoped config rides in scoped blocks; audio is the only one today.
|
|
6
|
+
* Pure + isomorphic: no server imports.
|
|
7
|
+
*/
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { audioSubscriptionSchema } from "./audio";
|
|
10
|
+
|
|
11
|
+
// --- connection.init (client to cloud) --------------------------------------
|
|
12
|
+
|
|
13
|
+
export const connectionInitPayloadSchema = z.object({
|
|
14
|
+
// omitted if provided via the ?token= fallback
|
|
15
|
+
token: z.string().optional(),
|
|
16
|
+
// semver of the client protocol build, e.g. "2.0.0"
|
|
17
|
+
protocolVersion: z.string(),
|
|
18
|
+
client: z
|
|
19
|
+
.object({
|
|
20
|
+
platform: z.enum(["ios", "android"]),
|
|
21
|
+
appVersion: z.string().optional(),
|
|
22
|
+
})
|
|
23
|
+
.optional(),
|
|
24
|
+
// Service-scoped config blocks. Audio is the only one today.
|
|
25
|
+
audio: z
|
|
26
|
+
.object({
|
|
27
|
+
codec: z.enum(["lc3", "pcm"]),
|
|
28
|
+
sampleRate: z.number().int(), // e.g. 16000
|
|
29
|
+
// LC3 bitrate / frame size in bytes. Only meaningful when codec is
|
|
30
|
+
// "lc3"; omitted PCM sessions keep treating payload bytes as PCM.
|
|
31
|
+
frameSizeBytes: z.union([z.literal(20), z.literal(40), z.literal(60)]).optional(),
|
|
32
|
+
// Optional initial subscription set, seeded atomically with the session
|
|
33
|
+
// so audio that starts before the first REST update is not transcribed
|
|
34
|
+
// with an empty set.
|
|
35
|
+
initialSubscriptions: z.array(audioSubscriptionSchema).optional(),
|
|
36
|
+
})
|
|
37
|
+
.optional(),
|
|
38
|
+
});
|
|
39
|
+
export type ConnectionInit = z.infer<typeof connectionInitPayloadSchema>;
|
|
40
|
+
|
|
41
|
+
// --- connection.ack (cloud to client) ---------------------------------------
|
|
42
|
+
|
|
43
|
+
export const connectionAckPayloadSchema = z.object({
|
|
44
|
+
sessionId: z.string(), // control-plane runtime session id
|
|
45
|
+
negotiatedVersion: z.string(),
|
|
46
|
+
// Audio session ingest coordinates (audio service, UDP path).
|
|
47
|
+
audio: z
|
|
48
|
+
.object({
|
|
49
|
+
sessionTag: z.number().int(), // u32 stamped into UDP audio frames
|
|
50
|
+
udp: z.object({ host: z.string(), port: z.number().int() }),
|
|
51
|
+
// Per-session key for encrypting UDP audio. Delivered here because the
|
|
52
|
+
// handshake is over the TLS WebSocket.
|
|
53
|
+
encryption: z.object({
|
|
54
|
+
algorithm: z.literal("xsalsa20-poly1305"), // NaCl secretbox
|
|
55
|
+
key: z.string(), // base64, 32 bytes
|
|
56
|
+
}),
|
|
57
|
+
})
|
|
58
|
+
.optional(),
|
|
59
|
+
});
|
|
60
|
+
export type ConnectionAck = z.infer<typeof connectionAckPayloadSchema>;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview `@glassly/cloud-runtime/protocol` public entrypoint.
|
|
3
|
+
*
|
|
4
|
+
* Pure, isomorphic transport types + zod validators for the Glassly Runtime
|
|
5
|
+
* protocol. Zero server imports (no `node:*`, no service code) so the client
|
|
6
|
+
* can import the contract without pulling in server code.
|
|
7
|
+
*
|
|
8
|
+
* See docs/issues/002-cloud-runtime/protocol.md.
|
|
9
|
+
*/
|
|
10
|
+
export * from "./envelope";
|
|
11
|
+
export * from "./handshake";
|
|
12
|
+
export * from "./control";
|
|
13
|
+
export * from "./errors";
|
|
14
|
+
export * from "./messages";
|
|
15
|
+
export * from "./audio";
|
|
16
|
+
export * from "./languages";
|
|
17
|
+
export * from "./camera";
|
|
18
|
+
export * from "./maps";
|
|
19
|
+
export * from "./llm";
|
package/src/languages.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The canonical language registry (issue 021, WP1).
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for every language value that crosses a layer
|
|
5
|
+
* boundary: miniapp SDK types, miniapp UI pickers, engine validation, cloud
|
|
6
|
+
* subscription validation, and STT provider config all derive from THIS
|
|
7
|
+
* module. Per-miniapp mapping tables are banned; the captions bugbash
|
|
8
|
+
* (OS-1746) traced directly to two hand-maintained registries disagreeing.
|
|
9
|
+
*
|
|
10
|
+
* Two vocabularies exist on purpose:
|
|
11
|
+
* - `TranscriptionLanguage`: BCP-47 tags ("en-US") used in subscriptions and
|
|
12
|
+
* stream keys. What a miniapp asks for.
|
|
13
|
+
* - `LanguageHint`: bare ISO 639-1 codes ("en") — the only format the STT
|
|
14
|
+
* provider (Soniox) accepts as language hints. Passing a BCP-47 tag kills
|
|
15
|
+
* the session with "Invalid language hint." (verified live 2026-07-21).
|
|
16
|
+
*
|
|
17
|
+
* Design doc: docs/issues/021-typed-language-subscriptions/README.md
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Bare ISO 639-1 code -> canonical default BCP-47 tag. One entry per language
|
|
22
|
+
* the platform offers. The key set doubles as the supported-hints list; the
|
|
23
|
+
* value set doubles as the supported-subscription-tags list.
|
|
24
|
+
*
|
|
25
|
+
* Seeded from the 63 languages the captions UI offers (all Soniox stt-rt-v4
|
|
26
|
+
* supported); regional variants beyond the default (e.g. "en-GB", "zh-TW",
|
|
27
|
+
* "pt-PT") are listed in EXTRA_REGIONAL_TAGS below.
|
|
28
|
+
*/
|
|
29
|
+
const CANONICAL_TAG_BY_CODE = {
|
|
30
|
+
af: "af-ZA",
|
|
31
|
+
am: "am-ET",
|
|
32
|
+
ar: "ar-AE",
|
|
33
|
+
az: "az-AZ",
|
|
34
|
+
be: "be-BY",
|
|
35
|
+
bg: "bg-BG",
|
|
36
|
+
bn: "bn-IN",
|
|
37
|
+
bs: "bs-BA",
|
|
38
|
+
ca: "ca-ES",
|
|
39
|
+
cs: "cs-CZ",
|
|
40
|
+
cy: "cy-GB",
|
|
41
|
+
da: "da-DK",
|
|
42
|
+
de: "de-DE",
|
|
43
|
+
el: "el-GR",
|
|
44
|
+
en: "en-US",
|
|
45
|
+
es: "es-ES",
|
|
46
|
+
et: "et-EE",
|
|
47
|
+
eu: "eu-ES",
|
|
48
|
+
fa: "fa-IR",
|
|
49
|
+
fi: "fi-FI",
|
|
50
|
+
fr: "fr-FR",
|
|
51
|
+
gl: "gl-ES",
|
|
52
|
+
gu: "gu-IN",
|
|
53
|
+
he: "he-IL",
|
|
54
|
+
hi: "hi-IN",
|
|
55
|
+
hr: "hr-HR",
|
|
56
|
+
hu: "hu-HU",
|
|
57
|
+
hy: "hy-AM",
|
|
58
|
+
id: "id-ID",
|
|
59
|
+
it: "it-IT",
|
|
60
|
+
ja: "ja-JP",
|
|
61
|
+
ka: "ka-GE",
|
|
62
|
+
kk: "kk-KZ",
|
|
63
|
+
kn: "kn-IN",
|
|
64
|
+
ko: "ko-KR",
|
|
65
|
+
lt: "lt-LT",
|
|
66
|
+
lv: "lv-LV",
|
|
67
|
+
mk: "mk-MK",
|
|
68
|
+
ml: "ml-IN",
|
|
69
|
+
mr: "mr-IN",
|
|
70
|
+
ms: "ms-MY",
|
|
71
|
+
nb: "nb-NO",
|
|
72
|
+
ne: "ne-NP",
|
|
73
|
+
nl: "nl-NL",
|
|
74
|
+
no: "nb-NO",
|
|
75
|
+
pa: "pa-IN",
|
|
76
|
+
pl: "pl-PL",
|
|
77
|
+
pt: "pt-BR",
|
|
78
|
+
ro: "ro-RO",
|
|
79
|
+
ru: "ru-RU",
|
|
80
|
+
sk: "sk-SK",
|
|
81
|
+
sl: "sl-SI",
|
|
82
|
+
sq: "sq-AL",
|
|
83
|
+
sr: "sr-RS",
|
|
84
|
+
sv: "sv-SE",
|
|
85
|
+
sw: "sw-KE",
|
|
86
|
+
ta: "ta-IN",
|
|
87
|
+
te: "te-IN",
|
|
88
|
+
th: "th-TH",
|
|
89
|
+
tl: "fil-PH",
|
|
90
|
+
tr: "tr-TR",
|
|
91
|
+
uk: "uk-UA",
|
|
92
|
+
ur: "ur-IN",
|
|
93
|
+
vi: "vi-VN",
|
|
94
|
+
zh: "zh-CN",
|
|
95
|
+
} as const;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Regional variants accepted IN ADDITION to the canonical defaults above.
|
|
99
|
+
* A tag listed here is a valid `TranscriptionLanguage` but is never produced
|
|
100
|
+
* by bare-code canonicalization.
|
|
101
|
+
*/
|
|
102
|
+
const EXTRA_REGIONAL_TAGS = [
|
|
103
|
+
"ar-EG",
|
|
104
|
+
"ar-SA",
|
|
105
|
+
"de-AT",
|
|
106
|
+
"de-CH",
|
|
107
|
+
"en-AU",
|
|
108
|
+
"en-CA",
|
|
109
|
+
"en-GB",
|
|
110
|
+
"en-IN",
|
|
111
|
+
"es-419",
|
|
112
|
+
"es-MX",
|
|
113
|
+
"es-US",
|
|
114
|
+
"fr-CA",
|
|
115
|
+
"nl-BE",
|
|
116
|
+
"pt-PT",
|
|
117
|
+
"zh-HK",
|
|
118
|
+
"zh-TW",
|
|
119
|
+
] as const;
|
|
120
|
+
|
|
121
|
+
type CodeMap = typeof CANONICAL_TAG_BY_CODE;
|
|
122
|
+
|
|
123
|
+
/** Bare ISO 639-1 hint codes the STT provider accepts ("en", "fr", ...). */
|
|
124
|
+
export type LanguageHint = keyof CodeMap;
|
|
125
|
+
|
|
126
|
+
/** BCP-47 tags valid in transcription subscriptions and stream keys. */
|
|
127
|
+
export type TranscriptionLanguage =
|
|
128
|
+
| CodeMap[LanguageHint]
|
|
129
|
+
| (typeof EXTRA_REGIONAL_TAGS)[number];
|
|
130
|
+
|
|
131
|
+
/** Every supported hint code, sorted, deduped. */
|
|
132
|
+
export const SUPPORTED_LANGUAGE_HINTS = Object.freeze(
|
|
133
|
+
[...new Set(Object.keys(CANONICAL_TAG_BY_CODE))].sort(),
|
|
134
|
+
) as readonly LanguageHint[];
|
|
135
|
+
|
|
136
|
+
/** Every supported subscription tag, sorted, deduped. */
|
|
137
|
+
export const SUPPORTED_TRANSCRIPTION_LANGUAGES = Object.freeze(
|
|
138
|
+
[
|
|
139
|
+
...new Set<string>([
|
|
140
|
+
...Object.values(CANONICAL_TAG_BY_CODE),
|
|
141
|
+
...EXTRA_REGIONAL_TAGS,
|
|
142
|
+
]),
|
|
143
|
+
].sort(),
|
|
144
|
+
) as readonly TranscriptionLanguage[];
|
|
145
|
+
|
|
146
|
+
const TAG_SET = new Set<string>(SUPPORTED_TRANSCRIPTION_LANGUAGES);
|
|
147
|
+
const HINT_SET = new Set<string>(SUPPORTED_LANGUAGE_HINTS);
|
|
148
|
+
|
|
149
|
+
/** Type guard: is `value` a supported subscription tag? */
|
|
150
|
+
export function isTranscriptionLanguage(
|
|
151
|
+
value: string,
|
|
152
|
+
): value is TranscriptionLanguage {
|
|
153
|
+
return TAG_SET.has(value);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Type guard: is `value` a supported bare hint code? */
|
|
157
|
+
export function isLanguageHint(value: string): value is LanguageHint {
|
|
158
|
+
return HINT_SET.has(value);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Canonicalize developer input to a subscription tag:
|
|
163
|
+
* - a supported tag passes through ("fr-FR" -> "fr-FR")
|
|
164
|
+
* - a bare supported code maps to its canonical default ("fr" -> "fr-FR")
|
|
165
|
+
* - anything else returns null (caller decides how loudly to fail)
|
|
166
|
+
*
|
|
167
|
+
* Per issue 021 open question 1, bare codes are accepted and canonicalized
|
|
168
|
+
* rather than rejected: it matches user intent and the canonicalization table
|
|
169
|
+
* lives HERE, nowhere else.
|
|
170
|
+
*/
|
|
171
|
+
export function toTranscriptionLanguage(
|
|
172
|
+
value: string,
|
|
173
|
+
): TranscriptionLanguage | null {
|
|
174
|
+
if (TAG_SET.has(value)) return value as TranscriptionLanguage;
|
|
175
|
+
const canonical = (CANONICAL_TAG_BY_CODE as Record<string, string>)[
|
|
176
|
+
value.toLowerCase()
|
|
177
|
+
];
|
|
178
|
+
return (canonical as TranscriptionLanguage) ?? null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Reduce a subscription tag (or bare code) to the hint format the STT
|
|
183
|
+
* provider accepts. "en-US" -> "en", "fil-PH" -> "tl" is NOT attempted (the
|
|
184
|
+
* primary subtag is what providers key on), so "fil-PH" -> "fil" falls back
|
|
185
|
+
* to null and the caller simply omits the hint. Null means "no valid hint";
|
|
186
|
+
* never pass the raw value through on null (that is the exact Soniox
|
|
187
|
+
* session-killer this registry exists to prevent).
|
|
188
|
+
*/
|
|
189
|
+
export function toLanguageHint(value: string): LanguageHint | null {
|
|
190
|
+
const primary = value.split("-")[0].toLowerCase();
|
|
191
|
+
return HINT_SET.has(primary) ? (primary as LanguageHint) : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Nearest valid tag for an invalid input, for error messages
|
|
196
|
+
* ("unknown language \"fr_FR\", did you mean \"fr-FR\"?"). Cheap heuristics
|
|
197
|
+
* only: underscore/case fixes, then primary-subtag canonical default.
|
|
198
|
+
*/
|
|
199
|
+
export function suggestTranscriptionLanguage(
|
|
200
|
+
value: string,
|
|
201
|
+
): TranscriptionLanguage | null {
|
|
202
|
+
const normalized = value.replace(/_/g, "-");
|
|
203
|
+
const parts = normalized.split("-");
|
|
204
|
+
const recased =
|
|
205
|
+
parts.length >= 2
|
|
206
|
+
? `${parts[0].toLowerCase()}-${parts[1].toUpperCase()}`
|
|
207
|
+
: normalized.toLowerCase();
|
|
208
|
+
return toTranscriptionLanguage(recased);
|
|
209
|
+
}
|
package/src/llm.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Canonical LLM wire types: single-shot completion + client-side
|
|
3
|
+
* tool loop.
|
|
4
|
+
*
|
|
5
|
+
* zod schemas + inferred TS types for the runtime LLM service. Plain REST
|
|
6
|
+
* request/result (no WebSocket push, like maps), so nothing here is registered
|
|
7
|
+
* into the message union. Pure + isomorphic: no server imports, safe to bundle
|
|
8
|
+
* into the client.
|
|
9
|
+
*
|
|
10
|
+
* These are the PROVIDER-NEUTRAL types. Each provider (Anthropic, Gemini)
|
|
11
|
+
* normalizes its own request/response into these inside its provider file, so
|
|
12
|
+
* this contract never leaks a vendor's shape — the same rule maps.ts follows.
|
|
13
|
+
*
|
|
14
|
+
* DELIBERATELY NOT MODELLED on the COMPLETE surface (the abstraction stops
|
|
15
|
+
* here on purpose):
|
|
16
|
+
* - streaming; every current caller is single-shot request/response
|
|
17
|
+
* - provider server-side tools OTHER than web search (Anthropic
|
|
18
|
+
* `code_execution` / Files API) — vendor-specific, beta-header-gated,
|
|
19
|
+
* and returning vendor-shaped result blocks
|
|
20
|
+
*
|
|
21
|
+
* Web search IS modelled, as the one neutral capability flag (`webSearch`):
|
|
22
|
+
* both Anthropic (`web_search`) and Gemini (`google_search`) offer a
|
|
23
|
+
* server-side search tool whose results fold into ordinary text output, so a
|
|
24
|
+
* single boolean stays provider-neutral. Providers without an equivalent
|
|
25
|
+
* (OpenAI Chat Completions) ignore the flag — it is a capability GRANT, not a
|
|
26
|
+
* guarantee that a search happens; `webSearchCount` on the result reports what
|
|
27
|
+
* actually ran.
|
|
28
|
+
*
|
|
29
|
+
* The GENERATE surface (llmGenerate*) is the one sanctioned exception: an
|
|
30
|
+
* agentic visual-generation call where the SERVER runs a provider loop with
|
|
31
|
+
* server-side tools (web search + code execution + file retrieval) and
|
|
32
|
+
* returns text plus rendered images. It is Anthropic-only by design and does
|
|
33
|
+
* not pretend to be provider-neutral — callers get a neutral result shape,
|
|
34
|
+
* not a neutral capability.
|
|
35
|
+
*/
|
|
36
|
+
import { z } from "zod";
|
|
37
|
+
|
|
38
|
+
// --- Providers + models -----------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Provider selector. Omitted means "let the service pick its default", which is
|
|
42
|
+
* what keeps callers portable — a miniapp that never names a provider keeps
|
|
43
|
+
* working when the platform default changes.
|
|
44
|
+
*/
|
|
45
|
+
export const llmProviderSchema = z.enum(["anthropic", "gemini", "openai"]);
|
|
46
|
+
export type LlmProvider = z.infer<typeof llmProviderSchema>;
|
|
47
|
+
|
|
48
|
+
// --- Tools (client-side function calling) -----------------------------------
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A tool the CALLER executes. The service never runs these — it relays the
|
|
52
|
+
* model's request back and waits for the caller to supply results on the next
|
|
53
|
+
* turn.
|
|
54
|
+
*
|
|
55
|
+
* `inputSchema` is JSON Schema. Providers disagree on dialect (Anthropic wants
|
|
56
|
+
* lowercase `"object"`, Gemini's OpenAPI flavour wants `"OBJECT"`), so callers
|
|
57
|
+
* write it once in the lowercase JSON Schema form and each provider translates.
|
|
58
|
+
*/
|
|
59
|
+
export const llmToolSchema = z.object({
|
|
60
|
+
name: z.string().min(1),
|
|
61
|
+
description: z.string(),
|
|
62
|
+
/** JSON Schema for the tool's arguments, lowercase-dialect. */
|
|
63
|
+
inputSchema: z.record(z.unknown()),
|
|
64
|
+
});
|
|
65
|
+
export type LlmTool = z.infer<typeof llmToolSchema>;
|
|
66
|
+
|
|
67
|
+
/** A model-issued tool call, relayed to the caller to execute. */
|
|
68
|
+
export const llmToolCallSchema = z.object({
|
|
69
|
+
/**
|
|
70
|
+
* Correlation id. Anthropic supplies a real `tool_use.id`; Gemini has no ids,
|
|
71
|
+
* so its provider synthesizes positional ones (`call-0`, `call-1`). Callers
|
|
72
|
+
* must echo the id back verbatim — never reorder or invent them.
|
|
73
|
+
*/
|
|
74
|
+
id: z.string(),
|
|
75
|
+
name: z.string(),
|
|
76
|
+
input: z.record(z.unknown()),
|
|
77
|
+
});
|
|
78
|
+
export type LlmToolCall = z.infer<typeof llmToolCallSchema>;
|
|
79
|
+
|
|
80
|
+
/** The caller's answer to one {@link LlmToolCall}. */
|
|
81
|
+
export const llmToolResultSchema = z.object({
|
|
82
|
+
toolCallId: z.string(),
|
|
83
|
+
/** Serialized result. Callers JSON.stringify structured data themselves. */
|
|
84
|
+
content: z.string(),
|
|
85
|
+
/** True when the tool failed; the model is told so it can recover. */
|
|
86
|
+
isError: z.boolean().optional(),
|
|
87
|
+
});
|
|
88
|
+
export type LlmToolResult = z.infer<typeof llmToolResultSchema>;
|
|
89
|
+
|
|
90
|
+
// --- Conversation -----------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* One turn. `toolCalls` is only present on assistant turns the model paused on;
|
|
94
|
+
* `toolResults` only on user turns answering them.
|
|
95
|
+
*
|
|
96
|
+
* The whole array round-trips through the caller on every request: the service
|
|
97
|
+
* holds NO per-conversation state. That mirrors the existing relay design in
|
|
98
|
+
* the assistant backend, where the conversation is client-held precisely so any
|
|
99
|
+
* replica can serve the next turn.
|
|
100
|
+
*/
|
|
101
|
+
export const llmMessageSchema = z.object({
|
|
102
|
+
role: z.enum(["user", "assistant"]),
|
|
103
|
+
content: z.string(),
|
|
104
|
+
toolCalls: z.array(llmToolCallSchema).optional(),
|
|
105
|
+
toolResults: z.array(llmToolResultSchema).optional(),
|
|
106
|
+
});
|
|
107
|
+
export type LlmMessage = z.infer<typeof llmMessageSchema>;
|
|
108
|
+
|
|
109
|
+
// --- Request ----------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
export const llmCompleteRequestSchema = z.object({
|
|
112
|
+
/** Conversation so far. At least one turn. */
|
|
113
|
+
messages: z.array(llmMessageSchema).min(1),
|
|
114
|
+
/** System prompt. Providers place this in their own system slot. */
|
|
115
|
+
system: z.string().optional(),
|
|
116
|
+
provider: llmProviderSchema.optional(),
|
|
117
|
+
/** Provider-specific model id. Omit to use the provider's default. */
|
|
118
|
+
model: z.string().optional(),
|
|
119
|
+
maxTokens: z.number().int().positive().max(32_000).optional(),
|
|
120
|
+
temperature: z.number().min(0).max(2).optional(),
|
|
121
|
+
/**
|
|
122
|
+
* Ask for a JSON object back. Gemini sets `responseMimeType`; Anthropic has
|
|
123
|
+
* no equivalent knob, so its provider appends a system-prompt instruction.
|
|
124
|
+
* Either way the caller still has to parse and validate the text.
|
|
125
|
+
*/
|
|
126
|
+
jsonMode: z.boolean().optional(),
|
|
127
|
+
/** Client-side tools. See {@link LlmTool}. */
|
|
128
|
+
tools: z.array(llmToolSchema).optional(),
|
|
129
|
+
/**
|
|
130
|
+
* Let the model search the web (the provider's own server-side search tool)
|
|
131
|
+
* when it decides live data would help. A grant, not a command: the model
|
|
132
|
+
* may answer without searching, and providers with no equivalent tool ignore
|
|
133
|
+
* the flag entirely. Searches run inside this single call — there is no
|
|
134
|
+
* extra round-trip — and platform-funded searches are metered with a
|
|
135
|
+
* per-search surcharge on top of tokens.
|
|
136
|
+
*/
|
|
137
|
+
webSearch: z.boolean().optional(),
|
|
138
|
+
});
|
|
139
|
+
export type LlmCompleteRequest = z.infer<typeof llmCompleteRequestSchema>;
|
|
140
|
+
|
|
141
|
+
// --- Result -----------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Why the model stopped.
|
|
145
|
+
* `end_turn` — finished normally.
|
|
146
|
+
* `tool_use` — wants tool results; `toolCalls` is populated. Answer them
|
|
147
|
+
* and call again with the turn appended.
|
|
148
|
+
* `max_tokens` — hit the output cap; `text` is truncated.
|
|
149
|
+
* `refusal` — declined to answer. Note this arrives as a SUCCESS, not an
|
|
150
|
+
* error, matching how Anthropic reports it (HTTP 200 with
|
|
151
|
+
* stop_reason "refusal") — see miniapps/notes/src/background/llm.ts.
|
|
152
|
+
*/
|
|
153
|
+
export const llmStopReasonSchema = z.enum(["end_turn", "tool_use", "max_tokens", "refusal"]);
|
|
154
|
+
export type LlmStopReason = z.infer<typeof llmStopReasonSchema>;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Token counts for this call. Every provider reports these; the platform meters
|
|
158
|
+
* them per user. Zero is a legitimate value (a refusal may spend no output
|
|
159
|
+
* tokens), so callers must not treat 0 as "unknown".
|
|
160
|
+
*/
|
|
161
|
+
export const llmUsageSchema = z.object({
|
|
162
|
+
inputTokens: z.number().int().nonnegative(),
|
|
163
|
+
outputTokens: z.number().int().nonnegative(),
|
|
164
|
+
});
|
|
165
|
+
export type LlmUsage = z.infer<typeof llmUsageSchema>;
|
|
166
|
+
|
|
167
|
+
// --- Generate (agentic visual generation) -----------------------------------
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Request for one agentic generation run. The server drives the provider loop
|
|
171
|
+
* (web search for live data, code execution for rendering, file retrieval for
|
|
172
|
+
* image bytes) — the caller sends a single prompt and gets the finished
|
|
173
|
+
* result. No conversation, no client tools: one shot per call.
|
|
174
|
+
*/
|
|
175
|
+
export const llmGenerateRequestSchema = z.object({
|
|
176
|
+
/** The task, written by the caller (usually composed by an on-device model). */
|
|
177
|
+
prompt: z.string().min(1).max(20_000),
|
|
178
|
+
/** Optional extra system guidance appended to the server's base prompt. */
|
|
179
|
+
system: z.string().max(10_000).optional(),
|
|
180
|
+
/** Provider-specific model id. Omit to use the generate default. */
|
|
181
|
+
model: z.string().optional(),
|
|
182
|
+
/** Output-token budget per round. The server clamps to its own ceiling. */
|
|
183
|
+
maxTokens: z.number().int().positive().max(32_000).optional(),
|
|
184
|
+
/** Agentic round budget (pause_turn continuations). Server-clamped. */
|
|
185
|
+
maxRounds: z.number().int().positive().max(6).optional(),
|
|
186
|
+
});
|
|
187
|
+
export type LlmGenerateRequest = z.infer<typeof llmGenerateRequestSchema>;
|
|
188
|
+
|
|
189
|
+
/** One image produced by the run, ready to display. */
|
|
190
|
+
export const llmGeneratedImageSchema = z.object({
|
|
191
|
+
/** Base64 image bytes (no data: prefix). */
|
|
192
|
+
data: z.string(),
|
|
193
|
+
mimeType: z.string(),
|
|
194
|
+
width: z.number().int().positive().optional(),
|
|
195
|
+
height: z.number().int().positive().optional(),
|
|
196
|
+
});
|
|
197
|
+
export type LlmGeneratedImage = z.infer<typeof llmGeneratedImageSchema>;
|
|
198
|
+
|
|
199
|
+
export const llmGenerateResultSchema = z.object({
|
|
200
|
+
/** The model's final text (caption/answer). May be empty if it only drew. */
|
|
201
|
+
text: z.string(),
|
|
202
|
+
/** Images extracted from the run, in generation order. */
|
|
203
|
+
images: z.array(llmGeneratedImageSchema),
|
|
204
|
+
stopReason: llmStopReasonSchema,
|
|
205
|
+
usage: llmUsageSchema.extend({
|
|
206
|
+
/** Agentic rounds actually consumed. */
|
|
207
|
+
rounds: z.number().int().nonnegative(),
|
|
208
|
+
}),
|
|
209
|
+
provider: llmProviderSchema,
|
|
210
|
+
model: z.string(),
|
|
211
|
+
billedToUserKey: z.boolean(),
|
|
212
|
+
});
|
|
213
|
+
export type LlmGenerateResult = z.infer<typeof llmGenerateResultSchema>;
|
|
214
|
+
|
|
215
|
+
// --- Generate jobs (async progress/cancel wrapper over generate) ------------
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* A generate run wrapped in a pollable job, so a caller staring at a 10-60s
|
|
219
|
+
* agentic loop can show honest progress and cancel an abandoned run.
|
|
220
|
+
*
|
|
221
|
+
* POST /api/llm/generate/jobs -> { jobId } (202)
|
|
222
|
+
* GET /api/llm/generate/jobs/:id -> LlmGenerateJobStatus
|
|
223
|
+
* DELETE /api/llm/generate/jobs/:id -> LlmGenerateJobStatus (cancelled)
|
|
224
|
+
*
|
|
225
|
+
* The job store is in-memory per runtime instance: a poll must land on the
|
|
226
|
+
* instance that created the job. True for a phone talking to one region over
|
|
227
|
+
* a stable connection; a cross-region failover mid-job surfaces as 404 and
|
|
228
|
+
* the caller treats it like a failed run.
|
|
229
|
+
*
|
|
230
|
+
* `activity` is a coarse, forward-compatible label ("thinking" | "searching"
|
|
231
|
+
* | "drawing" | "finishing" today) — clients map known values to UI copy and
|
|
232
|
+
* fall back to a generic label for unknown ones, so the server can refine
|
|
233
|
+
* labels without a lockstep client release.
|
|
234
|
+
*/
|
|
235
|
+
export const llmGenerateJobStateSchema = z.enum(["running", "done", "error", "cancelled"]);
|
|
236
|
+
export type LlmGenerateJobState = z.infer<typeof llmGenerateJobStateSchema>;
|
|
237
|
+
|
|
238
|
+
export const llmGenerateJobCreatedSchema = z.object({
|
|
239
|
+
jobId: z.string().min(1),
|
|
240
|
+
});
|
|
241
|
+
export type LlmGenerateJobCreated = z.infer<typeof llmGenerateJobCreatedSchema>;
|
|
242
|
+
|
|
243
|
+
export const llmGenerateJobStatusSchema = z.object({
|
|
244
|
+
jobId: z.string().min(1),
|
|
245
|
+
state: llmGenerateJobStateSchema,
|
|
246
|
+
/** What the run is doing right now; only meaningful while `state` is "running". */
|
|
247
|
+
activity: z.string().optional(),
|
|
248
|
+
/** Present iff `state` is "done". */
|
|
249
|
+
result: llmGenerateResultSchema.optional(),
|
|
250
|
+
/** Present iff `state` is "error". */
|
|
251
|
+
error: z.string().optional(),
|
|
252
|
+
});
|
|
253
|
+
export type LlmGenerateJobStatus = z.infer<typeof llmGenerateJobStatusSchema>;
|
|
254
|
+
|
|
255
|
+
export const llmCompleteResultSchema = z.object({
|
|
256
|
+
/** Concatenated text blocks. Empty string when the model only called tools. */
|
|
257
|
+
text: z.string(),
|
|
258
|
+
stopReason: llmStopReasonSchema,
|
|
259
|
+
/** Populated iff `stopReason === "tool_use"`. */
|
|
260
|
+
toolCalls: z.array(llmToolCallSchema).optional(),
|
|
261
|
+
usage: llmUsageSchema,
|
|
262
|
+
/** Which provider/model actually served this — the request may have omitted both. */
|
|
263
|
+
provider: llmProviderSchema,
|
|
264
|
+
model: z.string(),
|
|
265
|
+
/**
|
|
266
|
+
* True when the caller's own API key paid for this call. Such calls are NOT
|
|
267
|
+
* metered against the account, so the Settings usage display can explain why
|
|
268
|
+
* a number looks lower than expected.
|
|
269
|
+
*/
|
|
270
|
+
billedToUserKey: z.boolean(),
|
|
271
|
+
/**
|
|
272
|
+
* Server-side web searches the model actually ran (see `webSearch` on the
|
|
273
|
+
* request). Absent/0 when the request didn't grant search, the provider has
|
|
274
|
+
* no search tool, or the model answered from knowledge. Drives the
|
|
275
|
+
* per-search metering surcharge.
|
|
276
|
+
*/
|
|
277
|
+
webSearchCount: z.number().int().nonnegative().optional(),
|
|
278
|
+
});
|
|
279
|
+
export type LlmCompleteResult = z.infer<typeof llmCompleteResultSchema>;
|
package/src/maps.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Canonical maps wire types: directions + reverse geocoding.
|
|
3
|
+
*
|
|
4
|
+
* zod schemas + inferred TS types for the maps service. Both flows are plain
|
|
5
|
+
* REST request/result (no WebSocket push, unlike camera), so nothing here is
|
|
6
|
+
* registered into the message union. Pure + isomorphic: no server imports, safe
|
|
7
|
+
* to bundle into the client.
|
|
8
|
+
*
|
|
9
|
+
* The vocabulary (TravelMode, ManeuverKind, avoidances, the route/step shapes)
|
|
10
|
+
* deliberately mirrors the mobile miniapp navigation SDK
|
|
11
|
+
* (mobile/modules/miniapp/src/modules/navigation.ts) so the contract is identical
|
|
12
|
+
* end-to-end: miniapp SDK <-> cloud-client <-> runtime. These are the PROVIDER-
|
|
13
|
+
* NEUTRAL types; each provider (Mapbox, Google) normalizes its own response into
|
|
14
|
+
* these inside its provider file, so this contract never leaks a vendor's shape.
|
|
15
|
+
*
|
|
16
|
+
* See docs/issues/002-cloud-runtime/maps/spec.md.
|
|
17
|
+
*/
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
|
|
20
|
+
// --- Shared geometry --------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export const latLngSchema = z.object({
|
|
23
|
+
lat: z.number(),
|
|
24
|
+
lng: z.number(),
|
|
25
|
+
});
|
|
26
|
+
export type LatLng = z.infer<typeof latLngSchema>;
|
|
27
|
+
|
|
28
|
+
/** Travel profile. Providers map this to their own profile vocabulary. */
|
|
29
|
+
export const travelModeSchema = z.enum(["walking", "driving", "cycling", "two_wheeler"]);
|
|
30
|
+
export type TravelMode = z.infer<typeof travelModeSchema>;
|
|
31
|
+
|
|
32
|
+
/** Routing preferences. All flags default to false. */
|
|
33
|
+
export const routeAvoidancesSchema = z.object({
|
|
34
|
+
highways: z.boolean().optional(),
|
|
35
|
+
tolls: z.boolean().optional(),
|
|
36
|
+
ferries: z.boolean().optional(),
|
|
37
|
+
});
|
|
38
|
+
export type RouteAvoidances = z.infer<typeof routeAvoidancesSchema>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Neutral maneuver vocabulary. Each provider maps its own maneuver type/modifier
|
|
42
|
+
* vocabulary into exactly these values; consumers never see vendor strings.
|
|
43
|
+
*/
|
|
44
|
+
export const maneuverKindSchema = z.enum([
|
|
45
|
+
"STRAIGHT",
|
|
46
|
+
"CONTINUE",
|
|
47
|
+
"SLIGHT_LEFT",
|
|
48
|
+
"SLIGHT_RIGHT",
|
|
49
|
+
"TURN_LEFT",
|
|
50
|
+
"TURN_RIGHT",
|
|
51
|
+
"SHARP_LEFT",
|
|
52
|
+
"SHARP_RIGHT",
|
|
53
|
+
"U_TURN",
|
|
54
|
+
"NAME_CHANGE",
|
|
55
|
+
"DEPART",
|
|
56
|
+
"ARRIVE",
|
|
57
|
+
"CROSS_STREET",
|
|
58
|
+
]);
|
|
59
|
+
export type ManeuverKind = z.infer<typeof maneuverKindSchema>;
|
|
60
|
+
|
|
61
|
+
// --- Directions -------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
export const directionsRequestSchema = z.object({
|
|
64
|
+
origin: latLngSchema,
|
|
65
|
+
/** Ordered stops; last is the final destination. Must have >= 1 entry. */
|
|
66
|
+
stops: z.array(latLngSchema).min(1),
|
|
67
|
+
/** Defaults to "driving" when omitted. */
|
|
68
|
+
mode: travelModeSchema.optional(),
|
|
69
|
+
avoid: routeAvoidancesSchema.optional(),
|
|
70
|
+
/**
|
|
71
|
+
* Total number of routes to return — the primary plus any alternates, provider
|
|
72
|
+
* permitting. 1 (the default) means the primary route only, no alternates; a
|
|
73
|
+
* value > 1 asks the provider for alternates. (Mirrors the prior on-device
|
|
74
|
+
* behavior: Mapbox `alternatives` is requested only when this exceeds 1.)
|
|
75
|
+
*/
|
|
76
|
+
alternatives: z.number().int().positive().optional(),
|
|
77
|
+
});
|
|
78
|
+
export type DirectionsRequest = z.infer<typeof directionsRequestSchema>;
|
|
79
|
+
|
|
80
|
+
/** One step of a computed route. The maneuver ENDS the segment. */
|
|
81
|
+
export const routeStepSchema = z.object({
|
|
82
|
+
lat: z.number(),
|
|
83
|
+
lng: z.number(),
|
|
84
|
+
endLat: z.number(),
|
|
85
|
+
endLng: z.number(),
|
|
86
|
+
distanceMeters: z.number(),
|
|
87
|
+
maneuver: maneuverKindSchema.optional(),
|
|
88
|
+
/** Full instruction from the provider (e.g. "Turn left onto Fell St"). */
|
|
89
|
+
instruction: z.string().optional(),
|
|
90
|
+
/** Resolved road name. Prefer this over parsing `instruction`. */
|
|
91
|
+
road: z.string().nullable().optional(),
|
|
92
|
+
});
|
|
93
|
+
export type RouteStep = z.infer<typeof routeStepSchema>;
|
|
94
|
+
|
|
95
|
+
export const routeSchema = z.object({
|
|
96
|
+
points: z.array(latLngSchema),
|
|
97
|
+
totalDistanceMeters: z.number(),
|
|
98
|
+
totalDurationSeconds: z.number(),
|
|
99
|
+
summary: z.string().optional(),
|
|
100
|
+
steps: z.array(routeStepSchema).optional(),
|
|
101
|
+
});
|
|
102
|
+
export type Route = z.infer<typeof routeSchema>;
|
|
103
|
+
|
|
104
|
+
/** The REST response for `POST /api/maps/directions`. Primary route first. */
|
|
105
|
+
export const directionsResultSchema = z.object({
|
|
106
|
+
routes: z.array(routeSchema),
|
|
107
|
+
});
|
|
108
|
+
export type DirectionsResult = z.infer<typeof directionsResultSchema>;
|
|
109
|
+
|
|
110
|
+
// --- Reverse geocoding ------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
export const reverseGeocodeRequestSchema = latLngSchema;
|
|
113
|
+
export type ReverseGeocodeRequest = z.infer<typeof reverseGeocodeRequestSchema>;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The REST response for `POST /api/maps/reverse-geocode`.
|
|
117
|
+
*
|
|
118
|
+
* - `road` — short street name (e.g. "Hayes Street"). Backs the pivot
|
|
119
|
+
* engine's road-name fallback.
|
|
120
|
+
* - `address` — full formatted street address with house number + locality
|
|
121
|
+
* (e.g. "369 Hayes Street, San Francisco, California 94102"). Backs the
|
|
122
|
+
* navigation miniapp's dropped-pin / POI-tap labels.
|
|
123
|
+
*
|
|
124
|
+
* Either field is null when nothing of that kind was found near the coordinate;
|
|
125
|
+
* that is a successful empty answer, not an error (an actual failure is a
|
|
126
|
+
* non-2xx response).
|
|
127
|
+
*/
|
|
128
|
+
export const reverseGeocodeResultSchema = z.object({
|
|
129
|
+
road: z.string().nullable(),
|
|
130
|
+
address: z.string().nullable(),
|
|
131
|
+
});
|
|
132
|
+
export type ReverseGeocodeResult = z.infer<typeof reverseGeocodeResultSchema>;
|
|
133
|
+
|
|
134
|
+
// --- Place search (autocomplete + details) ----------------------------------
|
|
135
|
+
//
|
|
136
|
+
// A two-step "type-ahead then pick" flow, provider-neutral like the rest of this
|
|
137
|
+
// file. `placeAutocomplete` lists lightweight suggestions as the user types;
|
|
138
|
+
// `placeDetails` resolves the chosen suggestion to coordinates. A `sessionToken`
|
|
139
|
+
// threads BOTH calls so the provider can bill the keystrokes + the final detail
|
|
140
|
+
// fetch as ONE search session (Mapbox Search Box and Google Places both work
|
|
141
|
+
// this way). Callers create one token per "search box opening" and rotate it
|
|
142
|
+
// after a pick.
|
|
143
|
+
|
|
144
|
+
export const placeAutocompleteRequestSchema = z.object({
|
|
145
|
+
/** The user's partial query. Empty/whitespace yields no suggestions. */
|
|
146
|
+
query: z.string(),
|
|
147
|
+
/** Optional location bias — rank results near this coordinate. */
|
|
148
|
+
near: latLngSchema.optional(),
|
|
149
|
+
/** Opaque per-search-session token; the same value should go on `placeDetails`. */
|
|
150
|
+
sessionToken: z.string(),
|
|
151
|
+
});
|
|
152
|
+
export type PlaceAutocompleteRequest = z.infer<typeof placeAutocompleteRequestSchema>;
|
|
153
|
+
|
|
154
|
+
/** One type-ahead suggestion. `placeId` is fed back into `placeDetails`. */
|
|
155
|
+
export const placeSuggestionSchema = z.object({
|
|
156
|
+
placeId: z.string(),
|
|
157
|
+
/** Primary line, e.g. "Blue Bottle Coffee". */
|
|
158
|
+
mainText: z.string(),
|
|
159
|
+
/** Secondary line, e.g. "66 Mint St, San Francisco". Empty when none. */
|
|
160
|
+
secondaryText: z.string(),
|
|
161
|
+
});
|
|
162
|
+
export type PlaceSuggestion = z.infer<typeof placeSuggestionSchema>;
|
|
163
|
+
|
|
164
|
+
export const placeAutocompleteResultSchema = z.object({
|
|
165
|
+
suggestions: z.array(placeSuggestionSchema),
|
|
166
|
+
});
|
|
167
|
+
export type PlaceAutocompleteResult = z.infer<typeof placeAutocompleteResultSchema>;
|
|
168
|
+
|
|
169
|
+
export const placeDetailsRequestSchema = z.object({
|
|
170
|
+
/** A `placeId` returned by a prior `placeAutocomplete` suggestion. */
|
|
171
|
+
placeId: z.string(),
|
|
172
|
+
/** The same session token used for the autocomplete that produced this id. */
|
|
173
|
+
sessionToken: z.string(),
|
|
174
|
+
});
|
|
175
|
+
export type PlaceDetailsRequest = z.infer<typeof placeDetailsRequestSchema>;
|
|
176
|
+
|
|
177
|
+
/** The resolved place: a name, formatted address, and coordinates to route to. */
|
|
178
|
+
export const placeDetailsResultSchema = z.object({
|
|
179
|
+
placeId: z.string(),
|
|
180
|
+
name: z.string(),
|
|
181
|
+
address: z.string(),
|
|
182
|
+
lat: z.number(),
|
|
183
|
+
lng: z.number(),
|
|
184
|
+
});
|
|
185
|
+
export type PlaceDetailsResult = z.infer<typeof placeDetailsResultSchema>;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The deployment's PUBLIC client-side map render token. Some map consumers
|
|
189
|
+
* cannot be proxied through the cloud (map tile rendering, native map SDKs) —
|
|
190
|
+
* this is the token the runtime hands to authenticated clients for those, so
|
|
191
|
+
* the mobile binary ships no baked provider key. `token` is null when the
|
|
192
|
+
* deployment has none configured; it is never a secret-scope credential.
|
|
193
|
+
*/
|
|
194
|
+
export const mapsRenderTokenResultSchema = z.object({
|
|
195
|
+
/** Provider the token belongs to (e.g. "mapbox"). */
|
|
196
|
+
provider: z.string(),
|
|
197
|
+
token: z.string().nullable(),
|
|
198
|
+
});
|
|
199
|
+
export type MapsRenderTokenResult = z.infer<typeof mapsRenderTokenResultSchema>;
|
package/src/messages.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The WebSocket message registry.
|
|
3
|
+
*
|
|
4
|
+
* Each transport `type` maps 1:1 to an enveloped zod schema; the schemas are
|
|
5
|
+
* combined into discriminated unions on `type` for parse-time validation on
|
|
6
|
+
* both ends. Transport-level types live in protocol.md; per-service push events
|
|
7
|
+
* (stream.transcript / stream.translation) are registered by the audio service
|
|
8
|
+
* doc but their schemas live here so the one union validates everything.
|
|
9
|
+
*
|
|
10
|
+
* Pure + isomorphic: no server imports.
|
|
11
|
+
*/
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { envelope } from "./envelope";
|
|
14
|
+
import { connectionInitPayloadSchema, connectionAckPayloadSchema } from "./handshake";
|
|
15
|
+
import { controlPingPayloadSchema, controlPongPayloadSchema } from "./control";
|
|
16
|
+
import { protocolErrorPayloadSchema } from "./errors";
|
|
17
|
+
import {
|
|
18
|
+
quotaStatusPayloadSchema,
|
|
19
|
+
transcriptionDataSchema,
|
|
20
|
+
translationDataSchema,
|
|
21
|
+
udpLivenessAckPayloadSchema,
|
|
22
|
+
} from "./audio";
|
|
23
|
+
import { photoReadyPayloadSchema, photoErrorPayloadSchema } from "./camera";
|
|
24
|
+
|
|
25
|
+
// --- Per-type enveloped message schemas -------------------------------------
|
|
26
|
+
|
|
27
|
+
export const connectionInitMessage = envelope("connection.init", connectionInitPayloadSchema);
|
|
28
|
+
export const connectionAckMessage = envelope("connection.ack", connectionAckPayloadSchema);
|
|
29
|
+
export const controlPingMessage = envelope("control.ping", controlPingPayloadSchema);
|
|
30
|
+
export const controlPongMessage = envelope("control.pong", controlPongPayloadSchema);
|
|
31
|
+
export const errorMessage = envelope("error", protocolErrorPayloadSchema);
|
|
32
|
+
|
|
33
|
+
// Audio service push events (registered in audio/protocol.md).
|
|
34
|
+
export const streamTranscriptMessage = envelope("stream.transcript", transcriptionDataSchema);
|
|
35
|
+
export const streamTranslationMessage = envelope("stream.translation", translationDataSchema);
|
|
36
|
+
export const audioUdpLivenessAckMessage = envelope("audio.udp_liveness_ack", udpLivenessAckPayloadSchema);
|
|
37
|
+
export const streamQuotaMessage = envelope("stream.quota", quotaStatusPayloadSchema);
|
|
38
|
+
|
|
39
|
+
// Camera service push events (registered in camera/spec.md).
|
|
40
|
+
export const photoReadyMessage = envelope("photo.ready", photoReadyPayloadSchema);
|
|
41
|
+
export const photoErrorMessage = envelope("photo.error", photoErrorPayloadSchema);
|
|
42
|
+
|
|
43
|
+
// --- Direction-scoped unions ------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/** Messages the client sends to the cloud. */
|
|
46
|
+
export const clientToCloudMessage = z.discriminatedUnion("type", [
|
|
47
|
+
connectionInitMessage,
|
|
48
|
+
controlPingMessage,
|
|
49
|
+
controlPongMessage,
|
|
50
|
+
]);
|
|
51
|
+
export type ClientToCloudMessage = z.infer<typeof clientToCloudMessage>;
|
|
52
|
+
|
|
53
|
+
/** Messages the cloud pushes to the client. */
|
|
54
|
+
export const cloudToClientMessage = z.discriminatedUnion("type", [
|
|
55
|
+
connectionAckMessage,
|
|
56
|
+
controlPingMessage,
|
|
57
|
+
controlPongMessage,
|
|
58
|
+
errorMessage,
|
|
59
|
+
streamTranscriptMessage,
|
|
60
|
+
streamTranslationMessage,
|
|
61
|
+
audioUdpLivenessAckMessage,
|
|
62
|
+
streamQuotaMessage,
|
|
63
|
+
photoReadyMessage,
|
|
64
|
+
photoErrorMessage,
|
|
65
|
+
]);
|
|
66
|
+
export type CloudToClientMessage = z.infer<typeof cloudToClientMessage>;
|
|
67
|
+
|
|
68
|
+
/** Every known message in either direction. */
|
|
69
|
+
export const anyMessage = z.discriminatedUnion("type", [
|
|
70
|
+
connectionInitMessage,
|
|
71
|
+
connectionAckMessage,
|
|
72
|
+
controlPingMessage,
|
|
73
|
+
controlPongMessage,
|
|
74
|
+
errorMessage,
|
|
75
|
+
streamTranscriptMessage,
|
|
76
|
+
streamTranslationMessage,
|
|
77
|
+
audioUdpLivenessAckMessage,
|
|
78
|
+
streamQuotaMessage,
|
|
79
|
+
photoReadyMessage,
|
|
80
|
+
photoErrorMessage,
|
|
81
|
+
]);
|
|
82
|
+
export type AnyMessage = z.infer<typeof anyMessage>;
|