@tanstack/ai-cloudflare 0.0.0 → 0.1.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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -2
  3. package/dist/esm/adapters/embedding.d.ts +19 -0
  4. package/dist/esm/adapters/embedding.js +60 -0
  5. package/dist/esm/adapters/embedding.js.map +1 -0
  6. package/dist/esm/adapters/image.d.ts +26 -0
  7. package/dist/esm/adapters/image.js +62 -0
  8. package/dist/esm/adapters/image.js.map +1 -0
  9. package/dist/esm/adapters/summarize.d.ts +15 -0
  10. package/dist/esm/adapters/summarize.js +22 -0
  11. package/dist/esm/adapters/summarize.js.map +1 -0
  12. package/dist/esm/adapters/text.d.ts +71 -0
  13. package/dist/esm/adapters/text.js +88 -0
  14. package/dist/esm/adapters/text.js.map +1 -0
  15. package/dist/esm/adapters/transcription.d.ts +21 -0
  16. package/dist/esm/adapters/transcription.js +134 -0
  17. package/dist/esm/adapters/transcription.js.map +1 -0
  18. package/dist/esm/adapters/tts.d.ts +26 -0
  19. package/dist/esm/adapters/tts.js +68 -0
  20. package/dist/esm/adapters/tts.js.map +1 -0
  21. package/dist/esm/byok.d.ts +9 -0
  22. package/dist/esm/byok.js +24 -0
  23. package/dist/esm/byok.js.map +1 -0
  24. package/dist/esm/gateway.d.ts +30 -0
  25. package/dist/esm/gateway.js +45 -0
  26. package/dist/esm/gateway.js.map +1 -0
  27. package/dist/esm/index.d.ts +16 -0
  28. package/dist/esm/index.js +8 -0
  29. package/dist/esm/utils/config.d.ts +60 -0
  30. package/dist/esm/utils/config.js +48 -0
  31. package/dist/esm/utils/config.js.map +1 -0
  32. package/dist/esm/utils/fetch.d.ts +27 -0
  33. package/dist/esm/utils/fetch.js +94 -0
  34. package/dist/esm/utils/fetch.js.map +1 -0
  35. package/dist/esm/utils/models.d.ts +16 -0
  36. package/dist/esm/utils/run.d.ts +21 -0
  37. package/dist/esm/utils/run.js +62 -0
  38. package/dist/esm/utils/run.js.map +1 -0
  39. package/package.json +73 -4
  40. package/src/adapters/embedding.ts +84 -0
  41. package/src/adapters/image.ts +97 -0
  42. package/src/adapters/summarize.ts +46 -0
  43. package/src/adapters/text.ts +150 -0
  44. package/src/adapters/transcription.ts +227 -0
  45. package/src/adapters/tts.ts +100 -0
  46. package/src/byok.ts +21 -0
  47. package/src/gateway.ts +57 -0
  48. package/src/index.ts +68 -0
  49. package/src/utils/config.ts +129 -0
  50. package/src/utils/fetch.ts +131 -0
  51. package/src/utils/models.ts +41 -0
  52. package/src/utils/run.ts +116 -0
@@ -0,0 +1,227 @@
1
+ import { BaseTranscriptionAdapter } from '@tanstack/ai/adapters'
2
+ import { toRunErrorPayload } from '@tanstack/ai/adapter-internals'
3
+ import { arrayBufferToBase64, generateId } from '@tanstack/ai-utils'
4
+ import { isBindingConfig, resolveConfigFromEnv } from '../utils/config'
5
+ import { runModel } from '../utils/run'
6
+ import type {
7
+ TranscriptionOptions,
8
+ TranscriptionResult,
9
+ TranscriptionSegment,
10
+ TranscriptionWord,
11
+ } from '@tanstack/ai'
12
+ import type {
13
+ CloudflareConfig,
14
+ CloudflareConfigInput,
15
+ FetchLike,
16
+ } from '../utils/config'
17
+ import type { CloudflareTranscriptionModel } from '../utils/models'
18
+
19
+ /** Extra inputs forwarded to the transcription model (model specific). */
20
+ export type CloudflareTranscriptionProviderOptions = Record<string, unknown>
21
+
22
+ interface WhisperOutput {
23
+ text?: string
24
+ transcription_info?: { language?: string; duration?: number }
25
+ segments?: Array<{ start: number; end: number; text: string }>
26
+ words?: Array<{ word?: string; start?: number; end?: number }>
27
+ }
28
+
29
+ interface NovaOutput {
30
+ results?: {
31
+ channels?: Array<{
32
+ alternatives?: Array<{
33
+ transcript?: string
34
+ words?: Array<{ word?: string; start?: number; end?: number }>
35
+ }>
36
+ }>
37
+ }
38
+ metadata?: { duration?: number }
39
+ }
40
+
41
+ async function toBytes(
42
+ audio: TranscriptionOptions['audio'],
43
+ fetchImpl: FetchLike,
44
+ signal: AbortSignal | undefined,
45
+ ): Promise<{ bytes: ArrayBuffer; contentType: string }> {
46
+ if (typeof audio === 'string') {
47
+ // Base64 (data URI or bare) or a URL to fetch.
48
+ if (/^https?:\/\//.test(audio)) {
49
+ const response = await fetchImpl(audio, { signal })
50
+ if (!response.ok) {
51
+ throw new Error(
52
+ `Could not fetch audio from ${audio} (${response.status})`,
53
+ )
54
+ }
55
+ return {
56
+ bytes: await response.arrayBuffer(),
57
+ contentType: response.headers.get('content-type') ?? 'audio/mpeg',
58
+ }
59
+ }
60
+ const match = /^data:([^;]+);base64,(.*)$/.exec(audio)
61
+ const base64 = match?.[2] ?? audio
62
+ const binary = atob(base64)
63
+ const bytes = new Uint8Array(binary.length)
64
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
65
+ return { bytes: bytes.buffer, contentType: match?.[1] ?? 'audio/mpeg' }
66
+ }
67
+ if (audio instanceof ArrayBuffer) {
68
+ return { bytes: audio, contentType: 'audio/mpeg' }
69
+ }
70
+ return {
71
+ bytes: await audio.arrayBuffer(),
72
+ contentType: audio.type || 'audio/mpeg',
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Cloudflare transcription adapter. Whisper models take base64 audio in the
78
+ * `audio` input; Deepgram Nova takes the raw bytes. Both return text plus
79
+ * timed words, and Whisper also returns segments.
80
+ */
81
+ export class CloudflareTranscriptionAdapter<
82
+ TModel extends CloudflareTranscriptionModel,
83
+ > extends BaseTranscriptionAdapter<
84
+ TModel,
85
+ CloudflareTranscriptionProviderOptions
86
+ > {
87
+ readonly name = 'cloudflare' as const
88
+
89
+ constructor(
90
+ private readonly cfConfig: CloudflareConfig,
91
+ model: TModel,
92
+ ) {
93
+ super(model, {})
94
+ }
95
+
96
+ async transcribe(
97
+ options: TranscriptionOptions<CloudflareTranscriptionProviderOptions>,
98
+ ): Promise<TranscriptionResult> {
99
+ const { model, logger, language, prompt } = options
100
+ try {
101
+ logger.request(
102
+ `activity=transcription provider=${this.name} model=${model}`,
103
+ {
104
+ provider: this.name,
105
+ model,
106
+ },
107
+ )
108
+ const fetchImpl =
109
+ (isBindingConfig(this.cfConfig) ? undefined : this.cfConfig.fetch) ??
110
+ fetch
111
+ const { bytes, contentType } = await toBytes(
112
+ options.audio,
113
+ fetchImpl,
114
+ options.abortSignal,
115
+ )
116
+ const output = model.startsWith('@cf/deepgram/')
117
+ ? await this.runNova(model, bytes, contentType, options)
118
+ : await this.runWhisper(
119
+ model,
120
+ bytes,
121
+ { language, prompt, ...options.modelOptions },
122
+ options.abortSignal,
123
+ )
124
+ return { id: generateId(this.name), model, ...output }
125
+ } catch (error: unknown) {
126
+ logger.errors(`${this.name}.transcribe fatal`, {
127
+ error: toRunErrorPayload(error, `${this.name}.transcribe failed`),
128
+ source: `${this.name}.transcribe`,
129
+ })
130
+ throw error
131
+ }
132
+ }
133
+
134
+ private async runWhisper(
135
+ model: string,
136
+ bytes: ArrayBuffer,
137
+ inputs: { language?: string; prompt?: string } & Record<string, unknown>,
138
+ signal: AbortSignal | undefined,
139
+ ): Promise<Omit<TranscriptionResult, 'id' | 'model'>> {
140
+ const { language, prompt, ...rest } = inputs
141
+ const output = (await runModel(
142
+ this.cfConfig,
143
+ model,
144
+ {
145
+ ...(language && { language }),
146
+ ...(prompt && { initial_prompt: prompt }),
147
+ ...rest,
148
+ audio: arrayBufferToBase64(bytes),
149
+ },
150
+ { signal },
151
+ )) as WhisperOutput
152
+ if (typeof output.text !== 'string') {
153
+ throw new Error(`Workers AI ${model} returned no transcript`)
154
+ }
155
+ return {
156
+ text: output.text,
157
+ language: output.transcription_info?.language,
158
+ duration: output.transcription_info?.duration,
159
+ segments: output.segments?.map(
160
+ (segment, id): TranscriptionSegment => ({
161
+ id,
162
+ start: segment.start,
163
+ end: segment.end,
164
+ text: segment.text.trim(),
165
+ }),
166
+ ),
167
+ words: toWords(output.words),
168
+ }
169
+ }
170
+
171
+ private async runNova(
172
+ model: string,
173
+ bytes: ArrayBuffer,
174
+ contentType: string,
175
+ options: TranscriptionOptions<CloudflareTranscriptionProviderOptions>,
176
+ ): Promise<Omit<TranscriptionResult, 'id' | 'model'>> {
177
+ const output = (await runModel(
178
+ this.cfConfig,
179
+ model,
180
+ {
181
+ ...(options.language && { language: options.language }),
182
+ ...options.modelOptions,
183
+ },
184
+ {
185
+ signal: options.abortSignal,
186
+ binary: { field: 'audio', body: bytes, contentType },
187
+ },
188
+ )) as NovaOutput
189
+ const alternative = output.results?.channels?.[0]?.alternatives?.[0]
190
+ if (typeof alternative?.transcript !== 'string') {
191
+ throw new Error(`Workers AI ${model} returned no transcript`)
192
+ }
193
+ return {
194
+ text: alternative.transcript,
195
+ duration: output.metadata?.duration,
196
+ words: toWords(alternative?.words),
197
+ }
198
+ }
199
+ }
200
+
201
+ function toWords(
202
+ words: Array<{ word?: string; start?: number; end?: number }> | undefined,
203
+ ): Array<TranscriptionWord> | undefined {
204
+ return words?.map((w) => ({
205
+ word: (w.word ?? '').trim(),
206
+ start: w.start ?? 0,
207
+ end: w.end ?? 0,
208
+ }))
209
+ }
210
+
211
+ export function createCloudflareTranscription<
212
+ TModel extends CloudflareTranscriptionModel,
213
+ >(
214
+ model: TModel,
215
+ config: CloudflareConfig,
216
+ ): CloudflareTranscriptionAdapter<TModel> {
217
+ return new CloudflareTranscriptionAdapter(config, model)
218
+ }
219
+
220
+ export function cloudflareTranscription<
221
+ TModel extends CloudflareTranscriptionModel,
222
+ >(
223
+ model: TModel,
224
+ config?: CloudflareConfigInput,
225
+ ): CloudflareTranscriptionAdapter<TModel> {
226
+ return new CloudflareTranscriptionAdapter(resolveConfigFromEnv(config), model)
227
+ }
@@ -0,0 +1,100 @@
1
+ import { BaseTTSAdapter } from '@tanstack/ai/adapters'
2
+ import { toRunErrorPayload } from '@tanstack/ai/adapter-internals'
3
+ import { generateId } from '@tanstack/ai-utils'
4
+ import { resolveConfigFromEnv } from '../utils/config'
5
+ import { outputToBase64, runModel } from '../utils/run'
6
+ import type { TTSOptions, TTSResult } from '@tanstack/ai'
7
+ import type { CloudflareConfig, CloudflareConfigInput } from '../utils/config'
8
+ import type { CloudflareTTSModel } from '../utils/models'
9
+
10
+ /** Text-to-speech inputs forwarded to the model (Deepgram Aura fields). */
11
+ export interface CloudflareTTSProviderOptions {
12
+ speaker?: string
13
+ encoding?: 'linear16' | 'flac' | 'mulaw' | 'alaw' | 'mp3' | 'opus' | 'aac'
14
+ container?: 'none' | 'wav' | 'ogg'
15
+ sample_rate?: number
16
+ bit_rate?: number
17
+ [key: string]: unknown
18
+ }
19
+
20
+ const CONTENT_TYPES: Record<string, string> = {
21
+ mp3: 'audio/mpeg',
22
+ opus: 'audio/opus',
23
+ aac: 'audio/aac',
24
+ flac: 'audio/flac',
25
+ wav: 'audio/wav',
26
+ pcm: 'audio/pcm',
27
+ }
28
+
29
+ /**
30
+ * Cloudflare text-to-speech adapter for Workers AI models such as Deepgram
31
+ * Aura. `voice` maps to `speaker` and `format` to `encoding`; the audio comes
32
+ * back base64-encoded.
33
+ */
34
+ export class CloudflareTTSAdapter<
35
+ TModel extends CloudflareTTSModel,
36
+ > extends BaseTTSAdapter<TModel, CloudflareTTSProviderOptions> {
37
+ readonly name = 'cloudflare' as const
38
+
39
+ constructor(
40
+ private readonly cfConfig: CloudflareConfig,
41
+ model: TModel,
42
+ ) {
43
+ super(model, {})
44
+ }
45
+
46
+ async generateSpeech(
47
+ options: TTSOptions<CloudflareTTSProviderOptions>,
48
+ ): Promise<TTSResult> {
49
+ const { model, logger, text, voice, format = 'mp3' } = options
50
+ const inputs = {
51
+ ...(voice && { speaker: voice }),
52
+ // Aura takes the codec as `encoding` and the wrapper as `container`;
53
+ // `wav` and `pcm` are both linear16.
54
+ encoding: format === 'wav' || format === 'pcm' ? 'linear16' : format,
55
+ ...(format === 'wav' && { container: 'wav' }),
56
+ ...options.modelOptions,
57
+ text,
58
+ }
59
+ try {
60
+ logger.request(
61
+ `activity=tts provider=${this.name} model=${model} chars=${text.length}`,
62
+ { provider: this.name, model },
63
+ )
64
+ const output = await runModel(this.cfConfig, model, inputs, {
65
+ signal: options.abortSignal,
66
+ })
67
+ const audio =
68
+ output && typeof output === 'object' && 'audio' in output
69
+ ? (output as { audio: string }).audio
70
+ : await outputToBase64(output)
71
+ return {
72
+ id: generateId(this.name),
73
+ model,
74
+ audio,
75
+ format,
76
+ contentType: CONTENT_TYPES[format],
77
+ }
78
+ } catch (error: unknown) {
79
+ logger.errors(`${this.name}.generateSpeech fatal`, {
80
+ error: toRunErrorPayload(error, `${this.name}.generateSpeech failed`),
81
+ source: `${this.name}.generateSpeech`,
82
+ })
83
+ throw error
84
+ }
85
+ }
86
+ }
87
+
88
+ export function createCloudflareTTS<TModel extends CloudflareTTSModel>(
89
+ model: TModel,
90
+ config: CloudflareConfig,
91
+ ): CloudflareTTSAdapter<TModel> {
92
+ return new CloudflareTTSAdapter(config, model)
93
+ }
94
+
95
+ export function cloudflareTTS<TModel extends CloudflareTTSModel>(
96
+ model: TModel,
97
+ config?: CloudflareConfigInput,
98
+ ): CloudflareTTSAdapter<TModel> {
99
+ return new CloudflareTTSAdapter(resolveConfigFromEnv(config), model)
100
+ }
package/src/byok.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { defineByokProvider } from '@tanstack/ai/byok'
2
+
3
+ /** The account id that goes with {@link cloudflareByok}. */
4
+ export const cloudflareAccountByok = defineByokProvider({
5
+ id: 'cloudflare-account',
6
+ label: 'Cloudflare account ID',
7
+ env: 'CLOUDFLARE_ACCOUNT_ID',
8
+ })
9
+
10
+ /**
11
+ * BYOK descriptor for a user-supplied Cloudflare API token. A user who brings
12
+ * a token brings the account it belongs to, so {@link cloudflareAccountByok}
13
+ * rides along: register both with `defineByok({ providers })` and a send for
14
+ * `cloudflare` carries both headers.
15
+ */
16
+ export const cloudflareByok = defineByokProvider({
17
+ id: 'cloudflare',
18
+ label: 'Cloudflare API token',
19
+ env: 'CLOUDFLARE_API_TOKEN',
20
+ with: [cloudflareAccountByok],
21
+ })
package/src/gateway.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { gatewayHeaders } from './utils/config'
2
+ import type { AIGatewayProviders } from '@cloudflare/workers-types'
3
+ import type { CloudflareGatewayOptions } from './utils/config'
4
+
5
+ export interface CloudflareGatewayTarget extends Omit<
6
+ CloudflareGatewayOptions,
7
+ 'id'
8
+ > {
9
+ accountId: string
10
+ gatewayId: string
11
+ /** Cloudflare API token, needed when the gateway has authentication on. */
12
+ cfApiKey?: string
13
+ }
14
+
15
+ /**
16
+ * Gateway endpoints mirror each vendor's own path after the host, and most
17
+ * vendor SDKs already append their version segment (Anthropic `/v1/messages`,
18
+ * Mistral `/v1/...`, Cohere `/v1/chat`). OpenAI-style SDKs append only
19
+ * `/chat/completions`, which matches Cloudflare's `openai`, `groq`,
20
+ * `perplexity-ai`, `deepseek`, and `cerebras` endpoints. xAI is the exception:
21
+ * Cloudflare serves it at `/grok/v1/...`, so its base URL keeps the `/v1`.
22
+ */
23
+ const PROVIDER_PATH_SUFFIX: Record<string, string> = { grok: '/v1' }
24
+
25
+ /**
26
+ * Builds the `baseURL` and headers that point any provider adapter at that
27
+ * provider's endpoint on your AI Gateway. Pass them through the adapter's
28
+ * client options (`baseURL` + `defaultHeaders` for OpenAI-style SDKs).
29
+ *
30
+ * The headers carry the per-request `cf-aig-*` options plus
31
+ * `cf-aig-authorization: Bearer <cfApiKey>` when `cfApiKey` is set. The
32
+ * gateway id lives in the URL, so no `cf-aig-gateway-id` header is sent.
33
+ *
34
+ * @example
35
+ * ```typescript
36
+ * const gateway = cloudflareGateway('openai', { accountId, gatewayId: 'prod' })
37
+ * const adapter = createOpenaiChat('gpt-5.5', process.env.OPENAI_API_KEY!, {
38
+ * baseURL: gateway.baseURL,
39
+ * defaultHeaders: gateway.headers,
40
+ * })
41
+ * ```
42
+ */
43
+ export function cloudflareGateway(
44
+ provider: AIGatewayProviders | 'compat' | (string & {}),
45
+ target: CloudflareGatewayTarget,
46
+ ): { baseURL: string; headers: Record<string, string> } {
47
+ const { accountId, gatewayId, cfApiKey, ...options } = target
48
+ const { 'cf-aig-gateway-id': _id, ...headers } = gatewayHeaders({
49
+ id: gatewayId,
50
+ ...options,
51
+ })
52
+ if (cfApiKey) headers['cf-aig-authorization'] = `Bearer ${cfApiKey}`
53
+ return {
54
+ baseURL: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gatewayId}/${provider}${PROVIDER_PATH_SUFFIX[provider] ?? ''}`,
55
+ headers,
56
+ }
57
+ }
package/src/index.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @module @tanstack/ai-cloudflare
3
+ *
4
+ * Cloudflare provider adapter for TanStack AI: Workers AI chat, embeddings,
5
+ * images, speech and transcription over the `env.AI` binding or the REST
6
+ * API, plus AI Gateway routing for any provider.
7
+ */
8
+
9
+ export {
10
+ CloudflareTextAdapter,
11
+ createCloudflareText,
12
+ cloudflareText,
13
+ type CloudflareTextProviderOptions,
14
+ } from './adapters/text'
15
+
16
+ export {
17
+ createCloudflareSummarize,
18
+ cloudflareSummarize,
19
+ type CloudflareSummarizeModel,
20
+ } from './adapters/summarize'
21
+
22
+ export {
23
+ CloudflareEmbeddingAdapter,
24
+ createCloudflareEmbedding,
25
+ cloudflareEmbedding,
26
+ type CloudflareEmbeddingProviderOptions,
27
+ } from './adapters/embedding'
28
+
29
+ export {
30
+ CloudflareImageAdapter,
31
+ createCloudflareImage,
32
+ cloudflareImage,
33
+ type CloudflareImageProviderOptions,
34
+ } from './adapters/image'
35
+
36
+ export {
37
+ CloudflareTTSAdapter,
38
+ createCloudflareTTS,
39
+ cloudflareTTS,
40
+ type CloudflareTTSProviderOptions,
41
+ } from './adapters/tts'
42
+
43
+ export {
44
+ CloudflareTranscriptionAdapter,
45
+ createCloudflareTranscription,
46
+ cloudflareTranscription,
47
+ type CloudflareTranscriptionProviderOptions,
48
+ } from './adapters/transcription'
49
+
50
+ export { cloudflareGateway, type CloudflareGatewayTarget } from './gateway'
51
+
52
+ export type {
53
+ CloudflareBindingConfig,
54
+ CloudflareConfig,
55
+ CloudflareConfigInput,
56
+ CloudflareGatewayOptions,
57
+ CloudflareRestConfig,
58
+ CloudflareTextConfig,
59
+ CloudflareTextRestConfig,
60
+ } from './utils/config'
61
+
62
+ export type {
63
+ CloudflareEmbeddingModel,
64
+ CloudflareImageModel,
65
+ CloudflareTextModel,
66
+ CloudflareTranscriptionModel,
67
+ CloudflareTTSModel,
68
+ } from './utils/models'
@@ -0,0 +1,129 @@
1
+ import { getApiKeyFromEnv } from '@tanstack/ai-utils'
2
+ import type { Ai, GatewayOptions } from '@cloudflare/workers-types'
3
+ import type { ClientOptions } from 'openai'
4
+
5
+ /**
6
+ * AI Gateway routing options. `id` is the gateway id (use `"default"` for the
7
+ * account's auto-created gateway). The remaining fields are per-request
8
+ * gateway controls (cache, logging, retries) and map to `cf-aig-*` headers on
9
+ * the REST path or to the `gateway` run option on the binding path.
10
+ */
11
+ export type CloudflareGatewayOptions = GatewayOptions
12
+
13
+ export type FetchLike = NonNullable<ClientOptions['fetch']>
14
+
15
+ /**
16
+ * Run through the Workers AI binding (`env.AI`) inside a Cloudflare Worker.
17
+ * No API token is needed.
18
+ */
19
+ export interface CloudflareBindingConfig {
20
+ binding: Ai
21
+ gateway?: CloudflareGatewayOptions
22
+ accountId?: never
23
+ apiKey?: never
24
+ }
25
+
26
+ /** Run through the Cloudflare REST API from any runtime. */
27
+ export interface CloudflareRestConfig {
28
+ accountId: string
29
+ apiKey: string
30
+ gateway?: CloudflareGatewayOptions
31
+ /** Custom fetch for every request. */
32
+ fetch?: FetchLike
33
+ binding?: never
34
+ }
35
+
36
+ /**
37
+ * REST config for the chat surface. Also accepts the OpenAI SDK client
38
+ * options (`baseURL`, `defaultHeaders`, `timeout`, `maxRetries`, ...), which
39
+ * only the text and summarize adapters read.
40
+ */
41
+ export interface CloudflareTextRestConfig
42
+ extends CloudflareRestConfig, Omit<ClientOptions, 'apiKey' | 'fetch'> {}
43
+
44
+ export type CloudflareConfig = CloudflareBindingConfig | CloudflareRestConfig
45
+
46
+ export type CloudflareTextConfig =
47
+ | CloudflareBindingConfig
48
+ | CloudflareTextRestConfig
49
+
50
+ /**
51
+ * What the env-reading factories accept: a binding, or REST fields with any
52
+ * missing ones read from `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN`.
53
+ */
54
+ export type CloudflareConfigInput<
55
+ TRest extends CloudflareRestConfig = CloudflareRestConfig,
56
+ > = CloudflareBindingConfig | Partial<TRest>
57
+
58
+ export const CLOUDFLARE_API_BASE = 'https://api.cloudflare.com/client/v4'
59
+
60
+ export function isBindingConfig(config: {
61
+ binding?: Ai
62
+ }): config is CloudflareBindingConfig {
63
+ return config.binding !== undefined
64
+ }
65
+
66
+ /** Base URL for the OpenAI-compatible chat surface of an account. */
67
+ export function restChatBaseURL(config: CloudflareTextRestConfig): string {
68
+ return (
69
+ config.baseURL ||
70
+ `${CLOUDFLARE_API_BASE}/accounts/${config.accountId}/ai/v1`
71
+ )
72
+ }
73
+
74
+ /**
75
+ * Translates gateway options into the `cf-aig-*` request headers the REST
76
+ * API reads. Retries are not mapped: set them on the gateway itself.
77
+ */
78
+ export function gatewayHeaders(
79
+ gateway: CloudflareGatewayOptions | undefined,
80
+ ): Record<string, string> {
81
+ if (!gateway) return {}
82
+ const headers: Record<string, string> = { 'cf-aig-gateway-id': gateway.id }
83
+ if (gateway.skipCache !== undefined) {
84
+ headers['cf-aig-skip-cache'] = String(gateway.skipCache)
85
+ }
86
+ if (gateway.cacheTtl !== undefined) {
87
+ headers['cf-aig-cache-ttl'] = String(gateway.cacheTtl)
88
+ }
89
+ if (gateway.cacheKey !== undefined) {
90
+ headers['cf-aig-cache-key'] = gateway.cacheKey
91
+ }
92
+ if (gateway.collectLog !== undefined) {
93
+ headers['cf-aig-collect-log'] = String(gateway.collectLog)
94
+ }
95
+ if (gateway.eventId !== undefined) {
96
+ headers['cf-aig-event-id'] = gateway.eventId
97
+ }
98
+ if (gateway.requestTimeoutMs !== undefined) {
99
+ headers['cf-aig-request-timeout'] = String(gateway.requestTimeoutMs)
100
+ }
101
+ if (gateway.metadata !== undefined) {
102
+ headers['cf-aig-metadata'] = JSON.stringify(gateway.metadata)
103
+ }
104
+ return headers
105
+ }
106
+
107
+ /**
108
+ * Resolves a config for the env-reading factories: a binding config passes
109
+ * through, anything else is filled from `CLOUDFLARE_ACCOUNT_ID` and
110
+ * `CLOUDFLARE_API_TOKEN`.
111
+ */
112
+ export function resolveConfigFromEnv<TRest extends CloudflareRestConfig>(
113
+ config: CloudflareConfigInput<TRest> | undefined,
114
+ ): CloudflareBindingConfig | (Partial<TRest> & CloudflareRestConfig) {
115
+ if (config && isBindingConfig(config)) return config
116
+ const rest: Partial<TRest> = config ?? {}
117
+ try {
118
+ return {
119
+ ...rest,
120
+ accountId: rest.accountId ?? getApiKeyFromEnv('CLOUDFLARE_ACCOUNT_ID'),
121
+ apiKey: rest.apiKey ?? getApiKeyFromEnv('CLOUDFLARE_API_TOKEN'),
122
+ }
123
+ } catch (cause) {
124
+ throw new Error(
125
+ 'CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN are required. Set them in your environment, pass { accountId, apiKey }, or pass { binding: env.AI } inside a Worker.',
126
+ { cause },
127
+ )
128
+ }
129
+ }