@tanstack/ai-cloudflare 0.0.0 → 0.1.1
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/LICENSE +21 -0
- package/README.md +53 -2
- package/dist/esm/adapters/embedding.d.ts +19 -0
- package/dist/esm/adapters/embedding.js +60 -0
- package/dist/esm/adapters/embedding.js.map +1 -0
- package/dist/esm/adapters/image.d.ts +26 -0
- package/dist/esm/adapters/image.js +62 -0
- package/dist/esm/adapters/image.js.map +1 -0
- package/dist/esm/adapters/summarize.d.ts +15 -0
- package/dist/esm/adapters/summarize.js +22 -0
- package/dist/esm/adapters/summarize.js.map +1 -0
- package/dist/esm/adapters/text.d.ts +71 -0
- package/dist/esm/adapters/text.js +88 -0
- package/dist/esm/adapters/text.js.map +1 -0
- package/dist/esm/adapters/transcription.d.ts +21 -0
- package/dist/esm/adapters/transcription.js +134 -0
- package/dist/esm/adapters/transcription.js.map +1 -0
- package/dist/esm/adapters/tts.d.ts +26 -0
- package/dist/esm/adapters/tts.js +68 -0
- package/dist/esm/adapters/tts.js.map +1 -0
- package/dist/esm/byok.d.ts +9 -0
- package/dist/esm/byok.js +24 -0
- package/dist/esm/byok.js.map +1 -0
- package/dist/esm/gateway.d.ts +30 -0
- package/dist/esm/gateway.js +45 -0
- package/dist/esm/gateway.js.map +1 -0
- package/dist/esm/index.d.ts +16 -0
- package/dist/esm/index.js +8 -0
- package/dist/esm/utils/config.d.ts +60 -0
- package/dist/esm/utils/config.js +48 -0
- package/dist/esm/utils/config.js.map +1 -0
- package/dist/esm/utils/fetch.d.ts +27 -0
- package/dist/esm/utils/fetch.js +94 -0
- package/dist/esm/utils/fetch.js.map +1 -0
- package/dist/esm/utils/models.d.ts +16 -0
- package/dist/esm/utils/run.d.ts +21 -0
- package/dist/esm/utils/run.js +62 -0
- package/dist/esm/utils/run.js.map +1 -0
- package/package.json +73 -4
- package/src/adapters/embedding.ts +84 -0
- package/src/adapters/image.ts +97 -0
- package/src/adapters/summarize.ts +46 -0
- package/src/adapters/text.ts +150 -0
- package/src/adapters/transcription.ts +227 -0
- package/src/adapters/tts.ts +100 -0
- package/src/byok.ts +21 -0
- package/src/gateway.ts +57 -0
- package/src/index.ts +68 -0
- package/src/utils/config.ts +129 -0
- package/src/utils/fetch.ts +131 -0
- package/src/utils/models.ts +41 -0
- 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
|
+
}
|