@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.
- 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,62 @@
|
|
|
1
|
+
import { CLOUDFLARE_API_BASE, gatewayHeaders, isBindingConfig } from "./config.js";
|
|
2
|
+
import { arrayBufferToBase64 } from "@tanstack/ai-utils";
|
|
3
|
+
//#region src/utils/run.ts
|
|
4
|
+
/**
|
|
5
|
+
* Runs a Workers AI model with its native task inputs (embeddings, image,
|
|
6
|
+
* speech, transcription) through the binding or the REST `/ai/run` endpoint.
|
|
7
|
+
*
|
|
8
|
+
* Returns the model's decoded output: an object for JSON tasks, or bytes
|
|
9
|
+
* (`Uint8Array` / `ReadableStream`) for binary media outputs.
|
|
10
|
+
*/
|
|
11
|
+
async function runModel(config, model, inputs, options) {
|
|
12
|
+
if (isBindingConfig(config)) return await config.binding.run.bind(config.binding)(model, options?.binary ? {
|
|
13
|
+
...inputs,
|
|
14
|
+
[options.binary.field]: {
|
|
15
|
+
body: new Response(options.binary.body).body,
|
|
16
|
+
contentType: options.binary.contentType
|
|
17
|
+
}
|
|
18
|
+
} : inputs, config.gateway ? { gateway: config.gateway } : void 0);
|
|
19
|
+
const url = new URL(`${CLOUDFLARE_API_BASE}/accounts/${config.accountId}/ai/run/${model}`);
|
|
20
|
+
const headers = {
|
|
21
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
22
|
+
...gatewayHeaders(config.gateway)
|
|
23
|
+
};
|
|
24
|
+
let body;
|
|
25
|
+
if (options?.binary) {
|
|
26
|
+
for (const [key, value] of Object.entries(inputs)) if (value !== void 0) url.searchParams.set(key, String(value));
|
|
27
|
+
headers["Content-Type"] = options.binary.contentType;
|
|
28
|
+
body = options.binary.body;
|
|
29
|
+
} else {
|
|
30
|
+
headers["Content-Type"] = "application/json";
|
|
31
|
+
body = JSON.stringify(inputs);
|
|
32
|
+
}
|
|
33
|
+
const response = await (config.fetch ?? fetch)(url, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers,
|
|
36
|
+
body,
|
|
37
|
+
signal: options?.signal
|
|
38
|
+
});
|
|
39
|
+
if (!response.ok) throw new Error(`Workers AI request for ${model} failed (${response.status}): ${await response.text()}`);
|
|
40
|
+
if (response.headers.get("content-type")?.includes("application/json")) {
|
|
41
|
+
const json = await response.json();
|
|
42
|
+
if (json.success === false) throw new Error(`Workers AI request for ${model} failed: ${json.errors?.map((e) => e.message).join("; ")}`);
|
|
43
|
+
return "result" in json ? json.result : json;
|
|
44
|
+
}
|
|
45
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
46
|
+
}
|
|
47
|
+
/** Base64-encodes a binary model output, whatever shape it arrived in. */
|
|
48
|
+
async function outputToBase64(output) {
|
|
49
|
+
if (typeof output === "string") return output;
|
|
50
|
+
let bytes;
|
|
51
|
+
if (output instanceof Uint8Array) bytes = output;
|
|
52
|
+
else if (output instanceof ArrayBuffer) bytes = new Uint8Array(output);
|
|
53
|
+
else if (output instanceof ReadableStream) bytes = new Uint8Array(await new Response(output).arrayBuffer());
|
|
54
|
+
else throw new Error(`Unexpected Workers AI output type: ${Object.prototype.toString.call(output)}`);
|
|
55
|
+
const copy = new Uint8Array(bytes.byteLength);
|
|
56
|
+
copy.set(bytes);
|
|
57
|
+
return arrayBufferToBase64(copy.buffer);
|
|
58
|
+
}
|
|
59
|
+
//#endregion
|
|
60
|
+
export { outputToBase64, runModel };
|
|
61
|
+
|
|
62
|
+
//# sourceMappingURL=run.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"run.js","names":[],"sources":["../../../src/utils/run.ts"],"sourcesContent":["import { arrayBufferToBase64 } from '@tanstack/ai-utils'\nimport { CLOUDFLARE_API_BASE, gatewayHeaders, isBindingConfig } from './config'\nimport type { CloudflareConfig } from './config'\n\nexport type RunInputs = Record<string, unknown>\n\nexport interface RunBinary {\n /** Input field that carries the bytes on the binding path. */\n field: string\n body: Uint8Array | ArrayBuffer | Blob\n contentType: string\n}\n\n/**\n * Runs a Workers AI model with its native task inputs (embeddings, image,\n * speech, transcription) through the binding or the REST `/ai/run` endpoint.\n *\n * Returns the model's decoded output: an object for JSON tasks, or bytes\n * (`Uint8Array` / `ReadableStream`) for binary media outputs.\n */\nexport async function runModel(\n config: CloudflareConfig,\n model: string,\n inputs: RunInputs,\n options?: { signal?: AbortSignal; binary?: RunBinary },\n): Promise<unknown> {\n if (isBindingConfig(config)) {\n const run = config.binding.run.bind(config.binding) as (\n model: string,\n inputs: RunInputs,\n options?: Record<string, unknown>,\n ) => Promise<unknown>\n // The binding serializes inputs as JSON; binary bodies must travel as a\n // ReadableStream, which it forwards as the raw request body.\n const bindingInputs = options?.binary\n ? {\n ...inputs,\n [options.binary.field]: {\n body: new Response(options.binary.body as BodyInit).body,\n contentType: options.binary.contentType,\n },\n }\n : inputs\n return await run(\n model,\n bindingInputs,\n config.gateway ? { gateway: config.gateway } : undefined,\n )\n }\n\n const url = new URL(\n `${CLOUDFLARE_API_BASE}/accounts/${config.accountId}/ai/run/${model}`,\n )\n const headers: Record<string, string> = {\n Authorization: `Bearer ${config.apiKey}`,\n ...gatewayHeaders(config.gateway),\n }\n let body: BodyInit\n if (options?.binary) {\n // Binary tasks take the bytes as the body and the other inputs as query.\n for (const [key, value] of Object.entries(inputs)) {\n if (value !== undefined) url.searchParams.set(key, String(value))\n }\n headers['Content-Type'] = options.binary.contentType\n body = options.binary.body as BodyInit\n } else {\n headers['Content-Type'] = 'application/json'\n body = JSON.stringify(inputs)\n }\n const fetchImpl = config.fetch ?? fetch\n const response = await fetchImpl(url, {\n method: 'POST',\n headers,\n body,\n signal: options?.signal,\n })\n if (!response.ok) {\n throw new Error(\n `Workers AI request for ${model} failed (${response.status}): ${await response.text()}`,\n )\n }\n if (response.headers.get('content-type')?.includes('application/json')) {\n const json = (await response.json()) as {\n success?: boolean\n result?: unknown\n errors?: Array<{ message?: string }>\n }\n if (json.success === false) {\n throw new Error(\n `Workers AI request for ${model} failed: ${json.errors?.map((e) => e.message).join('; ')}`,\n )\n }\n return 'result' in json ? json.result : json\n }\n return new Uint8Array(await response.arrayBuffer())\n}\n\n/** Base64-encodes a binary model output, whatever shape it arrived in. */\nexport async function outputToBase64(output: unknown): Promise<string> {\n if (typeof output === 'string') return output\n let bytes: Uint8Array\n if (output instanceof Uint8Array) {\n bytes = output\n } else if (output instanceof ArrayBuffer) {\n bytes = new Uint8Array(output)\n } else if (output instanceof ReadableStream) {\n bytes = new Uint8Array(await new Response(output).arrayBuffer())\n } else {\n throw new Error(\n `Unexpected Workers AI output type: ${Object.prototype.toString.call(output)}`,\n )\n }\n const copy = new Uint8Array(bytes.byteLength)\n copy.set(bytes)\n return arrayBufferToBase64(copy.buffer)\n}\n"],"mappings":";;;;;;;;;;AAoBA,eAAsB,SACpB,QACA,OACA,QACA,SACkB;CAClB,IAAI,gBAAgB,MAAM,GAiBxB,OAAO,MAhBK,OAAO,QAAQ,IAAI,KAAK,OAAO,OAgB9B,CAAA,CACX,OAVoB,SAAS,SAC3B;EACE,GAAG;GACF,QAAQ,OAAO,QAAQ;GACtB,MAAM,IAAI,SAAS,QAAQ,OAAO,IAAgB,CAAC,CAAC;GACpD,aAAa,QAAQ,OAAO;EAC9B;CACF,IACA,QAIF,OAAO,UAAU,EAAE,SAAS,OAAO,QAAQ,IAAI,KAAA,CACjD;CAGF,MAAM,MAAM,IAAI,IACd,GAAG,oBAAoB,YAAY,OAAO,UAAU,UAAU,OAChE;CACA,MAAM,UAAkC;EACtC,eAAe,UAAU,OAAO;EAChC,GAAG,eAAe,OAAO,OAAO;CAClC;CACA,IAAI;CACJ,IAAI,SAAS,QAAQ;EAEnB,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,MAAM,GAC9C,IAAI,UAAU,KAAA,GAAW,IAAI,aAAa,IAAI,KAAK,OAAO,KAAK,CAAC;EAElE,QAAQ,kBAAkB,QAAQ,OAAO;EACzC,OAAO,QAAQ,OAAO;CACxB,OAAO;EACL,QAAQ,kBAAkB;EAC1B,OAAO,KAAK,UAAU,MAAM;CAC9B;CAEA,MAAM,WAAW,OADC,OAAO,SAAS,MAAA,CACD,KAAK;EACpC,QAAQ;EACR;EACA;EACA,QAAQ,SAAS;CACnB,CAAC;CACD,IAAI,CAAC,SAAS,IACZ,MAAM,IAAI,MACR,0BAA0B,MAAM,WAAW,SAAS,OAAO,KAAK,MAAM,SAAS,KAAK,GACtF;CAEF,IAAI,SAAS,QAAQ,IAAI,cAAc,CAAC,EAAE,SAAS,kBAAkB,GAAG;EACtE,MAAM,OAAQ,MAAM,SAAS,KAAK;EAKlC,IAAI,KAAK,YAAY,OACnB,MAAM,IAAI,MACR,0BAA0B,MAAM,WAAW,KAAK,QAAQ,KAAK,MAAM,EAAE,OAAO,CAAC,CAAC,KAAK,IAAI,GACzF;EAEF,OAAO,YAAY,OAAO,KAAK,SAAS;CAC1C;CACA,OAAO,IAAI,WAAW,MAAM,SAAS,YAAY,CAAC;AACpD;;AAGA,eAAsB,eAAe,QAAkC;CACrE,IAAI,OAAO,WAAW,UAAU,OAAO;CACvC,IAAI;CACJ,IAAI,kBAAkB,YACpB,QAAQ;MACH,IAAI,kBAAkB,aAC3B,QAAQ,IAAI,WAAW,MAAM;MACxB,IAAI,kBAAkB,gBAC3B,QAAQ,IAAI,WAAW,MAAM,IAAI,SAAS,MAAM,CAAC,CAAC,YAAY,CAAC;MAE/D,MAAM,IAAI,MACR,sCAAsC,OAAO,UAAU,SAAS,KAAK,MAAM,GAC7E;CAEF,MAAM,OAAO,IAAI,WAAW,MAAM,UAAU;CAC5C,KAAK,IAAI,KAAK;CACd,OAAO,oBAAoB,KAAK,MAAM;AACxC"}
|
package/package.json
CHANGED
|
@@ -1,11 +1,80 @@
|
|
|
1
|
-
|
|
1
|
+
{
|
|
2
2
|
"name": "@tanstack/ai-cloudflare",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cloudflare adapter for TanStack AI — Workers AI chat, embeddings, images, speech, transcription, and summarization over the AI binding or REST, with AI Gateway routing for any provider.",
|
|
5
|
+
"author": "Tanner Linsley",
|
|
5
6
|
"license": "MIT",
|
|
7
|
+
"homepage": "https://tanstack.com/ai",
|
|
6
8
|
"repository": {
|
|
7
9
|
"type": "git",
|
|
8
10
|
"url": "git+https://github.com/TanStack/ai.git",
|
|
9
11
|
"directory": "packages/ai-cloudflare"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/TanStack/ai/issues"
|
|
15
|
+
},
|
|
16
|
+
"funding": {
|
|
17
|
+
"type": "github",
|
|
18
|
+
"url": "https://github.com/sponsors/tannerlinsley"
|
|
19
|
+
},
|
|
20
|
+
"type": "module",
|
|
21
|
+
"module": "./dist/esm/index.js",
|
|
22
|
+
"types": "./dist/esm/index.d.ts",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/esm/index.d.ts",
|
|
26
|
+
"import": "./dist/esm/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./byok": {
|
|
29
|
+
"types": "./dist/esm/byok.d.ts",
|
|
30
|
+
"import": "./dist/esm/byok.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"src"
|
|
36
|
+
],
|
|
37
|
+
"keywords": [
|
|
38
|
+
"ai",
|
|
39
|
+
"ai-sdk",
|
|
40
|
+
"typescript",
|
|
41
|
+
"tanstack",
|
|
42
|
+
"cloudflare",
|
|
43
|
+
"workers-ai",
|
|
44
|
+
"ai-gateway",
|
|
45
|
+
"workers",
|
|
46
|
+
"adapter",
|
|
47
|
+
"chat",
|
|
48
|
+
"streaming",
|
|
49
|
+
"tool-calling",
|
|
50
|
+
"structured-outputs",
|
|
51
|
+
"embeddings",
|
|
52
|
+
"image-generation",
|
|
53
|
+
"text-to-speech",
|
|
54
|
+
"transcription"
|
|
55
|
+
],
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"@cloudflare/workers-types": "^4.20260317.1",
|
|
58
|
+
"openai": "^6.41.0",
|
|
59
|
+
"@tanstack/ai-utils": "^0.4.0",
|
|
60
|
+
"@tanstack/openai-base": "^0.10.10"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@vitest/coverage-v8": "4.1.10",
|
|
64
|
+
"vite": "^8.2.1",
|
|
65
|
+
"@tanstack/ai": "0.53.0"
|
|
66
|
+
},
|
|
67
|
+
"peerDependencies": {
|
|
68
|
+
"@tanstack/ai": "^0.53.0"
|
|
69
|
+
},
|
|
70
|
+
"scripts": {
|
|
71
|
+
"build": "vite build",
|
|
72
|
+
"clean": "premove ./build ./dist",
|
|
73
|
+
"lint:fix": "oxlint src --type-aware --fix",
|
|
74
|
+
"test:build": "publint --strict",
|
|
75
|
+
"test:oxlint": "oxlint src --type-aware",
|
|
76
|
+
"test:lib": "vitest run",
|
|
77
|
+
"test:lib:dev": "pnpm test:lib --watch",
|
|
78
|
+
"test:types": "tsc"
|
|
10
79
|
}
|
|
11
|
-
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { BaseEmbeddingAdapter } from '@tanstack/ai/adapters'
|
|
2
|
+
import { toRunErrorPayload } from '@tanstack/ai/adapter-internals'
|
|
3
|
+
import { generateId } from '@tanstack/ai-utils'
|
|
4
|
+
import { requireTextOnlyEmbeddingInput } from '@tanstack/ai'
|
|
5
|
+
import { resolveConfigFromEnv } from '../utils/config'
|
|
6
|
+
import { runModel } from '../utils/run'
|
|
7
|
+
import type { EmbeddingOptions, EmbeddingResult } from '@tanstack/ai'
|
|
8
|
+
import type { CloudflareConfig, CloudflareConfigInput } from '../utils/config'
|
|
9
|
+
import type { CloudflareEmbeddingModel } from '../utils/models'
|
|
10
|
+
|
|
11
|
+
/** Extra inputs forwarded to the embedding model (model specific). */
|
|
12
|
+
export type CloudflareEmbeddingProviderOptions = Record<string, unknown>
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Cloudflare embedding adapter. Runs Workers AI text-embedding models
|
|
16
|
+
* (`{ text: [...] }` in, `{ data: number[][] }` out) through the binding or
|
|
17
|
+
* the REST API.
|
|
18
|
+
*/
|
|
19
|
+
export class CloudflareEmbeddingAdapter<
|
|
20
|
+
TModel extends CloudflareEmbeddingModel,
|
|
21
|
+
> extends BaseEmbeddingAdapter<TModel, CloudflareEmbeddingProviderOptions> {
|
|
22
|
+
readonly name = 'cloudflare' as const
|
|
23
|
+
|
|
24
|
+
constructor(
|
|
25
|
+
private readonly cfConfig: CloudflareConfig,
|
|
26
|
+
model: TModel,
|
|
27
|
+
) {
|
|
28
|
+
super(model, {})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async createEmbeddings(
|
|
32
|
+
options: EmbeddingOptions<CloudflareEmbeddingProviderOptions>,
|
|
33
|
+
): Promise<EmbeddingResult> {
|
|
34
|
+
const { model, logger } = options
|
|
35
|
+
const texts = requireTextOnlyEmbeddingInput(options.input, this.name, model)
|
|
36
|
+
if (options.dimensions !== undefined) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
'Workers AI embedding models have fixed dimensions; do not set `dimensions`',
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
logger.request(
|
|
43
|
+
`activity=embed provider=${this.name} model=${model} inputs=${texts.length}`,
|
|
44
|
+
{ provider: this.name, model },
|
|
45
|
+
)
|
|
46
|
+
const output = (await runModel(this.cfConfig, model, {
|
|
47
|
+
...options.modelOptions,
|
|
48
|
+
text: texts,
|
|
49
|
+
})) as { data?: Array<Array<number>> }
|
|
50
|
+
if (!Array.isArray(output.data) || output.data.length !== texts.length) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Workers AI ${model} returned ${output.data?.length ?? 0} embeddings for ${texts.length} inputs`,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
id: generateId(this.name),
|
|
57
|
+
model,
|
|
58
|
+
embeddings: output.data.map((vector, index) => ({
|
|
59
|
+
vector,
|
|
60
|
+
index,
|
|
61
|
+
})),
|
|
62
|
+
}
|
|
63
|
+
} catch (error: unknown) {
|
|
64
|
+
logger.errors(`${this.name}.createEmbeddings fatal`, {
|
|
65
|
+
error: toRunErrorPayload(error, `${this.name}.createEmbeddings failed`),
|
|
66
|
+
source: `${this.name}.createEmbeddings`,
|
|
67
|
+
})
|
|
68
|
+
throw error
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createCloudflareEmbedding<
|
|
74
|
+
TModel extends CloudflareEmbeddingModel,
|
|
75
|
+
>(model: TModel, config: CloudflareConfig): CloudflareEmbeddingAdapter<TModel> {
|
|
76
|
+
return new CloudflareEmbeddingAdapter(config, model)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function cloudflareEmbedding<TModel extends CloudflareEmbeddingModel>(
|
|
80
|
+
model: TModel,
|
|
81
|
+
config?: CloudflareConfigInput,
|
|
82
|
+
): CloudflareEmbeddingAdapter<TModel> {
|
|
83
|
+
return new CloudflareEmbeddingAdapter(resolveConfigFromEnv(config), model)
|
|
84
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { BaseImageAdapter } from '@tanstack/ai/adapters'
|
|
2
|
+
import { toRunErrorPayload } from '@tanstack/ai/adapter-internals'
|
|
3
|
+
import { generateId } from '@tanstack/ai-utils'
|
|
4
|
+
import { resolveMediaPrompt } from '@tanstack/ai'
|
|
5
|
+
import { resolveConfigFromEnv } from '../utils/config'
|
|
6
|
+
import { outputToBase64, runModel } from '../utils/run'
|
|
7
|
+
import type {
|
|
8
|
+
GeneratedImage,
|
|
9
|
+
ImageGenerationOptions,
|
|
10
|
+
ImageGenerationResult,
|
|
11
|
+
} from '@tanstack/ai'
|
|
12
|
+
import type { CloudflareConfig, CloudflareConfigInput } from '../utils/config'
|
|
13
|
+
import type { CloudflareImageModel } from '../utils/models'
|
|
14
|
+
|
|
15
|
+
/** Text-to-image inputs forwarded to the model (`steps`, `guidance`, ...). */
|
|
16
|
+
export interface CloudflareImageProviderOptions {
|
|
17
|
+
negative_prompt?: string
|
|
18
|
+
steps?: number
|
|
19
|
+
num_steps?: number
|
|
20
|
+
guidance?: number
|
|
21
|
+
seed?: number
|
|
22
|
+
[key: string]: unknown
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Cloudflare image adapter. Runs Workers AI text-to-image models and returns
|
|
27
|
+
* base64 images, whether the model answers with `{ image }` JSON (Flux,
|
|
28
|
+
* Leonardo) or raw PNG bytes (Stable Diffusion).
|
|
29
|
+
*/
|
|
30
|
+
export class CloudflareImageAdapter<
|
|
31
|
+
TModel extends CloudflareImageModel,
|
|
32
|
+
> extends BaseImageAdapter<TModel, CloudflareImageProviderOptions> {
|
|
33
|
+
readonly name = 'cloudflare' as const
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
private readonly cfConfig: CloudflareConfig,
|
|
37
|
+
model: TModel,
|
|
38
|
+
) {
|
|
39
|
+
super(model, {})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async generateImages(
|
|
43
|
+
options: ImageGenerationOptions<CloudflareImageProviderOptions>,
|
|
44
|
+
): Promise<ImageGenerationResult> {
|
|
45
|
+
const { model, logger, numberOfImages = 1 } = options
|
|
46
|
+
const prompt = resolveMediaPrompt(options.prompt)
|
|
47
|
+
const [width, height] = options.size?.split('x').map(Number) ?? []
|
|
48
|
+
const inputs = {
|
|
49
|
+
...(width && { width }),
|
|
50
|
+
...(height && { height }),
|
|
51
|
+
...options.modelOptions,
|
|
52
|
+
prompt: prompt.text,
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
logger.request(
|
|
56
|
+
`activity=image provider=${this.name} model=${model} n=${numberOfImages}`,
|
|
57
|
+
{ provider: this.name, model },
|
|
58
|
+
)
|
|
59
|
+
const images = await Promise.all(
|
|
60
|
+
Array.from(
|
|
61
|
+
{ length: numberOfImages },
|
|
62
|
+
async (): Promise<GeneratedImage> => {
|
|
63
|
+
const output = await runModel(this.cfConfig, model, inputs, {
|
|
64
|
+
signal: options.abortSignal,
|
|
65
|
+
})
|
|
66
|
+
const image =
|
|
67
|
+
output && typeof output === 'object' && 'image' in output
|
|
68
|
+
? (output as { image: string }).image
|
|
69
|
+
: await outputToBase64(output)
|
|
70
|
+
return { b64Json: image }
|
|
71
|
+
},
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
return { id: generateId(this.name), model, images }
|
|
75
|
+
} catch (error: unknown) {
|
|
76
|
+
logger.errors(`${this.name}.generateImages fatal`, {
|
|
77
|
+
error: toRunErrorPayload(error, `${this.name}.generateImages failed`),
|
|
78
|
+
source: `${this.name}.generateImages`,
|
|
79
|
+
})
|
|
80
|
+
throw error
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createCloudflareImage<TModel extends CloudflareImageModel>(
|
|
86
|
+
model: TModel,
|
|
87
|
+
config: CloudflareConfig,
|
|
88
|
+
): CloudflareImageAdapter<TModel> {
|
|
89
|
+
return new CloudflareImageAdapter(config, model)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function cloudflareImage<TModel extends CloudflareImageModel>(
|
|
93
|
+
model: TModel,
|
|
94
|
+
config?: CloudflareConfigInput,
|
|
95
|
+
): CloudflareImageAdapter<TModel> {
|
|
96
|
+
return new CloudflareImageAdapter(resolveConfigFromEnv(config), model)
|
|
97
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ChatStreamSummarizeAdapter } from '@tanstack/ai/adapters'
|
|
2
|
+
import { resolveConfigFromEnv } from '../utils/config'
|
|
3
|
+
import { CloudflareTextAdapter } from './text'
|
|
4
|
+
import type { InferTextProviderOptions } from '@tanstack/ai/adapters'
|
|
5
|
+
import type {
|
|
6
|
+
CloudflareConfigInput,
|
|
7
|
+
CloudflareTextConfig,
|
|
8
|
+
CloudflareTextRestConfig,
|
|
9
|
+
} from '../utils/config'
|
|
10
|
+
import type { CloudflareTextModel } from '../utils/models'
|
|
11
|
+
|
|
12
|
+
export type CloudflareSummarizeModel = CloudflareTextModel
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Creates a Cloudflare summarize adapter. Summaries run as a chat request
|
|
16
|
+
* against the given model.
|
|
17
|
+
*/
|
|
18
|
+
export function createCloudflareSummarize<
|
|
19
|
+
TModel extends CloudflareSummarizeModel,
|
|
20
|
+
>(
|
|
21
|
+
model: TModel,
|
|
22
|
+
config: CloudflareTextConfig,
|
|
23
|
+
): ChatStreamSummarizeAdapter<
|
|
24
|
+
TModel,
|
|
25
|
+
InferTextProviderOptions<CloudflareTextAdapter<TModel>>
|
|
26
|
+
> {
|
|
27
|
+
return new ChatStreamSummarizeAdapter(
|
|
28
|
+
new CloudflareTextAdapter(config, model),
|
|
29
|
+
model,
|
|
30
|
+
'cloudflare',
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Creates a Cloudflare summarize adapter, reading `CLOUDFLARE_ACCOUNT_ID` and
|
|
36
|
+
* `CLOUDFLARE_API_TOKEN` from the environment unless a binding is passed.
|
|
37
|
+
*/
|
|
38
|
+
export function cloudflareSummarize<TModel extends CloudflareSummarizeModel>(
|
|
39
|
+
model: TModel,
|
|
40
|
+
config?: CloudflareConfigInput<CloudflareTextRestConfig>,
|
|
41
|
+
): ChatStreamSummarizeAdapter<
|
|
42
|
+
TModel,
|
|
43
|
+
InferTextProviderOptions<CloudflareTextAdapter<TModel>>
|
|
44
|
+
> {
|
|
45
|
+
return createCloudflareSummarize(model, resolveConfigFromEnv(config))
|
|
46
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import OpenAI from 'openai'
|
|
2
|
+
import { OpenAIBaseChatCompletionsTextAdapter } from '@tanstack/openai-base'
|
|
3
|
+
import {
|
|
4
|
+
gatewayHeaders,
|
|
5
|
+
isBindingConfig,
|
|
6
|
+
resolveConfigFromEnv,
|
|
7
|
+
restChatBaseURL,
|
|
8
|
+
} from '../utils/config'
|
|
9
|
+
import { createBindingFetch, createRestFetch } from '../utils/fetch'
|
|
10
|
+
import type {
|
|
11
|
+
ChatCompletionChunk,
|
|
12
|
+
ChatCompletionMessageParam,
|
|
13
|
+
} from 'openai/resources/chat/completions/completions'
|
|
14
|
+
import type {
|
|
15
|
+
CloudflareConfigInput,
|
|
16
|
+
CloudflareTextConfig,
|
|
17
|
+
CloudflareTextRestConfig,
|
|
18
|
+
} from '../utils/config'
|
|
19
|
+
import type { CloudflareTextModel } from '../utils/models'
|
|
20
|
+
import type { ModelMessage } from '@tanstack/ai'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Chat Completions parameters forwarded verbatim to Workers AI. Reasoning
|
|
24
|
+
* models (GLM, Kimi, gpt-oss, QwQ) read `reasoning_effort` and
|
|
25
|
+
* `chat_template_kwargs`; `null` for `reasoning_effort` turns reasoning off.
|
|
26
|
+
*/
|
|
27
|
+
export interface CloudflareTextProviderOptions {
|
|
28
|
+
temperature?: number
|
|
29
|
+
max_tokens?: number
|
|
30
|
+
top_p?: number
|
|
31
|
+
top_k?: number
|
|
32
|
+
seed?: number
|
|
33
|
+
repetition_penalty?: number
|
|
34
|
+
frequency_penalty?: number
|
|
35
|
+
presence_penalty?: number
|
|
36
|
+
reasoning_effort?: 'low' | 'medium' | 'high' | null
|
|
37
|
+
chat_template_kwargs?: {
|
|
38
|
+
enable_thinking?: boolean
|
|
39
|
+
clear_thinking?: boolean
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createClient(config: CloudflareTextConfig): OpenAI {
|
|
44
|
+
if (isBindingConfig(config)) {
|
|
45
|
+
return new OpenAI({
|
|
46
|
+
// The binding authenticates by itself; the SDK only requires a value.
|
|
47
|
+
apiKey: 'cloudflare-binding',
|
|
48
|
+
fetch: createBindingFetch(config.binding, config.gateway),
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
const {
|
|
52
|
+
accountId: _accountId,
|
|
53
|
+
binding: _binding,
|
|
54
|
+
gateway,
|
|
55
|
+
...clientOptions
|
|
56
|
+
} = config
|
|
57
|
+
return new OpenAI({
|
|
58
|
+
...clientOptions,
|
|
59
|
+
baseURL: restChatBaseURL(config),
|
|
60
|
+
defaultHeaders: {
|
|
61
|
+
...gatewayHeaders(gateway),
|
|
62
|
+
...clientOptions.defaultHeaders,
|
|
63
|
+
},
|
|
64
|
+
fetch: createRestFetch(clientOptions.fetch),
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Cloudflare text (chat) adapter.
|
|
70
|
+
*
|
|
71
|
+
* Drives Workers AI's OpenAI-compatible Chat Completions surface with the
|
|
72
|
+
* OpenAI SDK. Inside a Worker pass `{ binding: env.AI }`; anywhere else pass
|
|
73
|
+
* `{ accountId, apiKey }`. Add `gateway` to route through AI Gateway. Any
|
|
74
|
+
* catalog model works, including third-party `provider/model` ids billed
|
|
75
|
+
* through AI Gateway.
|
|
76
|
+
*/
|
|
77
|
+
export class CloudflareTextAdapter<
|
|
78
|
+
TModel extends CloudflareTextModel,
|
|
79
|
+
TProviderOptions extends Record<string, any> = CloudflareTextProviderOptions,
|
|
80
|
+
> extends OpenAIBaseChatCompletionsTextAdapter<TModel, TProviderOptions> {
|
|
81
|
+
override readonly kind = 'text' as const
|
|
82
|
+
override readonly name = 'cloudflare' as const
|
|
83
|
+
|
|
84
|
+
constructor(config: CloudflareTextConfig, model: TModel) {
|
|
85
|
+
super(model, 'cloudflare', createClient(config))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Workers AI validates `messages[].content` as a string, so a tool-call-only
|
|
90
|
+
* assistant turn (which OpenAI accepts as `content: null`) is sent as `''`.
|
|
91
|
+
*/
|
|
92
|
+
protected override convertMessage(
|
|
93
|
+
message: ModelMessage,
|
|
94
|
+
): ChatCompletionMessageParam {
|
|
95
|
+
const converted = super.convertMessage(message)
|
|
96
|
+
if (converted.role === 'assistant' && converted.content == null) {
|
|
97
|
+
return { ...converted, content: '' }
|
|
98
|
+
}
|
|
99
|
+
return converted
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Workers AI accepts `response_format` next to `tools` but its models answer
|
|
104
|
+
* the tool follow-up turn in prose, so structured output with tools runs as
|
|
105
|
+
* a separate finalization request instead.
|
|
106
|
+
*/
|
|
107
|
+
override supportsCombinedToolsAndSchema(): boolean {
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Workers AI reasoning models stream thinking as `reasoning_content` (some as `reasoning`). */
|
|
112
|
+
protected override extractReasoning(
|
|
113
|
+
chunk: ChatCompletionChunk,
|
|
114
|
+
): { text: string } | undefined {
|
|
115
|
+
const delta = chunk.choices[0]?.delta as
|
|
116
|
+
| { reasoning?: unknown; reasoning_content?: unknown }
|
|
117
|
+
| undefined
|
|
118
|
+
const raw = delta?.reasoning_content ?? delta?.reasoning
|
|
119
|
+
return typeof raw === 'string' && raw.length > 0 ? { text: raw } : undefined
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Creates a Cloudflare text adapter with explicit configuration.
|
|
125
|
+
*
|
|
126
|
+
* @example
|
|
127
|
+
* ```typescript
|
|
128
|
+
* // Inside a Worker
|
|
129
|
+
* const adapter = createCloudflareText('@cf/zai-org/glm-5.3-flash', { binding: env.AI })
|
|
130
|
+
* // Anywhere, over REST
|
|
131
|
+
* const adapter = createCloudflareText('@cf/zai-org/glm-5.3-flash', { accountId, apiKey })
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
export function createCloudflareText<TModel extends CloudflareTextModel>(
|
|
135
|
+
model: TModel,
|
|
136
|
+
config: CloudflareTextConfig,
|
|
137
|
+
): CloudflareTextAdapter<TModel> {
|
|
138
|
+
return new CloudflareTextAdapter(config, model)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Creates a Cloudflare text adapter, reading `CLOUDFLARE_ACCOUNT_ID` and
|
|
143
|
+
* `CLOUDFLARE_API_TOKEN` from the environment unless a binding is passed.
|
|
144
|
+
*/
|
|
145
|
+
export function cloudflareText<TModel extends CloudflareTextModel>(
|
|
146
|
+
model: TModel,
|
|
147
|
+
config?: CloudflareConfigInput<CloudflareTextRestConfig>,
|
|
148
|
+
): CloudflareTextAdapter<TModel> {
|
|
149
|
+
return new CloudflareTextAdapter(resolveConfigFromEnv(config), model)
|
|
150
|
+
}
|