@frockbot/cloudflare 0.0.0 → 0.7.292
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/build-artifact.ts +130 -0
- package/build-flutter-web.ts +508 -0
- package/deployment-config/README.md +414 -0
- package/deployment-config/cli.ts +228 -0
- package/deployment-config/generate.ts +689 -0
- package/deployment-config/jsonc.ts +18 -0
- package/deployment-config/profile-schema.generated.ts +391 -0
- package/deployment-config/profile.schema.json +358 -0
- package/deployment-config/profile.ts +124 -0
- package/migrations/0001_better_auth.sql +15 -0
- package/migrations/0002_drop_account_issuer.sql +8 -0
- package/package.json +61 -5
- package/release-version.ts +20 -0
- package/src/account-admission.ts +179 -0
- package/src/account-deletion.ts +274 -0
- package/src/admin-entrypoint.ts +194 -0
- package/src/admin-identities.ts +26 -0
- package/src/audit.ts +103 -0
- package/src/auth-package.access.ts +24 -0
- package/src/auth-package.ts +36 -0
- package/src/avatar-state-cleanup.ts +107 -0
- package/src/billing-computer.ts +267 -0
- package/src/billing-readiness.ts +21 -0
- package/src/billing.ts +461 -0
- package/src/bot-capabilities.ts +480 -0
- package/src/bot-recovery.ts +1 -0
- package/src/bot-state-channel.ts +702 -0
- package/src/bot-state.ts +3995 -0
- package/src/bot-template-cleanup.ts +98 -0
- package/src/bot-title-cleanup.ts +104 -0
- package/src/brand-icon.png +0 -0
- package/src/brand-logo.ts +2 -0
- package/src/brand.ts +36 -0
- package/src/client-compatibility.ts +51 -0
- package/src/compaction-announcement-cleanup.ts +113 -0
- package/src/computer-egress.ts +110 -0
- package/src/computer-host.ts +92 -0
- package/src/computer-screenshot-cleanup.ts +78 -0
- package/src/contracts.ts +832 -0
- package/src/debug.ts +272 -0
- package/src/default-packages-marker-cleanup.ts +39 -0
- package/src/deployment-policy-admin-host.ts +121 -0
- package/src/deployment-policy.ts +426 -0
- package/src/directory-profile-cleanup.ts +101 -0
- package/src/durable-rpc.ts +753 -0
- package/src/durable-session.ts +21 -0
- package/src/entry-boundary.ts +103 -0
- package/src/frock-ai.ts +338 -0
- package/src/gateway.ts +1392 -0
- package/src/group-chat.ts +736 -0
- package/src/hidden-bot-notifications-cleanup.ts +32 -0
- package/src/index.ts +2798 -0
- package/src/insights.ts +15 -0
- package/src/machine-messages-cleanup.ts +121 -0
- package/src/machine-socket.ts +164 -0
- package/src/memory-records.ts +181 -0
- package/src/memory.ts +145 -0
- package/src/model-rates.ts +252 -0
- package/src/native-auth.ts +1006 -0
- package/src/native-sessions.ts +121 -0
- package/src/notification-state-cleanup.ts +18 -0
- package/src/ollama-web-search-cleanup.ts +69 -0
- package/src/package-page-shapes-cleanup.ts +190 -0
- package/src/plugin-egress.ts +31 -0
- package/src/plugin-page-route.ts +72 -0
- package/src/plugin-panels-cleanup.ts +101 -0
- package/src/prepared-input-cleanup.ts +105 -0
- package/src/production-secrets.ts +484 -0
- package/src/project-cleanup.ts +123 -0
- package/src/project-events-cleanup.ts +152 -0
- package/src/publication-state-cleanup.ts +92 -0
- package/src/push.ts +498 -0
- package/src/request-body.ts +165 -0
- package/src/routine-state-cleanup.ts +57 -0
- package/src/search.ts +88 -0
- package/src/sidebar-label-cleanup.ts +165 -0
- package/src/skill-index-cleanup.ts +53 -0
- package/src/supersede-cleanup.ts +162 -0
- package/src/test-chat-cleanup.ts +93 -0
- package/src/uploads.ts +486 -0
- package/src/user-application.ts +1068 -0
- package/src/user-configuration.ts +4491 -0
- package/src/voice-assistant.ts +4999 -0
- package/src/voice-dictation.ts +743 -0
- package/src/working-context-cleanup.ts +96 -0
- package/src/workspace.ts +201 -0
- package/wrangler.jsonc +385 -0
- package/README.md +0 -3
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type SessionEvent } from "@frockbot/core/contracts";
|
|
2
|
+
|
|
3
|
+
function sameEvent(
|
|
4
|
+
left: SessionEvent,
|
|
5
|
+
right: SessionEvent | undefined,
|
|
6
|
+
): boolean {
|
|
7
|
+
return right !== undefined && JSON.stringify(left) === JSON.stringify(right);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function appendedSessionEvents(
|
|
11
|
+
previous: readonly SessionEvent[],
|
|
12
|
+
candidate: readonly SessionEvent[],
|
|
13
|
+
): SessionEvent[] {
|
|
14
|
+
if (
|
|
15
|
+
candidate.length < previous.length ||
|
|
16
|
+
previous.some((event, index) => !sameEvent(event, candidate[index]))
|
|
17
|
+
) {
|
|
18
|
+
throw new Error("candidate changed durable session history");
|
|
19
|
+
}
|
|
20
|
+
return structuredClone(candidate.slice(previous.length));
|
|
21
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The boundary every entry point answers behind.
|
|
3
|
+
*
|
|
4
|
+
* The gateway's `fetch` has had an error boundary for a long time. Nothing else
|
|
5
|
+
* did — and the dev Worker died twice in one evening on a `BotNotFoundError`
|
|
6
|
+
* thrown out of a Durable Object's own `fetch` during a state-channel upgrade,
|
|
7
|
+
* followed by wrangler reporting the network connection lost. An entry point is
|
|
8
|
+
* whatever workerd calls into: `fetch`, a WebSocket callback, `alarm`,
|
|
9
|
+
* `scheduled`, `queue`. Every one of them is a place where a thrown error has
|
|
10
|
+
* no caller left to catch it, so every one of them ends here instead.
|
|
11
|
+
*
|
|
12
|
+
* Two shapes, because the two kinds of entry point owe different things. A
|
|
13
|
+
* request-shaped one owes an answer, and the answer is JSON with a readable
|
|
14
|
+
* reason — a client that receives `Internal Server Error` as plain text can
|
|
15
|
+
* only report a JSON parse failure, which tells the person nothing. A
|
|
16
|
+
* fire-and-forget one owes nothing to anybody, so it records what happened and
|
|
17
|
+
* lets the next firing try again.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The named failures a Durable Object raises for a Bot that is not there, and
|
|
22
|
+
* the status each is owed.
|
|
23
|
+
*
|
|
24
|
+
* Matched on `name` rather than by class, because the error has crossed an RPC
|
|
25
|
+
* boundary by the time it is read and only its name and message survive — the
|
|
26
|
+
* same convention the gateway's Bot routes already rely on.
|
|
27
|
+
*/
|
|
28
|
+
const ENTRY_STATUS_BY_ERROR_NAME_V1 = new Map<string, number>([
|
|
29
|
+
["BotNotFoundError", 404],
|
|
30
|
+
["ComputerBotNotFoundError", 404],
|
|
31
|
+
["BotArchivedError", 409],
|
|
32
|
+
// Gone, not merely absent: the Bot was here, its history has been destroyed,
|
|
33
|
+
// and no retry will bring it back. A caller that still holds the id — a
|
|
34
|
+
// second tab, a queued Turn — is told the difference.
|
|
35
|
+
["BotDeletedError", 410],
|
|
36
|
+
// The same for the whole account, from the moment its deletion began.
|
|
37
|
+
["AccountDeletedError", 410],
|
|
38
|
+
// A message named a file this Bot was never given, or no longer holds. The
|
|
39
|
+
// request was well formed; what it names is not there to attach.
|
|
40
|
+
["UploadNotFoundError", 422],
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
function errorName(error: unknown): string | undefined {
|
|
44
|
+
return typeof error === "object" && error !== null && "name" in error
|
|
45
|
+
? String(error.name)
|
|
46
|
+
: undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The status an escaped error is owed, defaulting to 500. */
|
|
50
|
+
export function entryFailureStatusV1(error: unknown): number {
|
|
51
|
+
return ENTRY_STATUS_BY_ERROR_NAME_V1.get(errorName(error) ?? "") ?? 500;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function entryFailureResponseV1(
|
|
55
|
+
error: unknown,
|
|
56
|
+
fallback: string,
|
|
57
|
+
): Response {
|
|
58
|
+
const status = entryFailureStatusV1(error);
|
|
59
|
+
const message =
|
|
60
|
+
error instanceof Error && error.message ? error.message : fallback;
|
|
61
|
+
return Response.json({ error: message }, { status });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Run a request-shaped entry point, answering rather than throwing.
|
|
66
|
+
*
|
|
67
|
+
* A Bot that is not there is a 404 the caller can act on, not a fault; anything
|
|
68
|
+
* else is a 500 that still carries a reason. Either way the isolate survives to
|
|
69
|
+
* answer the next request, which is the property this exists for.
|
|
70
|
+
*/
|
|
71
|
+
export async function answeredEntryV1(
|
|
72
|
+
fallback: string,
|
|
73
|
+
work: () => Promise<Response>,
|
|
74
|
+
): Promise<Response> {
|
|
75
|
+
try {
|
|
76
|
+
return await work();
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return entryFailureResponseV1(error, fallback);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Run a fire-and-forget entry point — an alarm, a socket callback — recording
|
|
84
|
+
* a failure instead of letting it escape.
|
|
85
|
+
*
|
|
86
|
+
* Swallowing is deliberate and bounded: an alarm is rescheduled by whatever
|
|
87
|
+
* owns it and a socket callback has nobody to answer, so the durable
|
|
88
|
+
* consequence of a failure here is that the next firing tries again. What must
|
|
89
|
+
* not happen is the throw, which in a Durable Object is an uncaught exception
|
|
90
|
+
* and in the dev Worker took the whole process with it.
|
|
91
|
+
*/
|
|
92
|
+
export async function loggedEntryV1(
|
|
93
|
+
label: string,
|
|
94
|
+
work: () => void | Promise<void>,
|
|
95
|
+
): Promise<void> {
|
|
96
|
+
try {
|
|
97
|
+
await work();
|
|
98
|
+
} catch (error) {
|
|
99
|
+
console.error(
|
|
100
|
+
`${label} failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
package/src/frock-ai.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { MODEL_FIRST_BYTE_DEADLINE_MS_V1 } from "@frockbot/core/contracts";
|
|
2
|
+
import {
|
|
3
|
+
FROCK_AI_DEFAULT_AUTO_ROUTE,
|
|
4
|
+
gatewayModelForFrockIdV1,
|
|
5
|
+
} from "@frockbot/providers/frock-ai/catalog";
|
|
6
|
+
import {
|
|
7
|
+
frockAiServedModelFromHeadersV1,
|
|
8
|
+
FrockAiTransportErrorV1,
|
|
9
|
+
type FrockAiChatCompletionV1,
|
|
10
|
+
} from "@frockbot/providers/frock-ai/runtime";
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_FROCK_AI_GATEWAY_ID_V1 = "flock";
|
|
13
|
+
|
|
14
|
+
export interface FrockAiGatewayHostV1 {
|
|
15
|
+
/** `null` when this host took the `AI` binding, which carries no dynamic route. */
|
|
16
|
+
autoRoute: string | null;
|
|
17
|
+
runChatCompletion: FrockAiChatCompletionV1;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The Gateway request is bounded so a gateway that accepts the connection and
|
|
22
|
+
* then never answers fails the Turn instead of holding it open. The deadline
|
|
23
|
+
* covers *reaching* the gateway — nothing more. The SSE body that follows is
|
|
24
|
+
* bounded by the kernel's own first-byte and idle deadlines, which is where the
|
|
25
|
+
* copy a person reads lives.
|
|
26
|
+
*
|
|
27
|
+
* It was 60s, and it did not stop at the headers: the same signal was handed to
|
|
28
|
+
* `fetch` and left armed, so it guillotined the response body sixty seconds
|
|
29
|
+
* into a tool-calling step. The body's abort surfaced as a raw
|
|
30
|
+
* `TimeoutError: The operation was aborted due to timeout`, which the Agent
|
|
31
|
+
* read as an *uncertain* model outcome and parked the run on — a `POST /turns`
|
|
32
|
+
* answering 500 after 65s with "Couldn't reach the Bot" on screen. A step that
|
|
33
|
+
* carries a large SKILL.md plus the dynamic-tool schemas crosses a minute
|
|
34
|
+
* routinely, so 60s was not a slow gateway, it was the ordinary case.
|
|
35
|
+
*
|
|
36
|
+
* Two minutes now, matching `MODEL_FIRST_BYTE_DEADLINE_MS_V1` so the kernel's
|
|
37
|
+
* deadline — the one with a sentence written for a person — wins the race and
|
|
38
|
+
* this stays the backstop for a transport that never reaches the seam at all.
|
|
39
|
+
* Both are far inside the fifteen-minute Turn deadline.
|
|
40
|
+
*/
|
|
41
|
+
export const FROCK_AI_GATEWAY_TIMEOUT_MS_V1 = MODEL_FIRST_BYTE_DEADLINE_MS_V1;
|
|
42
|
+
|
|
43
|
+
export interface FrockAiBillingLimitV1 {
|
|
44
|
+
inputTokens: number;
|
|
45
|
+
outputTokens: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The input tokens one image is held to under a prepaid limit: about what a
|
|
50
|
+
* model that tiles at 28 pixels spends on a 2,048-pixel photo, which is the
|
|
51
|
+
* largest the app sends. Generous on purpose — it only decides whether a
|
|
52
|
+
* request fits its reservation; the charge is what the model reports.
|
|
53
|
+
*/
|
|
54
|
+
export const FROCK_AI_IMAGE_INPUT_TOKENS_V1 = 6_000;
|
|
55
|
+
|
|
56
|
+
export interface FrockAiGatewayConfigV1 {
|
|
57
|
+
/**
|
|
58
|
+
* Each hosted model's prepaid bound, keyed by its Frock AI model id, as the
|
|
59
|
+
* deployment's rate table holds them when the request is sent. A request is
|
|
60
|
+
* held to its own model's bound; one whose model has none — a structured
|
|
61
|
+
* Auto request pinned to a model of its own — to the smallest.
|
|
62
|
+
*/
|
|
63
|
+
billingLimits?: () => Promise<Record<string, FrockAiBillingLimitV1>>;
|
|
64
|
+
gatewayId?: string;
|
|
65
|
+
autoRoute?: string;
|
|
66
|
+
/**
|
|
67
|
+
* The Cloudflare account owning the Gateway. Present with `token`, requests
|
|
68
|
+
* take the compat HTTP transport; absent, they take the `AI` binding.
|
|
69
|
+
*/
|
|
70
|
+
accountId?: string;
|
|
71
|
+
/** The `cf-aig-authorization` bearer for an authenticated Gateway. */
|
|
72
|
+
token?: string;
|
|
73
|
+
/** Injectable for tests; defaults to the global `fetch`. */
|
|
74
|
+
fetch?: typeof fetch;
|
|
75
|
+
/** Deadline for reaching the Gateway. */
|
|
76
|
+
timeoutMs?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function compatChatCompletionsUrlV1(
|
|
80
|
+
accountId: string,
|
|
81
|
+
gatewayId: string,
|
|
82
|
+
): string {
|
|
83
|
+
return `https://gateway.ai.cloudflare.com/v1/${accountId}/${gatewayId}/compat/chat/completions`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function retryAfterMillisecondsV1(value: string | null): number | undefined {
|
|
87
|
+
if (!value) return undefined;
|
|
88
|
+
const seconds = Number(value);
|
|
89
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
90
|
+
return Math.ceil(seconds * 1000);
|
|
91
|
+
const at = Date.parse(value);
|
|
92
|
+
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A rejected request answers with a JSON error body rather than an SSE stream.
|
|
97
|
+
* Left unchecked it decodes as a stream that ends before its terminal marker,
|
|
98
|
+
* which the Agent reads as an *uncertain* outcome and parks the run on — so the
|
|
99
|
+
* status is what tells the two apart.
|
|
100
|
+
*/
|
|
101
|
+
async function streamOrThrowV1(
|
|
102
|
+
response: Response,
|
|
103
|
+
served: Parameters<FrockAiChatCompletionV1>[3],
|
|
104
|
+
): Promise<ReadableStream<Uint8Array>> {
|
|
105
|
+
if (!response.ok) {
|
|
106
|
+
const detail = (await response.text().catch(() => "")).slice(0, 512);
|
|
107
|
+
let reason = detail;
|
|
108
|
+
let code: string | number | undefined;
|
|
109
|
+
try {
|
|
110
|
+
const payload = JSON.parse(detail) as unknown;
|
|
111
|
+
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
|
112
|
+
const error = (payload as Record<string, unknown>).error;
|
|
113
|
+
if (typeof error === "string") reason = error;
|
|
114
|
+
else if (error && typeof error === "object" && !Array.isArray(error)) {
|
|
115
|
+
const envelope = error as Record<string, unknown>;
|
|
116
|
+
if (typeof envelope.message === "string") reason = envelope.message;
|
|
117
|
+
if (
|
|
118
|
+
typeof envelope.code === "string" ||
|
|
119
|
+
typeof envelope.code === "number"
|
|
120
|
+
) {
|
|
121
|
+
code = envelope.code;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
// The bounded non-JSON provider text is still a useful diagnostic.
|
|
127
|
+
}
|
|
128
|
+
throw new FrockAiTransportErrorV1(
|
|
129
|
+
`AI Gateway rejected the request (${response.status})${
|
|
130
|
+
reason ? `: ${reason}` : ""
|
|
131
|
+
}`,
|
|
132
|
+
response.status,
|
|
133
|
+
retryAfterMillisecondsV1(response.headers.get("retry-after")),
|
|
134
|
+
code,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (!response.body) {
|
|
138
|
+
throw new Error("AI Gateway did not return a response stream");
|
|
139
|
+
}
|
|
140
|
+
served?.(frockAiServedModelFromHeadersV1(response.headers));
|
|
141
|
+
return response.body;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The prepaid bounds by the gateway model each Frock AI model is sent as. */
|
|
145
|
+
function gatewayBillingLimitsV1(
|
|
146
|
+
limits: Record<string, FrockAiBillingLimitV1>,
|
|
147
|
+
autoRoute: string | null,
|
|
148
|
+
): {
|
|
149
|
+
byGatewayModel: Map<string, FrockAiBillingLimitV1>;
|
|
150
|
+
smallest: FrockAiBillingLimitV1;
|
|
151
|
+
} {
|
|
152
|
+
const byGatewayModel = new Map<string, FrockAiBillingLimitV1>();
|
|
153
|
+
for (const [model, limit] of Object.entries(limits)) {
|
|
154
|
+
let gatewayModel: string;
|
|
155
|
+
try {
|
|
156
|
+
gatewayModel = gatewayModelForFrockIdV1(model, autoRoute);
|
|
157
|
+
} catch {
|
|
158
|
+
// A priced id this deployment cannot send is never requested.
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
// Two ids sent as one gateway model are held to the tighter bound.
|
|
162
|
+
const shared = byGatewayModel.get(gatewayModel);
|
|
163
|
+
byGatewayModel.set(
|
|
164
|
+
gatewayModel,
|
|
165
|
+
shared
|
|
166
|
+
? {
|
|
167
|
+
inputTokens: Math.min(shared.inputTokens, limit.inputTokens),
|
|
168
|
+
outputTokens: Math.min(shared.outputTokens, limit.outputTokens),
|
|
169
|
+
}
|
|
170
|
+
: limit,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
const all = Object.values(limits);
|
|
174
|
+
return {
|
|
175
|
+
byGatewayModel,
|
|
176
|
+
smallest: {
|
|
177
|
+
inputTokens: all.length
|
|
178
|
+
? Math.min(...all.map((limit) => limit.inputTokens))
|
|
179
|
+
: 0,
|
|
180
|
+
outputTokens: all.length
|
|
181
|
+
? Math.min(...all.map((limit) => limit.outputTokens))
|
|
182
|
+
: 0,
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Keep the generated Cloudflare binding type on the Worker side of the seam. */
|
|
188
|
+
export function createFrockAiGatewayHostV1(
|
|
189
|
+
ai: Pick<Ai, "gateway">,
|
|
190
|
+
config: FrockAiGatewayConfigV1,
|
|
191
|
+
): FrockAiGatewayHostV1 {
|
|
192
|
+
const gatewayId = config.gatewayId || DEFAULT_FROCK_AI_GATEWAY_ID_V1;
|
|
193
|
+
const { accountId, token } = config;
|
|
194
|
+
// The `AI` binding's `gateway(...).run()` reaches the Gateway's *universal*
|
|
195
|
+
// endpoint, whose request-shape translation rejects a `dynamic/<route>` model
|
|
196
|
+
// before inference runs — cloudflare/ai#617. Concrete `workers-ai/@cf/...`
|
|
197
|
+
// ids survive that translation, so the binding stays the transport wherever
|
|
198
|
+
// no Gateway credentials are configured, which is every local and CI
|
|
199
|
+
// environment that binds a stand-in for `AI`.
|
|
200
|
+
const useCompat = Boolean(accountId && token);
|
|
201
|
+
// Only the compat transport can carry Auto as a route, so on the binding path
|
|
202
|
+
// this host has none and Auto resolves to a concrete model instead.
|
|
203
|
+
const autoRoute = useCompat
|
|
204
|
+
? config.autoRoute || FROCK_AI_DEFAULT_AUTO_ROUTE
|
|
205
|
+
: null;
|
|
206
|
+
const timeoutMs = config.timeoutMs ?? FROCK_AI_GATEWAY_TIMEOUT_MS_V1;
|
|
207
|
+
const doFetch = config.fetch ?? fetch;
|
|
208
|
+
return {
|
|
209
|
+
autoRoute,
|
|
210
|
+
async runChatCompletion(gatewayModel, body, signal, served) {
|
|
211
|
+
if (config.billingLimits) {
|
|
212
|
+
const billingLimits = gatewayBillingLimitsV1(
|
|
213
|
+
await config.billingLimits(),
|
|
214
|
+
autoRoute,
|
|
215
|
+
);
|
|
216
|
+
const { inputTokens, outputTokens } =
|
|
217
|
+
billingLimits.byGatewayModel.get(gatewayModel) ??
|
|
218
|
+
billingLimits.smallest;
|
|
219
|
+
// A byte bound overcounts text tokens. An image is not its base64:
|
|
220
|
+
// each one counts as a fixed allowance instead, and what it really
|
|
221
|
+
// cost settles from the model's reported usage, whose prompt tokens
|
|
222
|
+
// include the image's.
|
|
223
|
+
let images = 0;
|
|
224
|
+
const encoded = JSON.stringify(body, (key, value: unknown) => {
|
|
225
|
+
if (key !== "image_url") return value;
|
|
226
|
+
images += 1;
|
|
227
|
+
return {};
|
|
228
|
+
});
|
|
229
|
+
if (
|
|
230
|
+
new TextEncoder().encode(encoded).length +
|
|
231
|
+
1024 +
|
|
232
|
+
images * FROCK_AI_IMAGE_INPUT_TOKENS_V1 >
|
|
233
|
+
inputTokens
|
|
234
|
+
) {
|
|
235
|
+
throw new FrockAiTransportErrorV1(
|
|
236
|
+
"This request exceeds its prepaid model limit. Use a connected model for this request.",
|
|
237
|
+
400,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
body = { ...body, max_tokens: outputTokens };
|
|
241
|
+
}
|
|
242
|
+
// The deadline is disarmed the moment the response exists, so what it
|
|
243
|
+
// bounds is reaching the gateway and nothing after it. Left armed it
|
|
244
|
+
// aborted the response *body* mid-stream, which is not a gateway that
|
|
245
|
+
// never answered — it is an answer in progress — and the caller has no
|
|
246
|
+
// way to tell the two apart from the abort alone.
|
|
247
|
+
//
|
|
248
|
+
// The caller's `signal` is still chained in, and it is never disarmed:
|
|
249
|
+
// a Stop must tear the request down whether the headers have arrived or
|
|
250
|
+
// not.
|
|
251
|
+
const deadline = new AbortController();
|
|
252
|
+
const timer = setTimeout(() => {
|
|
253
|
+
deadline.abort(
|
|
254
|
+
new Error(`AI Gateway did not respond within ${timeoutMs}ms`),
|
|
255
|
+
);
|
|
256
|
+
}, timeoutMs);
|
|
257
|
+
const requestSignal = signal
|
|
258
|
+
? AbortSignal.any([signal, deadline.signal])
|
|
259
|
+
: deadline.signal;
|
|
260
|
+
const timedOut = (error: unknown): never => {
|
|
261
|
+
if (deadline.signal.aborted && !signal?.aborted) {
|
|
262
|
+
throw deadline.signal.reason as Error;
|
|
263
|
+
}
|
|
264
|
+
throw error;
|
|
265
|
+
};
|
|
266
|
+
const reachGatewayV1 = async (): Promise<ReadableStream<Uint8Array>> => {
|
|
267
|
+
if (useCompat) {
|
|
268
|
+
let response: Response;
|
|
269
|
+
try {
|
|
270
|
+
response = await doFetch(
|
|
271
|
+
compatChatCompletionsUrlV1(accountId!, gatewayId),
|
|
272
|
+
{
|
|
273
|
+
method: "POST",
|
|
274
|
+
headers: {
|
|
275
|
+
"content-type": "application/json",
|
|
276
|
+
"cf-aig-authorization": `Bearer ${token!}`,
|
|
277
|
+
// Every answer is billed as the model that produced it; a
|
|
278
|
+
// cached one would be another request's answer.
|
|
279
|
+
"cf-aig-skip-cache": "true",
|
|
280
|
+
},
|
|
281
|
+
body: JSON.stringify({ ...body, model: gatewayModel }),
|
|
282
|
+
signal: requestSignal,
|
|
283
|
+
},
|
|
284
|
+
);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
return timedOut(error);
|
|
287
|
+
}
|
|
288
|
+
return streamOrThrowV1(response, served);
|
|
289
|
+
}
|
|
290
|
+
// The `AI` binding takes no signal, so the deadline is raced against
|
|
291
|
+
// the call rather than cancelling it.
|
|
292
|
+
//
|
|
293
|
+
// The loser of that race is cleaned up rather than left hanging. Once
|
|
294
|
+
// the deadline stopped firing on the success path there was nothing
|
|
295
|
+
// left to settle this promise or drop its listener, so every request
|
|
296
|
+
// that answered normally left one of each attached to a signal that
|
|
297
|
+
// lives as long as the Turn. `abandonRace` is what ends it.
|
|
298
|
+
const abandonRace = new AbortController();
|
|
299
|
+
// An RPC-backed binding hands back a stub; left undisposed, it keeps
|
|
300
|
+
// the Durable Object that asked referenced and unevictable.
|
|
301
|
+
const gateway = ai.gateway(gatewayId) as ReturnType<Ai["gateway"]> &
|
|
302
|
+
Partial<Disposable>;
|
|
303
|
+
let response: Response;
|
|
304
|
+
try {
|
|
305
|
+
response = await Promise.race([
|
|
306
|
+
gateway.run({
|
|
307
|
+
provider: "compat",
|
|
308
|
+
endpoint: "chat/completions",
|
|
309
|
+
headers: { "cf-aig-skip-cache": "true" },
|
|
310
|
+
query: { ...body, model: gatewayModel },
|
|
311
|
+
}),
|
|
312
|
+
new Promise<never>((_resolve, reject) => {
|
|
313
|
+
requestSignal.addEventListener(
|
|
314
|
+
"abort",
|
|
315
|
+
() => reject(requestSignal.reason),
|
|
316
|
+
{ once: true, signal: abandonRace.signal },
|
|
317
|
+
);
|
|
318
|
+
}),
|
|
319
|
+
]);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
return timedOut(error);
|
|
322
|
+
} finally {
|
|
323
|
+
abandonRace.abort();
|
|
324
|
+
gateway[Symbol.dispose]?.();
|
|
325
|
+
}
|
|
326
|
+
return streamOrThrowV1(response, served);
|
|
327
|
+
};
|
|
328
|
+
try {
|
|
329
|
+
return await reachGatewayV1();
|
|
330
|
+
} finally {
|
|
331
|
+
// Disarmed here rather than per-branch: whatever happened, the gateway
|
|
332
|
+
// has either answered or failed, and the body is the stream deadline's
|
|
333
|
+
// to bound from now on.
|
|
334
|
+
clearTimeout(timer);
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
}
|