mirror of
https://github.com/kennethnym/aris.git
synced 2026-05-08 01:21:18 +01:00
feat: migrate LLM provider to Cloudflare Workers AI
Replace OpenRouter SDK with direct Cloudflare Workers AI REST API calls. Same model (glm-4.7-flash), validated with arktype. Env vars: CF_ACCOUNT_ID, WORKERS_AI_API_KEY. Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
@@ -10,10 +10,11 @@ CREDENTIAL_ENCRYPTION_KEY=
|
||||
# Base URL of the backend
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
|
||||
# OpenRouter (LLM feed enhancement)
|
||||
OPENROUTER_API_KEY=
|
||||
# Optional: override the default model (default: openai/gpt-4.1-mini)
|
||||
# OPENROUTER_MODEL=openai/gpt-4.1-mini
|
||||
# Cloudflare Workers AI (LLM feed enhancement)
|
||||
CF_ACCOUNT_ID=
|
||||
WORKERS_AI_API_KEY=
|
||||
# Optional: override the default model (default: @cf/zai-org/glm-4.7-flash)
|
||||
# WORKERS_AI_MODEL=@cf/zai-org/glm-4.7-flash
|
||||
|
||||
# Apple WeatherKit credentials
|
||||
WEATHERKIT_PRIVATE_KEY=
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
"@aelis/source-location": "workspace:*",
|
||||
"@aelis/source-tfl": "workspace:*",
|
||||
"@aelis/source-weatherkit": "workspace:*",
|
||||
"@openrouter/sdk": "^0.9.11",
|
||||
"arktype": "^2.1.29",
|
||||
"better-auth": "^1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { OpenRouter } from "@openrouter/sdk"
|
||||
import { type } from "arktype"
|
||||
|
||||
import type { EnhancementResult } from "./schema.ts"
|
||||
|
||||
import { enhancementResultJsonSchema, parseEnhancementResult } from "./schema.ts"
|
||||
|
||||
const DEFAULT_MODEL = "z-ai/glm-4.7-flash"
|
||||
const DEFAULT_MODEL = "@cf/zai-org/glm-4.7-flash"
|
||||
const DEFAULT_TIMEOUT_MS = 30_000
|
||||
|
||||
export interface LlmClientConfig {
|
||||
accountId: string
|
||||
apiKey: string
|
||||
model?: string
|
||||
timeoutMs?: number
|
||||
@@ -22,52 +23,94 @@ export interface LlmClient {
|
||||
enhance(request: LlmClientRequest): Promise<EnhancementResult | null>
|
||||
}
|
||||
|
||||
const CloudflareApiResponse = type({
|
||||
result: {
|
||||
choices: type({
|
||||
message: {
|
||||
content: "string",
|
||||
"role?": "string",
|
||||
},
|
||||
}).array(),
|
||||
},
|
||||
success: "boolean",
|
||||
"errors?": type({ message: "string" }).array(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Creates a reusable LLM client backed by OpenRouter.
|
||||
* The OpenRouter SDK instance is created once and reused across calls.
|
||||
* Creates a reusable LLM client backed by Cloudflare Workers AI.
|
||||
* Uses the REST API with structured JSON output.
|
||||
*/
|
||||
export function createLlmClient(config: LlmClientConfig): LlmClient {
|
||||
const client = new OpenRouter({
|
||||
apiKey: config.apiKey,
|
||||
timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
})
|
||||
const model = config.model ?? DEFAULT_MODEL
|
||||
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
||||
const baseUrl = `https://api.cloudflare.com/client/v4/accounts/${config.accountId}/ai/run/${model}`
|
||||
|
||||
return {
|
||||
async enhance(request) {
|
||||
const response = await client.chat.send({
|
||||
chatGenerationParams: {
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system" as const, content: request.systemPrompt },
|
||||
{ role: "user" as const, content: request.userMessage },
|
||||
],
|
||||
responseFormat: {
|
||||
type: "json_schema" as const,
|
||||
jsonSchema: {
|
||||
name: "enhancement_result",
|
||||
strict: false,
|
||||
schema: enhancementResultJsonSchema,
|
||||
},
|
||||
try {
|
||||
const res = await fetch(baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
reasoning: { effort: "none" },
|
||||
stream: false,
|
||||
},
|
||||
})
|
||||
body: JSON.stringify({
|
||||
messages: [
|
||||
{ role: "system", content: request.systemPrompt },
|
||||
{ role: "user", content: request.userMessage },
|
||||
],
|
||||
response_format: {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
name: "enhancement_result",
|
||||
strict: false,
|
||||
schema: enhancementResultJsonSchema,
|
||||
},
|
||||
},
|
||||
stream: false,
|
||||
}),
|
||||
// @ts-expect-error — bun-types AbortSignal conflicts with ESNext lib in tsc; works at runtime and in VSCode
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
})
|
||||
|
||||
const message = response.choices?.[0]?.message
|
||||
const content = message?.content ?? message?.reasoning
|
||||
if (typeof content !== "string") {
|
||||
console.warn("[enhancement] LLM returned no content in response")
|
||||
if (!res.ok) {
|
||||
const body = await res.text()
|
||||
console.warn(`[enhancement] Cloudflare API error ${res.status}: ${body}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const json: unknown = await res.json()
|
||||
const parsed = CloudflareApiResponse(json)
|
||||
if (parsed instanceof type.errors) {
|
||||
console.warn("[enhancement] Unexpected API response shape:", parsed.summary)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!parsed.success) {
|
||||
console.warn("[enhancement] Cloudflare API errors:", parsed.errors)
|
||||
return null
|
||||
}
|
||||
|
||||
const content = parsed.result.choices[0]?.message.content
|
||||
if (content === undefined) {
|
||||
console.warn("[enhancement] LLM returned no choices in response")
|
||||
return null
|
||||
}
|
||||
|
||||
const result = parseEnhancementResult(content)
|
||||
if (!result) {
|
||||
console.warn("[enhancement] Failed to parse LLM response:", content)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "TimeoutError") {
|
||||
console.warn("[enhancement] LLM request timed out")
|
||||
} else {
|
||||
console.warn("[enhancement] LLM request failed:", error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const result = parseEnhancementResult(content)
|
||||
if (!result) {
|
||||
console.warn("[enhancement] Failed to parse LLM response:", content)
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,7 @@ export type SyntheticItem = typeof SyntheticItem.infer
|
||||
export type EnhancementResult = typeof EnhancementResult.infer
|
||||
|
||||
/**
|
||||
* JSON Schema passed to OpenRouter's structured output.
|
||||
* OpenRouter doesn't support arktype, so this is maintained separately.
|
||||
* JSON Schema passed to Cloudflare Workers AI for structured output.
|
||||
*
|
||||
* ⚠️ Must stay in sync with EnhancementResult above.
|
||||
* If you add/remove fields, update both schemas.
|
||||
|
||||
@@ -23,17 +23,22 @@ function main() {
|
||||
const { db, close: closeDb } = createDatabase(process.env.DATABASE_URL!)
|
||||
const auth = createAuth(db)
|
||||
|
||||
const openrouterApiKey = process.env.OPENROUTER_API_KEY
|
||||
const feedEnhancer = openrouterApiKey
|
||||
? createFeedEnhancer({
|
||||
client: createLlmClient({
|
||||
apiKey: openrouterApiKey,
|
||||
model: process.env.OPENROUTER_MODEL || undefined,
|
||||
}),
|
||||
})
|
||||
: null
|
||||
const cfAccountId = process.env.CF_ACCOUNT_ID
|
||||
const workersAiApiKey = process.env.WORKERS_AI_API_KEY
|
||||
const feedEnhancer =
|
||||
cfAccountId && workersAiApiKey
|
||||
? createFeedEnhancer({
|
||||
client: createLlmClient({
|
||||
accountId: cfAccountId,
|
||||
apiKey: workersAiApiKey,
|
||||
model: process.env.WORKERS_AI_MODEL || undefined,
|
||||
}),
|
||||
})
|
||||
: null
|
||||
if (!feedEnhancer) {
|
||||
console.warn("[enhancement] OPENROUTER_API_KEY not set — feed enhancement disabled")
|
||||
console.warn(
|
||||
"[enhancement] CF_ACCOUNT_ID/WORKERS_AI_API_KEY not set — feed enhancement disabled",
|
||||
)
|
||||
}
|
||||
|
||||
const credentialEncryptionKey = process.env.CREDENTIAL_ENCRYPTION_KEY
|
||||
|
||||
Reference in New Issue
Block a user