mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-03-31 14:22:27 +00:00
feat(id): brand ProviderID and ModelID (#17110)
This commit is contained in:
@@ -35,6 +35,7 @@ import { Hash } from "../util/hash"
|
||||
import { ACPSessionManager } from "./session"
|
||||
import type { ACPConfig } from "./types"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { ProviderID } from "../provider/schema"
|
||||
import { Agent as AgentModule } from "../agent/agent"
|
||||
import { Installation } from "@/installation"
|
||||
import { MessageV2 } from "@/session/message-v2"
|
||||
@@ -590,7 +591,7 @@ export namespace ACP {
|
||||
}
|
||||
} catch (e) {
|
||||
const error = MessageV2.fromError(e, {
|
||||
providerID: this.config.defaultModel?.providerID ?? "unknown",
|
||||
providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
|
||||
})
|
||||
if (LoadAPIKeyError.isInstance(error)) {
|
||||
throw RequestError.authRequired()
|
||||
@@ -655,7 +656,7 @@ export namespace ACP {
|
||||
return result
|
||||
} catch (e) {
|
||||
const error = MessageV2.fromError(e, {
|
||||
providerID: this.config.defaultModel?.providerID ?? "unknown",
|
||||
providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
|
||||
})
|
||||
if (LoadAPIKeyError.isInstance(error)) {
|
||||
throw RequestError.authRequired()
|
||||
@@ -700,7 +701,7 @@ export namespace ACP {
|
||||
return response
|
||||
} catch (e) {
|
||||
const error = MessageV2.fromError(e, {
|
||||
providerID: this.config.defaultModel?.providerID ?? "unknown",
|
||||
providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
|
||||
})
|
||||
if (LoadAPIKeyError.isInstance(error)) {
|
||||
throw RequestError.authRequired()
|
||||
@@ -765,7 +766,7 @@ export namespace ACP {
|
||||
return mode
|
||||
} catch (e) {
|
||||
const error = MessageV2.fromError(e, {
|
||||
providerID: this.config.defaultModel?.providerID ?? "unknown",
|
||||
providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
|
||||
})
|
||||
if (LoadAPIKeyError.isInstance(error)) {
|
||||
throw RequestError.authRequired()
|
||||
@@ -796,7 +797,7 @@ export namespace ACP {
|
||||
return result
|
||||
} catch (e) {
|
||||
const error = MessageV2.fromError(e, {
|
||||
providerID: this.config.defaultModel?.providerID ?? "unknown",
|
||||
providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
|
||||
})
|
||||
if (LoadAPIKeyError.isInstance(error)) {
|
||||
throw RequestError.authRequired()
|
||||
@@ -1666,7 +1667,8 @@ export namespace ACP {
|
||||
): ModelOption[] {
|
||||
const includeVariants = options.includeVariants ?? false
|
||||
return providers.flatMap((provider) => {
|
||||
const models = Provider.sort(Object.values(provider.models) as any)
|
||||
const unsorted: Array<{ id: string; name: string; variants?: Record<string, any> }> = Object.values(provider.models)
|
||||
const models = Provider.sort(unsorted)
|
||||
return models.flatMap((model) => {
|
||||
const base: ModelOption = {
|
||||
modelId: `${provider.id}/${model.id}`,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Config } from "../config/config"
|
||||
import z from "zod"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { generateObject, streamObject, type ModelMessage } from "ai"
|
||||
import { SystemPrompt } from "../session/system"
|
||||
import { Instance } from "../project/instance"
|
||||
@@ -34,8 +35,8 @@ export namespace Agent {
|
||||
permission: PermissionNext.Ruleset,
|
||||
model: z
|
||||
.object({
|
||||
modelID: z.string(),
|
||||
providerID: z.string(),
|
||||
modelID: ModelID.zod,
|
||||
providerID: ProviderID.zod,
|
||||
})
|
||||
.optional(),
|
||||
variant: z.string().optional(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Installation } from "../installation"
|
||||
import { Auth, OAUTH_DUMMY_KEY } from "../auth"
|
||||
import os from "os"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
const log = Log.create({ service: "plugin.codex" })
|
||||
@@ -375,8 +376,8 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
|
||||
if (!provider.models["gpt-5.3-codex"]) {
|
||||
const model = {
|
||||
id: "gpt-5.3-codex",
|
||||
providerID: "openai",
|
||||
id: ModelID.make("gpt-5.3-codex"),
|
||||
providerID: ProviderID.make("openai"),
|
||||
api: {
|
||||
id: "gpt-5.3-codex",
|
||||
url: "https://chatgpt.com/backend-api/codex",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { fn } from "@/util/fn"
|
||||
import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Auth } from "@/auth"
|
||||
import { ProviderID } from "./schema"
|
||||
|
||||
export namespace ProviderAuth {
|
||||
const state = Instance.state(async () => {
|
||||
@@ -53,7 +54,7 @@ export namespace ProviderAuth {
|
||||
|
||||
export const authorize = fn(
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
providerID: ProviderID.zod,
|
||||
method: z.number(),
|
||||
}),
|
||||
async (input): Promise<Authorization | undefined> => {
|
||||
@@ -73,7 +74,7 @@ export namespace ProviderAuth {
|
||||
|
||||
export const callback = fn(
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
providerID: ProviderID.zod,
|
||||
method: z.number(),
|
||||
code: z.string().optional(),
|
||||
}),
|
||||
@@ -119,7 +120,7 @@ export namespace ProviderAuth {
|
||||
|
||||
export const api = fn(
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
providerID: ProviderID.zod,
|
||||
key: z.string(),
|
||||
}),
|
||||
async (input) => {
|
||||
@@ -133,13 +134,13 @@ export namespace ProviderAuth {
|
||||
export const OauthMissing = NamedError.create(
|
||||
"ProviderAuthOauthMissing",
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
)
|
||||
export const OauthCodeMissing = NamedError.create(
|
||||
"ProviderAuthOauthCodeMissing",
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
||||
import { GoogleAuth } from "google-auth-library"
|
||||
import { ProviderTransform } from "./transform"
|
||||
import { Installation } from "../installation"
|
||||
import { ModelID, ProviderID } from "./schema"
|
||||
|
||||
const DEFAULT_CHUNK_TIMEOUT = 120_000
|
||||
|
||||
@@ -673,8 +674,8 @@ export namespace Provider {
|
||||
|
||||
export const Model = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
providerID: z.string(),
|
||||
id: ModelID.zod,
|
||||
providerID: ProviderID.zod,
|
||||
api: z.object({
|
||||
id: z.string(),
|
||||
url: z.string(),
|
||||
@@ -744,7 +745,7 @@ export namespace Provider {
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
id: ProviderID.zod,
|
||||
name: z.string(),
|
||||
source: z.enum(["env", "config", "custom", "api"]),
|
||||
env: z.string().array(),
|
||||
@@ -759,8 +760,8 @@ export namespace Provider {
|
||||
|
||||
function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
|
||||
const m: Model = {
|
||||
id: model.id,
|
||||
providerID: provider.id,
|
||||
id: ModelID.make(model.id),
|
||||
providerID: ProviderID.make(provider.id),
|
||||
name: model.name,
|
||||
family: model.family,
|
||||
api: {
|
||||
@@ -826,7 +827,7 @@ export namespace Provider {
|
||||
|
||||
export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
|
||||
return {
|
||||
id: provider.id,
|
||||
id: ProviderID.make(provider.id),
|
||||
source: "custom",
|
||||
name: provider.name,
|
||||
env: provider.env ?? [],
|
||||
@@ -866,11 +867,11 @@ export namespace Provider {
|
||||
const githubCopilot = database["github-copilot"]
|
||||
database["github-copilot-enterprise"] = {
|
||||
...githubCopilot,
|
||||
id: "github-copilot-enterprise",
|
||||
id: ProviderID.make("github-copilot-enterprise"),
|
||||
name: "GitHub Copilot Enterprise",
|
||||
models: mapValues(githubCopilot.models, (model) => ({
|
||||
...model,
|
||||
providerID: "github-copilot-enterprise",
|
||||
providerID: ProviderID.make("github-copilot-enterprise"),
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -892,7 +893,7 @@ export namespace Provider {
|
||||
for (const [providerID, provider] of configProviders) {
|
||||
const existing = database[providerID]
|
||||
const parsed: Info = {
|
||||
id: providerID,
|
||||
id: ProviderID.make(providerID),
|
||||
name: provider.name ?? existing?.name ?? providerID,
|
||||
env: provider.env ?? existing?.env ?? [],
|
||||
options: mergeDeep(existing?.options ?? {}, provider.options ?? {}),
|
||||
@@ -908,7 +909,7 @@ export namespace Provider {
|
||||
return existingModel?.name ?? modelID
|
||||
})
|
||||
const parsedModel: Model = {
|
||||
id: modelID,
|
||||
id: ModelID.make(modelID),
|
||||
api: {
|
||||
id: model.id ?? existingModel?.api.id ?? modelID,
|
||||
npm:
|
||||
@@ -921,7 +922,7 @@ export namespace Provider {
|
||||
},
|
||||
status: model.status ?? existingModel?.status ?? "active",
|
||||
name,
|
||||
providerID,
|
||||
providerID: ProviderID.make(providerID),
|
||||
capabilities: {
|
||||
temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false,
|
||||
reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false,
|
||||
@@ -1356,7 +1357,7 @@ export namespace Provider {
|
||||
}
|
||||
|
||||
const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"]
|
||||
export function sort(models: Model[]) {
|
||||
export function sort<T extends { id: string }>(models: T[]) {
|
||||
return sortBy(
|
||||
models,
|
||||
[(model) => priority.findIndex((filter) => model.id.includes(filter)), "desc"],
|
||||
@@ -1370,11 +1371,11 @@ export namespace Provider {
|
||||
if (cfg.model) return parseModel(cfg.model)
|
||||
|
||||
const providers = await list()
|
||||
const recent = (await Filesystem.readJson<{ recent?: { providerID: string; modelID: string }[] }>(
|
||||
const recent = (await Filesystem.readJson<{ recent?: { providerID: ProviderID; modelID: ModelID }[] }>(
|
||||
path.join(Global.Path.state, "model.json"),
|
||||
)
|
||||
.then((x) => (Array.isArray(x.recent) ? x.recent : []))
|
||||
.catch(() => [])) as { providerID: string; modelID: string }[]
|
||||
.catch(() => [])) as { providerID: ProviderID; modelID: ModelID }[]
|
||||
for (const entry of recent) {
|
||||
const provider = providers[entry.providerID]
|
||||
if (!provider) continue
|
||||
@@ -1395,16 +1396,16 @@ export namespace Provider {
|
||||
export function parseModel(model: string) {
|
||||
const [providerID, ...rest] = model.split("/")
|
||||
return {
|
||||
providerID: providerID,
|
||||
modelID: rest.join("/"),
|
||||
providerID: ProviderID.make(providerID),
|
||||
modelID: ModelID.make(rest.join("/")),
|
||||
}
|
||||
}
|
||||
|
||||
export const ModelNotFoundError = NamedError.create(
|
||||
"ProviderModelNotFoundError",
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
modelID: z.string(),
|
||||
providerID: ProviderID.zod,
|
||||
modelID: ModelID.zod,
|
||||
suggestions: z.array(z.string()).optional(),
|
||||
}),
|
||||
)
|
||||
@@ -1412,7 +1413,7 @@ export namespace Provider {
|
||||
export const InitError = NamedError.create(
|
||||
"ProviderInitError",
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
26
packages/opencode/src/provider/schema.ts
Normal file
26
packages/opencode/src/provider/schema.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
const providerIdSchema = Schema.String.pipe(Schema.brand("ProviderID"))
|
||||
|
||||
export type ProviderID = typeof providerIdSchema.Type
|
||||
|
||||
export const ProviderID = providerIdSchema.pipe(
|
||||
withStatics((schema: typeof providerIdSchema) => ({
|
||||
make: (id: string) => schema.makeUnsafe(id),
|
||||
zod: z.string().pipe(z.custom<ProviderID>()),
|
||||
})),
|
||||
)
|
||||
|
||||
const modelIdSchema = Schema.String.pipe(Schema.brand("ModelID"))
|
||||
|
||||
export type ModelID = typeof modelIdSchema.Type
|
||||
|
||||
export const ModelID = modelIdSchema.pipe(
|
||||
withStatics((schema: typeof modelIdSchema) => ({
|
||||
make: (id: string) => schema.makeUnsafe(id),
|
||||
zod: z.string().pipe(z.custom<ModelID>()),
|
||||
})),
|
||||
)
|
||||
@@ -5,6 +5,7 @@ import { Config } from "../../config/config"
|
||||
import { Provider } from "../../provider/provider"
|
||||
import { ModelsDev } from "../../provider/models"
|
||||
import { ProviderAuth } from "../../provider/auth"
|
||||
import { ProviderID } from "../../provider/schema"
|
||||
import { mapValues } from "remeda"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
@@ -101,7 +102,7 @@ export const ProviderRoutes = lazy(() =>
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: z.string().meta({ description: "Provider ID" }),
|
||||
providerID: ProviderID.zod.meta({ description: "Provider ID" }),
|
||||
}),
|
||||
),
|
||||
validator(
|
||||
@@ -141,7 +142,7 @@ export const ProviderRoutes = lazy(() =>
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: z.string().meta({ description: "Provider ID" }),
|
||||
providerID: ProviderID.zod.meta({ description: "Provider ID" }),
|
||||
}),
|
||||
),
|
||||
validator(
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Snapshot } from "@/snapshot"
|
||||
import { Log } from "../../util/log"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
import { PermissionID } from "@/permission/schema"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
|
||||
@@ -510,8 +511,8 @@ export const SessionRoutes = lazy(() =>
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
modelID: z.string(),
|
||||
providerID: ProviderID.zod,
|
||||
modelID: ModelID.zod,
|
||||
auto: z.boolean().optional().default(false),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Command } from "../command"
|
||||
import { Global } from "../global"
|
||||
import { WorkspaceContext } from "../control-plane/workspace-context"
|
||||
import { WorkspaceID } from "../control-plane/schema"
|
||||
import { ProviderID } from "../provider/schema"
|
||||
import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware"
|
||||
import { ProjectRoutes } from "./routes/project"
|
||||
import { SessionRoutes } from "./routes/session"
|
||||
@@ -148,7 +149,7 @@ export namespace Server {
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
),
|
||||
validator("json", Auth.Info),
|
||||
@@ -180,7 +181,7 @@ export namespace Server {
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Agent } from "@/agent/agent"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Config } from "@/config/config"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
|
||||
export namespace SessionCompaction {
|
||||
const log = Log.create({ service: "session.compaction" })
|
||||
@@ -298,8 +299,8 @@ When constructing the summary, try to stick to this template:
|
||||
sessionID: SessionID.zod,
|
||||
agent: z.string(),
|
||||
model: z.object({
|
||||
providerID: z.string(),
|
||||
modelID: z.string(),
|
||||
providerID: ProviderID.zod,
|
||||
modelID: ModelID.zod,
|
||||
}),
|
||||
auto: z.boolean(),
|
||||
overflow: z.boolean().optional(),
|
||||
|
||||
@@ -27,6 +27,7 @@ import { WorkspaceID } from "../control-plane/schema"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
import { Global } from "@/global"
|
||||
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
|
||||
@@ -875,8 +876,8 @@ export namespace Session {
|
||||
export const initialize = fn(
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
modelID: z.string(),
|
||||
providerID: z.string(),
|
||||
modelID: ModelID.zod,
|
||||
providerID: ProviderID.zod,
|
||||
messageID: MessageID.zod,
|
||||
}),
|
||||
async (input) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ProviderError } from "@/provider/error"
|
||||
import { iife } from "@/util/iife"
|
||||
import { type SystemError } from "bun"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
|
||||
export namespace MessageV2 {
|
||||
export function isMedia(mime: string) {
|
||||
@@ -213,8 +214,8 @@ export namespace MessageV2 {
|
||||
agent: z.string(),
|
||||
model: z
|
||||
.object({
|
||||
providerID: z.string(),
|
||||
modelID: z.string(),
|
||||
providerID: ProviderID.zod,
|
||||
modelID: ModelID.zod,
|
||||
})
|
||||
.optional(),
|
||||
command: z.string().optional(),
|
||||
@@ -362,8 +363,8 @@ export namespace MessageV2 {
|
||||
.optional(),
|
||||
agent: z.string(),
|
||||
model: z.object({
|
||||
providerID: z.string(),
|
||||
modelID: z.string(),
|
||||
providerID: ProviderID.zod,
|
||||
modelID: ModelID.zod,
|
||||
}),
|
||||
system: z.string().optional(),
|
||||
tools: z.record(z.string(), z.boolean()).optional(),
|
||||
@@ -411,8 +412,8 @@ export namespace MessageV2 {
|
||||
])
|
||||
.optional(),
|
||||
parentID: MessageID.zod,
|
||||
modelID: z.string(),
|
||||
providerID: z.string(),
|
||||
modelID: ModelID.zod,
|
||||
providerID: ProviderID.zod,
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@@ -824,7 +825,7 @@ export namespace MessageV2 {
|
||||
return result
|
||||
}
|
||||
|
||||
export function fromError(e: unknown, ctx: { providerID: string }) {
|
||||
export function fromError(e: unknown, ctx: { providerID: ProviderID }): NonNullable<Assistant["error"]> {
|
||||
switch (true) {
|
||||
case e instanceof DOMException && e.name === "AbortError":
|
||||
return new MessageV2.AbortedError(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import z from "zod"
|
||||
import { SessionID } from "./schema"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
|
||||
export namespace Message {
|
||||
@@ -160,8 +161,8 @@ export namespace Message {
|
||||
assistant: z
|
||||
.object({
|
||||
system: z.string().array(),
|
||||
modelID: z.string(),
|
||||
providerID: z.string(),
|
||||
modelID: ModelID.zod,
|
||||
providerID: ProviderID.zod,
|
||||
path: z.object({
|
||||
cwd: z.string(),
|
||||
root: z.string(),
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SessionRevert } from "./revert"
|
||||
import { Session } from "."
|
||||
import { Agent } from "../agent/agent"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { type Tool as AITool, tool, jsonSchema, type ToolCallOptions, asSchema } from "ai"
|
||||
import { SessionCompaction } from "./compaction"
|
||||
import { Instance } from "../project/instance"
|
||||
@@ -94,8 +95,8 @@ export namespace SessionPrompt {
|
||||
messageID: MessageID.zod.optional(),
|
||||
model: z
|
||||
.object({
|
||||
providerID: z.string(),
|
||||
modelID: z.string(),
|
||||
providerID: ProviderID.zod,
|
||||
modelID: ModelID.zod,
|
||||
})
|
||||
.optional(),
|
||||
agent: z.string().optional(),
|
||||
@@ -1471,8 +1472,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
agent: z.string(),
|
||||
model: z
|
||||
.object({
|
||||
providerID: z.string(),
|
||||
modelID: z.string(),
|
||||
providerID: ProviderID.zod,
|
||||
modelID: ModelID.zod,
|
||||
})
|
||||
.optional(),
|
||||
command: z.string(),
|
||||
|
||||
Reference in New Issue
Block a user