From ff6e712e2aea1645fc7477f89064f677860760e9 Mon Sep 17 00:00:00 2001 From: boomzero Date: Mon, 5 Oct 2026 14:36:55 +0800 Subject: [PATCH 1/2] Call Workers AI over HTTP instead of the ai binding Cloudflare fails to attach the ai binding to new versions of this worker (code 10021), which blocks every deploy. Badge moderation now uses the Workers AI REST API with an AI_API_TOKEN secret, keeping the binding as the first choice if it is ever restored. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017dqWu3YgeVRGwBt5pXDq4A --- Source/Process.ts | 32 +++++++++++++++++++-- Source/index.ts | 3 +- test/process.test.js | 66 +++++++++++++++++++++++++++++++++++++++++++- wrangler.toml | 3 -- 4 files changed, 97 insertions(+), 7 deletions(-) diff --git a/Source/Process.ts b/Source/Process.ts index efbe824..e930a3e 100644 --- a/Source/Process.ts +++ b/Source/Process.ts @@ -35,7 +35,8 @@ interface Environment { CaptchaSecretKey: string; DB: D1Database; logdb: AnalyticsEngineDataset; - AI: any; + AI?: any; + AI_API_TOKEN?: string; NOTIFICATIONS: DurableObjectNamespace; NOTIFICATION_PUSH_TOKEN: string; } @@ -172,6 +173,31 @@ export function ModerationReplyTruncated(Reply: any): boolean { return Reply?.choices?.[0]?.finish_reason === "length"; } +// Workers AI over the REST API, shaped like the binding's `run` so callers do not +// care which one they got. Cloudflare has been failing to attach the `ai` binding +// to new versions of this worker (error 10021), so it is no longer in wrangler.toml; +// the REST API serves the same models and returns the binding's output under +// `result`. https://github.com/cloudflare/workers-sdk/issues/15973 +export function WorkersAIOverHTTP(AccountID: string, Token: string) { + return { + run: async (Model: string, Inputs: object): Promise => { + const Response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${AccountID}/ai/run/${Model}`, { + method: "POST", + headers: { + "Authorization": `Bearer ${Token}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(Inputs) + }); + const Body: any = await Response.json().catch(() => null); + if (!Response.ok || !Body?.success) { + throw new Error("Workers AI returned HTTP " + Response.status + ": " + JSON.stringify(Body?.errors ?? null)); + } + return Body.result; + } + }; +} + // The KV key holding the list of problems that have a std answer. It is a // cache of `SELECT problem_id FROM std_answer`, kept so that GetStdList - a // hot read - costs no database rows. @@ -1855,7 +1881,9 @@ export class Process { constructor(RequestData: Request, Environment: Environment) { this.XMOJDatabase = new Database(Environment.DB); - this.AI = Environment.AI; + // Prefer the binding if it is ever restored; otherwise go over HTTP. With + // neither, `run` throws inside the moderation try block and the edit is refused. + this.AI = Environment.AI ?? (Environment.AI_API_TOKEN ? WorkersAIOverHTTP(Environment.ACCOUNT_ID, Environment.AI_API_TOKEN) : null); this.kv = Environment.kv; this.logs = Environment.logdb; this.notifications = Environment.NOTIFICATIONS; diff --git a/Source/index.ts b/Source/index.ts index 5875d7f..4b4124f 100644 --- a/Source/index.ts +++ b/Source/index.ts @@ -29,7 +29,8 @@ interface Environment { CaptchaSecretKey: string; DB: D1Database; logdb: AnalyticsEngineDataset; - AI: Ai; + AI?: Ai; + AI_API_TOKEN?: string; NOTIFICATIONS: DurableObjectNamespace; NOTIFICATION_PUSH_TOKEN: string; } diff --git a/test/process.test.js b/test/process.test.js index 19051ed..0068ec0 100644 --- a/test/process.test.js +++ b/test/process.test.js @@ -1,6 +1,6 @@ const test = require('node:test'); const assert = require('node:assert'); -const { Process, RebuildStdList } = require('../Source/Process.ts'); +const { Process, RebuildStdList, WorkersAIOverHTTP } = require('../Source/Process.ts'); const { Result } = require('../Source/Result.ts'); function createProcess(mocks = {}) { @@ -1211,3 +1211,67 @@ test('UploadStd rebuilds when the cache key is unset entirely', async () => { assert.strictEqual(kv.store.std_list, '1234'); }); + +// --------------------------------------------------------------------------- +// Workers AI over HTTP +// --------------------------------------------------------------------------- + +function processWithEnv(extra) { + const env = Object.assign({ ACCOUNT_ID: 'test-account-id', DB: { withSession() { return this; } } }, extra); + return new Process(new Request('https://example.com'), env); +} + +test('WorkersAIOverHTTP posts the inputs to the account run endpoint and returns result', async (t) => { + let seenUrl = null, seenInit = null; + t.mock.method(global, 'fetch', async (url, init) => { + seenUrl = url; + seenInit = init; + return Response.json({ success: true, result: verdict(true, 0), errors: [] }); + }); + const reply = await WorkersAIOverHTTP('acct', 'tok').run('@cf/zai-org/glm-4.7-flash', { temperature: 0 }); + assert.strictEqual(seenUrl, 'https://api.cloudflare.com/client/v4/accounts/acct/ai/run/@cf/zai-org/glm-4.7-flash'); + assert.strictEqual(seenInit.method, 'POST'); + assert.strictEqual(seenInit.headers['Authorization'], 'Bearer tok'); + assert.deepStrictEqual(JSON.parse(seenInit.body), { temperature: 0 }); + assert.deepStrictEqual(reply, verdict(true, 0)); +}); + +test('WorkersAIOverHTTP throws on an HTTP error', async (t) => { + t.mock.method(global, 'fetch', async () => Response.json({ success: false, errors: [{ code: 10000 }] }, { status: 401 })); + await assert.rejects(WorkersAIOverHTTP('acct', 'tok').run('m', {}), /HTTP 401/); +}); + +test('WorkersAIOverHTTP throws when the API reports failure with 200', async (t) => { + t.mock.method(global, 'fetch', async () => Response.json({ success: false, errors: [] })); + await assert.rejects(WorkersAIOverHTTP('acct', 'tok').run('m', {}), /HTTP 200/); +}); + +test('WorkersAIOverHTTP throws on a non-JSON body', async (t) => { + t.mock.method(global, 'fetch', async () => new Response('', { status: 502 })); + await assert.rejects(WorkersAIOverHTTP('acct', 'tok').run('m', {}), /HTTP 502/); +}); + +test('Process prefers the AI binding when it exists', () => { + const binding = { run: async () => ({}) }; + const proc = processWithEnv({ AI: binding, AI_API_TOKEN: 'tok' }); + assert.strictEqual(proc.AI, binding); +}); + +test('Process falls back to HTTP when only the token is set', async (t) => { + let seenUrl = null; + t.mock.method(global, 'fetch', async (url) => { + seenUrl = url; + return Response.json({ success: true, result: {} }); + }); + const proc = processWithEnv({ AI_API_TOKEN: 'tok' }); + await proc.AI.run('m', {}); + assert.strictEqual(seenUrl, 'https://api.cloudflare.com/client/v4/accounts/test-account-id/ai/run/m'); +}); + +test('EditBadge refuses the edit when there is no AI at all', async () => { + const proc = createBadgeProcess(); + proc.AI = processWithEnv({}).AI; + const result = await proc.ProcessFunctions['EditBadge'](editArgs('hello')); + assert.strictEqual(result.Success, false); + assert.strictEqual(result.Message, '内容审核服务暂时不可用,请稍后重试'); +}); diff --git a/wrangler.toml b/wrangler.toml index 02a50c6..a656c22 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -17,9 +17,6 @@ crons = ["0 0 * * *"] [[analytics_engine_datasets]] binding = "logdb" -[ai] -binding = "AI" - [observability] enabled = true From 89b520a9632f49b67a7be6b807517f81bcb4cfb7 Mon Sep 17 00:00:00 2001 From: boomzero Date: Mon, 5 Oct 2026 14:38:17 +0800 Subject: [PATCH 2/2] Fall back to API_TOKEN for Workers AI so no new secret is needed Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017dqWu3YgeVRGwBt5pXDq4A --- Source/Process.ts | 9 ++++++--- test/process.test.js | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/Source/Process.ts b/Source/Process.ts index e930a3e..6747f1a 100644 --- a/Source/Process.ts +++ b/Source/Process.ts @@ -1881,9 +1881,12 @@ export class Process { constructor(RequestData: Request, Environment: Environment) { this.XMOJDatabase = new Database(Environment.DB); - // Prefer the binding if it is ever restored; otherwise go over HTTP. With - // neither, `run` throws inside the moderation try block and the edit is refused. - this.AI = Environment.AI ?? (Environment.AI_API_TOKEN ? WorkersAIOverHTTP(Environment.ACCOUNT_ID, Environment.AI_API_TOKEN) : null); + // Prefer the binding if it is ever restored; otherwise go over HTTP, with a + // dedicated AI_API_TOKEN if one is set and the analytics API_TOKEN (granted + // Workers AI Read) if not. With no AI at all, `run` throws inside the + // moderation try block and the edit is refused. + const AIToken = Environment.AI_API_TOKEN || Environment.API_TOKEN; + this.AI = Environment.AI ?? (AIToken ? WorkersAIOverHTTP(Environment.ACCOUNT_ID, AIToken) : null); this.kv = Environment.kv; this.logs = Environment.logdb; this.notifications = Environment.NOTIFICATIONS; diff --git a/test/process.test.js b/test/process.test.js index 0068ec0..3168f44 100644 --- a/test/process.test.js +++ b/test/process.test.js @@ -1268,6 +1268,26 @@ test('Process falls back to HTTP when only the token is set', async (t) => { assert.strictEqual(seenUrl, 'https://api.cloudflare.com/client/v4/accounts/test-account-id/ai/run/m'); }); +test('Process prefers AI_API_TOKEN over API_TOKEN', async (t) => { + let seenAuth = null; + t.mock.method(global, 'fetch', async (url, init) => { + seenAuth = init.headers['Authorization']; + return Response.json({ success: true, result: {} }); + }); + await processWithEnv({ API_TOKEN: 'analytics', AI_API_TOKEN: 'ai' }).AI.run('m', {}); + assert.strictEqual(seenAuth, 'Bearer ai'); +}); + +test('Process falls back to API_TOKEN for Workers AI', async (t) => { + let seenAuth = null; + t.mock.method(global, 'fetch', async (url, init) => { + seenAuth = init.headers['Authorization']; + return Response.json({ success: true, result: {} }); + }); + await processWithEnv({ API_TOKEN: 'analytics' }).AI.run('m', {}); + assert.strictEqual(seenAuth, 'Bearer analytics'); +}); + test('EditBadge refuses the edit when there is no AI at all', async () => { const proc = createBadgeProcess(); proc.AI = processWithEnv({}).AI;