Compare commits

...
Author SHA1 Message Date
Aiden Cline 455d61f665 fix(app): restore custom provider creation on v2 2026-10-01 22:37:15 -05:00
13 changed files with 429 additions and 35 deletions

No files matched your search

@@ -632,6 +632,61 @@ for (const row of ["configured", "disabled", "failure"] as const) {
})
}
test("creates a custom provider with V2 config and separate credentials", async ({ page }) => {
const catalog = {
all: [] as { id: string; name: string; models: Record<string, { id: string; name: string }> }[],
connected: [] as string[],
default: {},
}
const { settings } = await open(page, { provider: () => catalog })
const writes: unknown[] = []
await page.route("**/api/experimental/config", async (route) => {
const payload = route.request().postDataJSON()
writes.push(payload)
catalog.all.push({
id: "desktop-custom",
name: "Desktop Custom",
models: { demo: { id: "demo", name: "Custom Demo" } },
})
catalog.connected.push("desktop-custom")
await route.fulfill({ status: 204 })
})
await page.route("**/api/credential", async (route) => {
const payload = route.request().postDataJSON()
writes.push(payload)
await route.fulfill({
json: { data: { id: "cred_custom", ...payload, label: "API key" } },
})
})
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
await settings.getByRole("button", { name: "Show more providers", exact: true }).click()
await page.getByText("Custom OpenAI-compatible provider", { exact: true }).click()
const dialog = page.getByRole("dialog")
await dialog.getByRole("textbox", { name: "Provider ID", exact: true }).fill("desktop-custom")
await dialog.getByRole("textbox", { name: "Display name", exact: true }).fill("Desktop Custom")
await dialog.getByRole("textbox", { name: "Base URL", exact: true }).fill("http://127.0.0.1:1/v1")
await dialog.getByRole("textbox", { name: "API key", exact: true }).fill("test-key")
await dialog.getByPlaceholder("model-id", { exact: true }).fill("demo")
await dialog.getByPlaceholder("Display Name", { exact: true }).fill("Custom Demo")
await dialog.getByRole("button", { name: "Submit", exact: true }).click()
await expect(dialog).toBeHidden()
expect(writes).toEqual([
{
providers: {
"desktop-custom": {
package: "@opencode/ai/providers/openai-compatible",
name: "Desktop Custom",
settings: { baseURL: "http://127.0.0.1:1/v1" },
models: { demo: { name: "Custom Demo" } },
},
},
},
{ integrationID: "desktop-custom", value: { type: "key", key: "test-key" } },
])
await settings.getByRole("tab", { name: "Models", exact: true }).click()
await expect(settings.getByRole("switch", { name: "Custom Demo", exact: true })).toBeChecked()
})
// Zen and the Console account share the id `opencode`: the provider comes from models.dev, the
// integration carries the account sign-in.
for (const row of [
@@ -108,7 +108,7 @@ export const DialogConnectProvider: Component<{
return (
<Switch>
<Match when={controller.selected() === CUSTOM_ID}>
<CustomProviderForm autofocus={false} />
<CustomProviderForm autofocus={false} directory={props.directory} />
</Match>
<Match
keyed
@@ -9,13 +9,17 @@ import { batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { ExternalLink } from "@/runtime/platform/external-link"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useModels } from "@/providers/models/models"
import { useLanguage } from "@/runtime/i18n/language"
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./form"
import { CustomManagedProviderIcon } from "@/providers/models/provider-group"
export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
export function CustomProviderForm(props: { autofocus?: boolean; directory?: string } = {}) {
const dialog = useDialog()
const data = useData()
const serverSDK = useServerSDK()
const models = useModels()
const language = useLanguage()
const [form, setForm] = createStore<FormState>({
@@ -104,8 +108,24 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const saveMutation = useMutation(() => ({
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>): Promise<typeof result> => {
// TODO: Restore custom providers when V2 exposes config and arbitrary credential APIs.
throw new Error(language.t("provider.custom.unavailable"))
await serverSDK.api.config.update({ providers: { [result.providerID]: result.config } })
if (result.key)
await serverSDK.api.credential.create({
integrationID: result.providerID,
value: { type: "key", key: result.key },
})
const location = props.directory ? { directory: props.directory } : undefined
data.location.integration.invalidate(location)
data.location.provider.invalidate(location)
data.location.model.invalidate(location)
await Promise.all([
data.location.integration.sync(location),
data.location.provider.sync(location),
data.location.model.sync(location),
])
for (const modelID of Object.keys(result.config.models))
models.setVisibility({ providerID: result.providerID, modelID }, true)
return result
},
onSuccess: (result) => {
dialog.close()
@@ -141,7 +161,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
<p class="text-14-regular text-text-base">
{language.t("provider.custom.description.prefix")}
<ExternalLink href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
<ExternalLink href="https://opencode.ai/v2/docs/providers/#custom" tabIndex={-1}>
{language.t("provider.custom.description.link")}
</ExternalLink>
{language.t("provider.custom.description.suffix")}
@@ -28,14 +28,14 @@ describe("validateCustomProvider", () => {
name: "Custom Provider",
key: undefined,
config: {
npm: "@ai-sdk/openai-compatible",
package: "@opencode/ai/providers/openai-compatible",
name: "Custom Provider",
env: ["CUSTOM_PROVIDER_KEY"],
options: {
settings: {
baseURL: "https://api.example.com",
headers: {
"X-Test": "enabled",
},
},
headers: {
"X-Test": "enabled",
},
models: {
"model-a": { name: "Model A" },
@@ -1,5 +1,5 @@
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
const OPENAI_COMPATIBLE = "@opencode/ai/providers/openai-compatible"
type Translator = (key: string, vars?: Record<string, string | number | boolean>) => string
@@ -137,13 +137,13 @@ export function validateCustomProvider(input: ValidateArgs) {
name,
key,
config: {
npm: OPENAI_COMPATIBLE,
package: OPENAI_COMPATIBLE,
name,
...(env ? { env: [env] } : {}),
options: {
settings: {
baseURL,
...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}),
},
...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}),
models: modelConfig,
},
},
+94 -2
View File
@@ -2370,9 +2370,101 @@ export type ConfigShellsOutput = ReadonlyArray<{
}>
export type ConfigShellsOperation<E = never> = () => Effect.Effect<ConfigShellsOutput, E>
export type ConfigUpdateInput = { readonly shell: string | null }
export type ConfigUpdateInput = {
readonly shell?: string | null | undefined
readonly providers?:
| {
readonly [x: string]: {
readonly canonical?: Provider.ID | undefined
readonly name?: string | undefined
readonly env?: ReadonlyArray<string> | undefined
readonly package?: string | undefined
readonly settings?:
| ({
readonly timeout?: number | false | undefined
readonly headerTimeout?: number | false | undefined
readonly chunkTimeout?: number | false | undefined
readonly compaction?: Provider.Compaction | undefined
readonly transport?: Provider.Transport | undefined
} & { readonly [x: string]: Schema.Json | undefined })
| undefined
readonly headers?: { readonly [x: string]: string } | undefined
readonly body?: { readonly [x: string]: Schema.Json } | undefined
readonly models?:
| {
readonly [x: string]: {
readonly modelID?: Model.ID | undefined
readonly family?: Model.Family | undefined
readonly name?: string | undefined
readonly compatibility?: Model.Compatibility | undefined
readonly package?: string | undefined
readonly settings?:
| ({ readonly compaction?: Provider.Compaction | undefined } & {
readonly [x: string]: Schema.Json | undefined
})
| undefined
readonly headers?: { readonly [x: string]: string } | undefined
readonly body?: { readonly [x: string]: Schema.Json } | undefined
readonly capabilities?:
| {
readonly tools?: boolean | undefined
readonly input?: ReadonlyArray<string> | undefined
readonly output?: ReadonlyArray<string> | undefined
}
| undefined
readonly variants?:
| ReadonlyArray<{
readonly id: Model.VariantID
readonly settings?:
| ({ readonly compaction?: Provider.Compaction | undefined } & {
readonly [x: string]: Schema.Json | undefined
})
| undefined
readonly headers?: { readonly [x: string]: string } | undefined
readonly body?: { readonly [x: string]: Schema.Json } | undefined
}>
| undefined
readonly cost?:
| {
readonly tier?: { readonly type: "context"; readonly size: number } | undefined
readonly input: number & Brand.Brand<"Money.USDPerMillionTokens">
readonly output: number & Brand.Brand<"Money.USDPerMillionTokens">
readonly cache?:
| {
readonly read?: (number & Brand.Brand<"Money.USDPerMillionTokens">) | undefined
readonly write?: (number & Brand.Brand<"Money.USDPerMillionTokens">) | undefined
}
| undefined
}
| ReadonlyArray<{
readonly tier?: { readonly type: "context"; readonly size: number } | undefined
readonly input: number & Brand.Brand<"Money.USDPerMillionTokens">
readonly output: number & Brand.Brand<"Money.USDPerMillionTokens">
readonly cache?:
| {
readonly read?: (number & Brand.Brand<"Money.USDPerMillionTokens">) | undefined
readonly write?: (number & Brand.Brand<"Money.USDPerMillionTokens">) | undefined
}
| undefined
}>
| undefined
readonly disabled?: boolean | undefined
readonly limit?:
| {
readonly context?: number | undefined
readonly input?: number | undefined
readonly output?: number | undefined
}
| undefined
}
}
| undefined
} | null
}
| undefined
}
export type ConfigUpdateOutput = void
export type ConfigUpdateOperation<E = never> = (input: ConfigUpdateInput) => Effect.Effect<ConfigUpdateOutput, E>
export type ConfigUpdateOperation<E = never> = (input?: ConfigUpdateInput) => Effect.Effect<ConfigUpdateOutput, E>
export interface ConfigApi<E = never> {
readonly get: ConfigGetOperation<E>
@@ -1561,9 +1561,11 @@ const EndpointConfigGet = (raw: RawClient["server.config"]) => (input?: ConfigGe
const EndpointConfigShells = (raw: RawClient["server.config"]) => () =>
preserveEffect<ConfigShellsOutput>()(raw["config.shells"]({}).pipe(Effect.mapError(mapClientError)))
const EndpointConfigUpdate = (raw: RawClient["server.config"]) => (input: ConfigUpdateInput) =>
const EndpointConfigUpdate = (raw: RawClient["server.config"]) => (input?: ConfigUpdateInput) =>
preserveEffect<ConfigUpdateOutput>()(
raw["config.update"]({ payload: { shell: input["shell"] } }).pipe(Effect.mapError(mapClientError)),
raw["config.update"]({ payload: { shell: input?.["shell"], providers: input?.["providers"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroupConfig = (raw: RawClient["server.config"]) => ({
@@ -2190,12 +2190,12 @@ export function make(options: ClientOptions) {
{ method: "GET", path: `/api/config/shell`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
requestOptions,
),
update: (input: ConfigUpdateInput, requestOptions?: RequestOptions) =>
update: (input?: ConfigUpdateInput, requestOptions?: RequestOptions) =>
request<ConfigUpdateOutput>(
{
method: "PATCH",
path: `/api/experimental/config`,
body: { shell: input["shell"] },
body: { shell: input?.["shell"], providers: input?.["providers"] },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
+140 -1
View File
@@ -6599,6 +6599,145 @@ export type ConfigGetOutput = Array<ConfigEntry>
export type ConfigShellsOutput = Array<ConfigShellOption>
export type ConfigUpdateInput = { readonly shell: { readonly shell: string | null }["shell"] }
export type ConfigUpdateInput = {
readonly shell?: {
readonly shell?: string | null
readonly providers?: {
readonly [x: string]: {
readonly canonical?: string
readonly name?: string
readonly env?: ReadonlyArray<string>
readonly package?: string
readonly settings?: {
readonly timeout?: number | false
readonly headerTimeout?: number | false
readonly chunkTimeout?: number | false
readonly compaction?: { readonly type: "summary" } | { readonly type: "native" }
readonly transport?: "http" | "websocket"
} & { readonly [x: string]: JsonValue | null }
readonly headers?: { readonly [x: string]: string }
readonly body?: { readonly [x: string]: JsonValue }
readonly models?: {
readonly [x: string]: {
readonly modelID?: string
readonly family?: string
readonly name?: string
readonly compatibility?: {
readonly reasoningField?: "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
readonly requireReasoning?: boolean
readonly maxTokensField?: "max_completion_tokens" | "max_tokens"
readonly requireFinishReason?: boolean
readonly requireAssistantAfterTool?: boolean
readonly supportsPromptCacheKey?: boolean
}
readonly package?: string
readonly settings?: { readonly compaction?: { readonly type: "summary" } | { readonly type: "native" } } & {
readonly [x: string]: JsonValue | null
}
readonly headers?: { readonly [x: string]: string }
readonly body?: { readonly [x: string]: JsonValue }
readonly capabilities?: {
readonly tools?: boolean
readonly input?: ReadonlyArray<string>
readonly output?: ReadonlyArray<string>
}
readonly variants?: ReadonlyArray<{
readonly id: string
readonly settings?: {
readonly compaction?: { readonly type: "summary" } | { readonly type: "native" }
} & { readonly [x: string]: JsonValue | null }
readonly headers?: { readonly [x: string]: string }
readonly body?: { readonly [x: string]: JsonValue }
}>
readonly cost?:
| {
readonly tier?: { readonly type: "context"; readonly size: number }
readonly input: number
readonly output: number
readonly cache?: { readonly read?: number; readonly write?: number }
}
| ReadonlyArray<{
readonly tier?: { readonly type: "context"; readonly size: number }
readonly input: number
readonly output: number
readonly cache?: { readonly read?: number; readonly write?: number }
}>
readonly disabled?: boolean
readonly limit?: { readonly context?: number; readonly input?: number; readonly output?: number }
}
}
} | null
}
}["shell"]
readonly providers?: {
readonly shell?: string | null
readonly providers?: {
readonly [x: string]: {
readonly canonical?: string
readonly name?: string
readonly env?: ReadonlyArray<string>
readonly package?: string
readonly settings?: {
readonly timeout?: number | false
readonly headerTimeout?: number | false
readonly chunkTimeout?: number | false
readonly compaction?: { readonly type: "summary" } | { readonly type: "native" }
readonly transport?: "http" | "websocket"
} & { readonly [x: string]: JsonValue | null }
readonly headers?: { readonly [x: string]: string }
readonly body?: { readonly [x: string]: JsonValue }
readonly models?: {
readonly [x: string]: {
readonly modelID?: string
readonly family?: string
readonly name?: string
readonly compatibility?: {
readonly reasoningField?: "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
readonly requireReasoning?: boolean
readonly maxTokensField?: "max_completion_tokens" | "max_tokens"
readonly requireFinishReason?: boolean
readonly requireAssistantAfterTool?: boolean
readonly supportsPromptCacheKey?: boolean
}
readonly package?: string
readonly settings?: { readonly compaction?: { readonly type: "summary" } | { readonly type: "native" } } & {
readonly [x: string]: JsonValue | null
}
readonly headers?: { readonly [x: string]: string }
readonly body?: { readonly [x: string]: JsonValue }
readonly capabilities?: {
readonly tools?: boolean
readonly input?: ReadonlyArray<string>
readonly output?: ReadonlyArray<string>
}
readonly variants?: ReadonlyArray<{
readonly id: string
readonly settings?: {
readonly compaction?: { readonly type: "summary" } | { readonly type: "native" }
} & { readonly [x: string]: JsonValue | null }
readonly headers?: { readonly [x: string]: string }
readonly body?: { readonly [x: string]: JsonValue }
}>
readonly cost?:
| {
readonly tier?: { readonly type: "context"; readonly size: number }
readonly input: number
readonly output: number
readonly cache?: { readonly read?: number; readonly write?: number }
}
| ReadonlyArray<{
readonly tier?: { readonly type: "context"; readonly size: number }
readonly input: number
readonly output: number
readonly cache?: { readonly read?: number; readonly write?: number }
}>
readonly disabled?: boolean
readonly limit?: { readonly context?: number; readonly input?: number; readonly output?: number }
}
}
} | null
}
}["providers"]
}
export type ConfigUpdateOutput = void
+23 -8
View File
@@ -237,7 +237,12 @@ export const layer = (options?: Options) =>
]
})
const initial = yield* ConfigDiscovery.discover(options)
const discover = ConfigDiscovery.discover(options).pipe(
Effect.provideService(FSUtil.Service, fs),
Effect.provideService(Global.Service, globalService),
Effect.provideService(Location.Service, location),
)
const initial = yield* discover
let sources = initial
let configs = yield* load(initial)
const updates = yield* PubSub.unbounded<Watcher.Update>()
@@ -264,7 +269,7 @@ export const layer = (options?: Options) =>
const reload = Effect.fn("Config.reload")(
function* () {
const discovered = yield* ConfigDiscovery.discover(options)
const discovered = yield* discover
const next = yield* load(discovered)
yield* reconcile(discovered)
const compatibilityChanged =
@@ -275,7 +280,7 @@ export const layer = (options?: Options) =>
configs = next
yield* bus.publish(Event.Updated, {})
},
(effect) => reloadLock.withPermit(effect),
(effect) => reloadLock.withPermit(effect).pipe(Effect.provideService(FSUtil.Service, fs)),
)
// Subscribe eagerly so synchronous watch readiness isn't dropped.
@@ -334,16 +339,26 @@ export const layer = (options?: Options) =>
const text = (yield* fs.readFileStringSafe(filepath)) ?? "{}\n"
const updated = yield* Effect.try({
try: () =>
applyEdits(
[
...(patch.shell === undefined ? [] : [{ path: ["shell"], value: patch.shell ?? undefined }]),
...Object.entries(patch.providers ?? {}).map(([id, provider]) => ({
path: ["providers", id],
value: provider ?? undefined,
})),
].reduce(
(text, edit) =>
applyEdits(
text,
modify(text, edit.path, edit.value, {
formattingOptions: { tabSize: 2, insertSpaces: true },
}),
),
text,
modify(text, ["shell"], patch.shell ?? undefined, {
formattingOptions: { tabSize: 2, insertSpaces: true },
}),
),
catch: (cause) => new FSUtil.FileSystemError({ method: "config.update", cause }),
})
yield* fs.writeWithDirs(filepath, updated.endsWith("\n") ? updated : `${updated}\n`)
yield* requestReload
yield* reload()
},
(effect) => updateLock.withPermit(effect),
)
+2 -1
View File
@@ -39,7 +39,8 @@ export const ConfigGroup = HttpApiGroup.make("server.config")
OpenApi.annotations({
identifier: "experimental.config.update",
summary: "Update global configuration",
description: "Patch supported fields in the highest-precedence global configuration document.",
description:
"Patch supported fields in the highest-precedence global configuration document. Provider entries are replaced by ID; null removes an entry. Omitted fields are unchanged.",
}),
),
)
+2 -1
View File
@@ -110,7 +110,8 @@ export class Info extends Schema.Class<Info>("Config.Info")({
}) {}
export const Patch = Schema.Struct({
shell: Schema.NullOr(Schema.String),
shell: Schema.NullOr(Schema.String).pipe(optional),
providers: Schema.Record(Schema.String, Schema.NullOr(ConfigProvider.Info)).pipe(optional),
}).annotate({ identifier: "Config.Patch" })
export interface Patch extends Schema.Schema.Type<typeof Patch> {}
+73 -4
View File
@@ -59,7 +59,7 @@ it.live("returns ordered config entries for the requested directory", () =>
}),
)
it.live("updates the global shell without replacing unrelated JSONC", () =>
it.live("updates supported global fields without replacing unrelated JSONC", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-config-shells-")))
const global = path.join(tmp.path, "global")
@@ -71,7 +71,8 @@ it.live("updates the global shell without replacing unrelated JSONC", () =>
`{
// keep this comment
"model": "provider/model",
"shell": "bash"
"shell": "bash",
"providers": { "existing": { "name": "Existing" } }
}
`,
),
@@ -80,17 +81,85 @@ it.live("updates the global shell without replacing unrelated JSONC", () =>
const response = yield* Effect.promise(() =>
fetch(new URL("/api/experimental/config", server.base), {
method: "PATCH",
headers: { ...server.headers, "content-type": "application/json" },
headers: { ...server.headers, "content-type": "application/json", "x-opencode-directory": global },
body: JSON.stringify({ shell: "/bin/zsh" }),
}),
)
expect(response.status).toBe(204)
expect({ status: response.status, body: yield* Effect.promise(() => response.text()) }).toEqual({
status: 204,
body: "",
})
const text = yield* Effect.promise(() => fs.readFile(config, "utf8"))
expect(text).toContain("// keep this comment")
expect(text).toContain('"model": "provider/model"')
expect(text).toContain('"shell": "/bin/zsh"')
const provider = {
name: "Custom Provider",
package: "@opencode/ai/providers/openai-compatible",
settings: { baseURL: "http://127.0.0.1:1/v1" },
models: { demo: { name: "Demo" } },
}
const added = yield* Effect.promise(() =>
fetch(new URL("/api/experimental/config", server.base), {
method: "PATCH",
headers: { ...server.headers, "content-type": "application/json", "x-opencode-directory": global },
body: JSON.stringify({ providers: { custom: provider } }),
}),
)
expect(added.status).toBe(204)
const updated = yield* Effect.promise(() => fs.readFile(config, "utf8"))
expect(updated).toContain("// keep this comment")
expect(JSON.parse(updated.replace("// keep this comment", ""))).toMatchObject({
model: "provider/model",
shell: "/bin/zsh",
providers: { existing: { name: "Existing" }, custom: provider },
})
const loaded = yield* Effect.promise(() =>
fetch(new URL("/api/config", server.base), {
headers: { ...server.headers, "x-opencode-directory": global },
}).then((response) => response.json()),
)
expect(Schema.decodeUnknownSync(Schema.Array(Config.Entry))(loaded)).toContainEqual(
expect.objectContaining({
info: expect.objectContaining({ providers: expect.objectContaining({ custom: provider }) }),
}),
)
const credential = yield* Effect.promise(() =>
fetch(new URL("/api/credential", server.base), {
method: "POST",
headers: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ integrationID: "custom", value: { type: "key", key: "test-key" } }),
}),
)
expect(credential.ok).toBe(true)
const integrations = yield* Effect.promise(() =>
fetch(new URL("/api/integration", server.base), {
headers: { ...server.headers, "x-opencode-directory": global },
}).then((response) => response.json()),
)
expect(integrations.data).toContainEqual(expect.objectContaining({ id: "custom" }))
const available = yield* Effect.promise(() =>
fetch(new URL("/api/provider", server.base), {
headers: { ...server.headers, "x-opencode-directory": global },
}).then((response) => response.json()),
)
expect(available.data).toContainEqual(expect.objectContaining({ id: "custom", name: "Custom Provider" }))
const removed = yield* Effect.promise(() =>
fetch(new URL("/api/experimental/config", server.base), {
method: "PATCH",
headers: { ...server.headers, "content-type": "application/json", "x-opencode-directory": global },
body: JSON.stringify({ providers: { custom: null } }),
}),
)
expect(removed.status).toBe(204)
const remaining = yield* Effect.promise(() => fs.readFile(config, "utf8"))
expect(JSON.parse(remaining.replace("// keep this comment", "")).providers).toEqual({
existing: { name: "Existing" },
})
const shells = yield* Effect.promise(() =>
fetch(new URL("/api/config/shell", server.base), { headers: server.headers }),
)