mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-04 14:36:18 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
455d61f665 |
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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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> {}
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user