fix: logo 右半部分从 CODING 改为 CODE
去掉难以正确渲染的 N 和 G 字母,右半部分简化为 CODE(4 字母), 与左半部分 AIR 组合为 AIR CODE。
This commit is contained in:
29
packages/console/function/package.json
Normal file
29
packages/console/function/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.17.4",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/node": "catalog:",
|
||||
"openai": "5.11.0",
|
||||
"typescript": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
"@ai-sdk/openai-compatible": "2.0.37",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
"@opencode-ai/console-resource": "workspace:*",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"ai": "catalog:",
|
||||
"zod": "catalog:"
|
||||
}
|
||||
}
|
||||
226
packages/console/function/src/auth.ts
Normal file
226
packages/console/function/src/auth.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import type { KVNamespace } from "@cloudflare/workers-types"
|
||||
import { z } from "zod"
|
||||
import { issuer } from "@openauthjs/openauth"
|
||||
import type { Theme } from "@openauthjs/openauth/ui/theme"
|
||||
import { createSubjects } from "@openauthjs/openauth/subject"
|
||||
import { THEME_OPENAUTH } from "@openauthjs/openauth/ui/theme"
|
||||
import { GithubProvider } from "@openauthjs/openauth/provider/github"
|
||||
import { GoogleOidcProvider } from "@openauthjs/openauth/provider/google"
|
||||
import { CloudflareStorage } from "@openauthjs/openauth/storage/cloudflare"
|
||||
import { Account } from "@opencode-ai/console-core/account.js"
|
||||
import { Workspace } from "@opencode-ai/console-core/workspace.js"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { User } from "@opencode-ai/console-core/user.js"
|
||||
import { and, Database, eq, isNull, or } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
||||
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||
import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js"
|
||||
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
||||
|
||||
type Env = {
|
||||
AuthStorage: KVNamespace
|
||||
}
|
||||
|
||||
export const subjects = createSubjects({
|
||||
account: z.object({
|
||||
accountID: z.string(),
|
||||
email: z.string(),
|
||||
newAccount: z.boolean().optional(),
|
||||
}),
|
||||
user: z.object({
|
||||
userID: z.string(),
|
||||
workspaceID: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
const MY_THEME: Theme = {
|
||||
...THEME_OPENAUTH,
|
||||
logo: "https://opencode.ai/favicon-v3.svg",
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
||||
const result = await issuer({
|
||||
theme: MY_THEME,
|
||||
providers: {
|
||||
github: GithubProvider({
|
||||
clientID: Resource.GITHUB_CLIENT_ID_CONSOLE.value,
|
||||
clientSecret: Resource.GITHUB_CLIENT_SECRET_CONSOLE.value,
|
||||
scopes: ["read:user", "user:email"],
|
||||
}),
|
||||
google: GoogleOidcProvider({
|
||||
clientID: Resource.GOOGLE_CLIENT_ID.value,
|
||||
scopes: ["openid", "email"],
|
||||
}),
|
||||
// email: CodeProvider({
|
||||
// async request(req, state, form, error) {
|
||||
// console.log(state)
|
||||
// const params = new URLSearchParams()
|
||||
// if (error) {
|
||||
// params.set("error", error.type)
|
||||
// }
|
||||
// if (state.type === "start") {
|
||||
// return Response.redirect(process.env.AUTH_FRONTEND_URL + "/auth/email?" + params.toString(), 302)
|
||||
// }
|
||||
//
|
||||
// if (state.type === "code") {
|
||||
// return Response.redirect(process.env.AUTH_FRONTEND_URL + "/auth/code?" + params.toString(), 302)
|
||||
// }
|
||||
//
|
||||
// return new Response("ok")
|
||||
// },
|
||||
// async sendCode(claims, code) {
|
||||
// const email = z.string().email().parse(claims.email)
|
||||
// const cmd = new SendEmailCommand({
|
||||
// Destination: {
|
||||
// ToAddresses: [email],
|
||||
// },
|
||||
// FromEmailAddress: `SST <auth@${Resource.Email.sender}>`,
|
||||
// Content: {
|
||||
// Simple: {
|
||||
// Body: {
|
||||
// Html: {
|
||||
// Data: `Your pin code is <strong>${code}</strong>`,
|
||||
// },
|
||||
// Text: {
|
||||
// Data: `Your pin code is ${code}`,
|
||||
// },
|
||||
// },
|
||||
// Subject: {
|
||||
// Data: "SST Console Pin Code: " + code,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// })
|
||||
// await ses.send(cmd)
|
||||
// },
|
||||
// }),
|
||||
},
|
||||
storage: CloudflareStorage({
|
||||
// @ts-ignore
|
||||
namespace: env.AuthStorage,
|
||||
}),
|
||||
subjects,
|
||||
async success(ctx, response) {
|
||||
console.log(response)
|
||||
|
||||
let subject: string | undefined
|
||||
let email: string | undefined
|
||||
|
||||
if (response.provider === "github") {
|
||||
const emails = (await fetch("https://api.github.com/user/emails", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${response.tokenset.access}`,
|
||||
"User-Agent": "opencode",
|
||||
Accept: "application/vnd.github+json",
|
||||
},
|
||||
}).then((x) => x.json())) as any
|
||||
const user = (await fetch("https://api.github.com/user", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${response.tokenset.access}`,
|
||||
"User-Agent": "opencode",
|
||||
Accept: "application/vnd.github+json",
|
||||
},
|
||||
}).then((x) => x.json())) as any
|
||||
subject = user.id.toString()
|
||||
|
||||
const primaryEmail = emails.find((x: any) => x.primary)
|
||||
if (!primaryEmail) throw new Error("No primary email found for GitHub user")
|
||||
if (!primaryEmail.verified) throw new Error("Primary email for GitHub user not verified")
|
||||
email = primaryEmail.email
|
||||
} else if (response.provider === "google") {
|
||||
if (!response.id.email_verified) throw new Error("Google email not verified")
|
||||
subject = response.id.sub as string
|
||||
email = response.id.email as string
|
||||
} else throw new Error("Unsupported provider")
|
||||
|
||||
if (!email) throw new Error("No email found")
|
||||
if (!subject) throw new Error("No subject found")
|
||||
|
||||
if (Resource.App.stage !== "production" && !email.endsWith("@anoma.ly")) {
|
||||
throw new Error("Invalid email")
|
||||
}
|
||||
|
||||
// Get account
|
||||
let newAccount = false
|
||||
const accountID = await (async () => {
|
||||
const matches = await Database.use(async (tx) =>
|
||||
tx
|
||||
.select({
|
||||
provider: AuthTable.provider,
|
||||
accountID: AuthTable.accountID,
|
||||
})
|
||||
.from(AuthTable)
|
||||
.where(
|
||||
or(
|
||||
and(eq(AuthTable.provider, response.provider), eq(AuthTable.subject, subject)),
|
||||
and(eq(AuthTable.provider, "email"), eq(AuthTable.subject, email)),
|
||||
),
|
||||
),
|
||||
)
|
||||
const idByProvider = matches.find((x) => x.provider === response.provider)?.accountID
|
||||
const idByEmail = matches.find((x) => x.provider === "email")?.accountID
|
||||
if (idByProvider && idByEmail) return idByProvider
|
||||
|
||||
// create account if not found
|
||||
let accountID = idByProvider ?? idByEmail
|
||||
if (!accountID) {
|
||||
console.log("creating account for", email)
|
||||
accountID = await Account.create({})
|
||||
newAccount = true
|
||||
}
|
||||
|
||||
await Database.use(async (tx) =>
|
||||
tx
|
||||
.insert(AuthTable)
|
||||
.values([
|
||||
{
|
||||
id: Identifier.create("auth"),
|
||||
accountID,
|
||||
provider: response.provider,
|
||||
subject,
|
||||
},
|
||||
{
|
||||
id: Identifier.create("auth"),
|
||||
accountID,
|
||||
provider: "email",
|
||||
subject: email,
|
||||
},
|
||||
])
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
timeDeleted: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
return accountID
|
||||
})()
|
||||
|
||||
// Get workspace
|
||||
await Actor.provide("account", { accountID, email }, async () => {
|
||||
await User.joinInvitedWorkspaces()
|
||||
const workspaces = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ id: WorkspaceTable.id })
|
||||
.from(WorkspaceTable)
|
||||
.innerJoin(UserTable, eq(UserTable.workspaceID, WorkspaceTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(UserTable.accountID, accountID),
|
||||
isNull(UserTable.timeDeleted),
|
||||
isNull(WorkspaceTable.timeDeleted),
|
||||
),
|
||||
),
|
||||
)
|
||||
if (workspaces.length === 0) {
|
||||
await Workspace.create({ name: "Default" })
|
||||
}
|
||||
})
|
||||
return ctx.subject("account", accountID, { accountID, email, newAccount })
|
||||
},
|
||||
}).fetch(request, env, ctx)
|
||||
return result
|
||||
},
|
||||
}
|
||||
209
packages/console/function/src/log-processor.ts
Normal file
209
packages/console/function/src/log-processor.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import type { TraceItem } from "@cloudflare/workers-types"
|
||||
|
||||
export default {
|
||||
async tail(events: TraceItem[]) {
|
||||
for (const event of events) {
|
||||
if (!event.event) continue
|
||||
if (!("request" in event.event)) continue
|
||||
if (event.event.request.method !== "POST") continue
|
||||
|
||||
const url = new URL(event.event.request.url)
|
||||
if (
|
||||
url.pathname !== "/zen/v1/chat/completions" &&
|
||||
url.pathname !== "/zen/v1/messages" &&
|
||||
url.pathname !== "/zen/v1/responses" &&
|
||||
!url.pathname.startsWith("/zen/v1/models/") &&
|
||||
url.pathname !== "/zen/go/v1/chat/completions" &&
|
||||
url.pathname !== "/zen/go/v1/messages" &&
|
||||
url.pathname !== "/zen/go/v1/responses" &&
|
||||
!url.pathname.startsWith("/zen/go/v1/models/")
|
||||
)
|
||||
continue
|
||||
|
||||
const ip = event.event.request.headers["x-real-ip"]
|
||||
let data: Record<string, unknown> = {
|
||||
"cf.continent": event.event.request.cf?.continent,
|
||||
"cf.country": event.event.request.cf?.country,
|
||||
"cf.city": event.event.request.cf?.city,
|
||||
"cf.region": event.event.request.cf?.region,
|
||||
"cf.latitude": event.event.request.cf?.latitude,
|
||||
"cf.longitude": event.event.request.cf?.longitude,
|
||||
"cf.timezone": event.event.request.cf?.timezone,
|
||||
duration: event.wallTime,
|
||||
request_length: parseInt(event.event.request.headers["content-length"] ?? "0"),
|
||||
status: event.event.response?.status ?? 0,
|
||||
ip,
|
||||
"ip.prefix": ipPrefix(ip),
|
||||
}
|
||||
const time = new Date(event.eventTimestamp ?? Date.now()).toISOString()
|
||||
const events = [
|
||||
...event.logs.flatMap((log) =>
|
||||
log.message.flatMap((message: string) => {
|
||||
if (!message.startsWith("_metric:")) return []
|
||||
const json = JSON.parse(message.slice(8)) as Record<string, unknown>
|
||||
data = { ...data, ...json }
|
||||
if ("llm.error.code" in json) {
|
||||
return [{ time, data: { ...data, event_type: "llm.error" } }]
|
||||
}
|
||||
return []
|
||||
}),
|
||||
),
|
||||
{ time, data: { ...data, event_type: "completions" } },
|
||||
]
|
||||
console.log(JSON.stringify(data, null, 2))
|
||||
|
||||
const lakeIngest = getLakeIngest()
|
||||
const [honeycomb, lake] = await Promise.all([
|
||||
fetch("https://api.honeycomb.io/1/batch/zen", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Honeycomb-Team": Resource.HONEYCOMB_API_KEY.value,
|
||||
},
|
||||
body: JSON.stringify(events),
|
||||
}),
|
||||
...(lakeIngest
|
||||
? [
|
||||
fetch(lakeIngest.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${lakeIngest.secret}`,
|
||||
},
|
||||
body: JSON.stringify({ events: events.map((event) => toLakeEvent(event.time, event.data)) }),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
])
|
||||
console.log(honeycomb.status)
|
||||
console.log(await honeycomb.text())
|
||||
if (lake) {
|
||||
console.log(lake.status)
|
||||
console.log(await lake.text())
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
function getLakeIngest(): { url: string; secret: string } | undefined {
|
||||
try {
|
||||
return Resource.LakeIngest
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function toLakeEvent(time: string, data: Record<string, unknown>) {
|
||||
return {
|
||||
_datalake_key: "inference.event",
|
||||
event_timestamp: time,
|
||||
event_date: time.slice(0, 10),
|
||||
event_type: string(data, "event_type"),
|
||||
dataset: "zen",
|
||||
cf_continent: string(data, "cf.continent"),
|
||||
cf_country: string(data, "cf.country"),
|
||||
cf_city: string(data, "cf.city"),
|
||||
cf_region: string(data, "cf.region"),
|
||||
cf_latitude: number(data, "cf.latitude"),
|
||||
cf_longitude: number(data, "cf.longitude"),
|
||||
cf_timezone: string(data, "cf.timezone"),
|
||||
duration: number(data, "duration"),
|
||||
request_length: integer(data, "request_length"),
|
||||
status: integer(data, "status"),
|
||||
ip: string(data, "ip"),
|
||||
ip_prefix: string(data, "ip.prefix"),
|
||||
is_stream: boolean(data, "is_stream"),
|
||||
session: string(data, "session"),
|
||||
request: string(data, "request"),
|
||||
client: string(data, "client"),
|
||||
user_agent: string(data, "user_agent"),
|
||||
model_variant: string(data, "model.variant"),
|
||||
source: string(data, "source"),
|
||||
provider: string(data, "provider"),
|
||||
provider_model: string(data, "provider.model"),
|
||||
model: string(data, "model"),
|
||||
llm_error_code: integer(data, "llm.error.code"),
|
||||
llm_error_message: string(data, "llm.error.message"),
|
||||
error_response: string(data, "error.response"),
|
||||
error_type: string(data, "error.type"),
|
||||
error_message: string(data, "error.message"),
|
||||
error_cause: string(data, "error.cause"),
|
||||
error_cause2: string(data, "error.cause2"),
|
||||
api_key: string(data, "api_key"),
|
||||
workspace: string(data, "workspace"),
|
||||
is_subscription: boolean(data, "isSubscription"), // removed
|
||||
subscription: string(data, "subscription"),
|
||||
response_length: integer(data, "response_length"),
|
||||
time_to_first_byte: integer(data, "time_to_first_byte"),
|
||||
timestamp_first_byte: integer(data, "timestamp.first_byte"),
|
||||
timestamp_last_byte: integer(data, "timestamp.last_byte"),
|
||||
tokens_input: integer(data, "tokens.input"),
|
||||
tokens_output: integer(data, "tokens.output"),
|
||||
tokens_reasoning: integer(data, "tokens.reasoning"),
|
||||
tokens_cache_read: integer(data, "tokens.cache_read"),
|
||||
tokens_cache_write_5m: integer(data, "tokens.cache_write_5m"),
|
||||
tokens_cache_write_1h: integer(data, "tokens.cache_write_1h"),
|
||||
cost_input_microcents: integer(data, "cost.input.microcents"),
|
||||
cost_output_microcents: integer(data, "cost.output.microcents"),
|
||||
cost_cache_read_microcents: integer(data, "cost.cache_read.microcents"),
|
||||
cost_cache_write_microcents: integer(data, "cost.cache_write.microcents"),
|
||||
cost_total_microcents: integer(data, "cost.total.microcents"),
|
||||
}
|
||||
}
|
||||
|
||||
// Returns a stable lookup key for an IP address.
|
||||
// IPv4: full address as /32 (e.g. "203.0.113.45/32").
|
||||
// IPv6: the /64 network prefix (e.g. "2001:db8:abcd:1234::/64"). ISPs commonly
|
||||
// rotate the lower 64 host bits via SLAAC privacy extensions (RFC 8981), so
|
||||
// grouping by /64 collapses those rotations into one key.
|
||||
function ipPrefix(ip: string | undefined) {
|
||||
if (!ip) return undefined
|
||||
if (ip.includes(".") && !ip.includes(":")) return `${ip}/32`
|
||||
if (!ip.includes(":")) return undefined
|
||||
|
||||
// Expand "::" to its full form, then keep the first 4 hextets.
|
||||
const [head, tail] = ip.split("::") as [string, string | undefined]
|
||||
const headParts = head ? head.split(":") : []
|
||||
const tailParts = tail !== undefined ? tail.split(":") : []
|
||||
const missing = 8 - headParts.length - tailParts.length
|
||||
if (missing < 0) return undefined
|
||||
const full = [...headParts, ...new Array(missing).fill("0"), ...tailParts]
|
||||
if (full.length !== 8) return undefined
|
||||
|
||||
const prefix = full
|
||||
.slice(0, 4)
|
||||
.map((part) => part.toLowerCase().replace(/^0+(?=.)/, ""))
|
||||
.join(":")
|
||||
return `${prefix}::/64`
|
||||
}
|
||||
|
||||
function string(data: Record<string, unknown>, key: string) {
|
||||
const value = data[key]
|
||||
if (typeof value === "string") return value
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function boolean(data: Record<string, unknown>, key: string) {
|
||||
const value = data[key]
|
||||
if (typeof value === "boolean") return value
|
||||
if (typeof value === "string") return value === "true" ? true : value === "false" ? false : undefined
|
||||
return undefined
|
||||
}
|
||||
|
||||
function integer(data: Record<string, unknown>, key: string) {
|
||||
const value = number(data, key)
|
||||
if (value === undefined) return undefined
|
||||
return Math.round(value)
|
||||
}
|
||||
|
||||
function number(data: Record<string, unknown>, key: string) {
|
||||
const value = data[key]
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : undefined
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
43
packages/console/function/src/stat.ts
Normal file
43
packages/console/function/src/stat.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { and, Database, inArray } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { ModelTpsRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
||||
|
||||
type Result = Record<string, { interval: number; qualify: number; unqualify: number }[]>
|
||||
|
||||
export default {
|
||||
async fetch(request: Request) {
|
||||
if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405 })
|
||||
|
||||
const body = (await request.json()) as { ids: string[] }
|
||||
const ids = body.ids
|
||||
if (ids.length === 0) return Response.json({} satisfies Result)
|
||||
|
||||
const toInterval = (date: Date) =>
|
||||
parseInt(
|
||||
date
|
||||
.toISOString()
|
||||
.replace(/[^0-9]/g, "")
|
||||
.substring(0, 12),
|
||||
)
|
||||
const now = Date.now()
|
||||
const intervals = Array.from({ length: 30 }, (_, i) => toInterval(new Date(now - i * 60 * 1000)))
|
||||
|
||||
const rows = await Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(ModelTpsRateLimitTable)
|
||||
.where(and(inArray(ModelTpsRateLimitTable.id, ids), inArray(ModelTpsRateLimitTable.interval, intervals))),
|
||||
)
|
||||
|
||||
const rowsByKey = new Map(rows.map((row) => [`${row.id}:${row.interval}`, row]))
|
||||
const result: Result = Object.fromEntries(
|
||||
ids.map((id) => [
|
||||
id,
|
||||
intervals.map((interval) => {
|
||||
const row = rowsByKey.get(`${id}:${interval}`)
|
||||
return { interval, qualify: row?.qualify ?? 0, unqualify: row?.unqualify ?? 0 }
|
||||
}),
|
||||
]),
|
||||
)
|
||||
return Response.json(result)
|
||||
},
|
||||
}
|
||||
10
packages/console/function/sst-env.d.ts
vendored
Normal file
10
packages/console/function/sst-env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
11
packages/console/function/tsconfig.json
Normal file
11
packages/console/function/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "react",
|
||||
"types": ["@cloudflare/workers-types", "node"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user