Compare commits

...
Author SHA1 Message Date
thdxr cc43e9d226 fix(core): load Console config from the V2 endpoint 2026-08-28 15:21:26 +00:00
2 changed files with 342 additions and 198 deletions
+101 -67
View File
@@ -8,15 +8,67 @@ import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import { ConfigProviderV1 } from "../../v1/config/provider.js"
import { Money } from "@opencode-ai/schema/money"
import { ConfigProviderOptionsV1 } from "../../v1/config/provider-options.js"
import { ConfigV1 } from "../../v1/config/config.js"
const defaultServer = "https://opencode.ai/console"
const clientID = "opencode-cli"
const methodID = Integration.MethodID.make("device")
const RemoteResponse = Schema.Struct({ config: ConfigV1.Info })
// Console's wire contract is independent of the local opencode.json format.
const RemoteApi = Schema.Struct({
id: Schema.optional(Model.ID),
type: Schema.Literal("aisdk"),
package: Schema.String,
url: Schema.optional(Schema.String),
settings: Schema.optional(Schema.Record(Schema.String, Schema.Json)),
})
const RemoteRequest = Schema.Struct({
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
body: Schema.optional(Schema.Record(Schema.String, Schema.Json)),
})
const RemoteResponse = Schema.Struct({
providers: Schema.Record(
Schema.String,
Schema.Struct({
name: Schema.optional(Schema.String),
api: Schema.optional(RemoteApi),
request: Schema.optional(RemoteRequest),
models: Schema.optional(
Schema.Record(
Schema.String,
Schema.Struct({
family: Schema.optional(Model.Family),
name: Schema.optional(Schema.String),
api: Schema.optional(RemoteApi),
capabilities: Schema.optional(Model.Capabilities),
request: Schema.optional(RemoteRequest),
variants: Schema.optional(Schema.Array(Schema.Struct({ id: Model.VariantID, ...RemoteRequest.fields }))),
cost: Schema.optional(
Schema.Array(
Schema.Struct({
...Model.Cost.fields,
cache: Schema.optional(
Schema.Struct({
read: Schema.optional(Money.USDPerMillionTokens),
write: Schema.optional(Money.USDPerMillionTokens),
}),
),
}),
),
),
disabled: Schema.optional(Schema.Boolean),
limit: Schema.optional(
Schema.Struct({
context: Schema.optional(Schema.Int),
input: Schema.optional(Schema.Int),
output: Schema.optional(Schema.Int),
}),
),
}),
),
),
}),
),
})
const Device = Schema.Struct({
device_code: Schema.String,
user_code: Schema.String,
@@ -89,7 +141,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
const http = yield* HttpClient.HttpClient
const loading = Semaphore.makeUnsafe(1)
let connected = false
let providers: typeof ConfigV1.Info.Type.provider | undefined
let providers: typeof RemoteResponse.Type.providers | undefined
const load = Effect.fn("OpencodePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("opencode")
@@ -120,56 +172,62 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
catalog.provider.update(providerID, (provider) => {
provider.integrationID = Integration.ID.make("opencode")
if (item.name !== undefined) provider.name = item.name
provider.package = item.npm ? Provider.aisdk(item.npm) : ""
if (item.api !== undefined) provider.package = Provider.aisdk(item.api.package)
provider.settings = {
...provider.settings,
...withoutCredentials(item.options),
...(item.api ? { baseURL: item.api } : {}),
...withoutCredentials(item.api?.settings),
...(item.api?.url !== undefined ? { baseURL: item.api.url } : {}),
}
provider.headers = { ...provider.headers, ...item.options?.headers }
if (item.request?.headers !== undefined)
provider.headers = Provider.mergeHeaders(provider.headers, item.request.headers)
if (item.request?.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.request.body)
})
for (const [modelID, config] of Object.entries(item.models ?? {})) {
catalog.model.update(providerID, modelID, (model) => {
if (config.family !== undefined) model.family = Model.Family.make(config.family)
if (config.name !== undefined) model.name = config.name
if (config.id !== undefined) model.modelID = Model.ID.make(config.id)
model.compatibility = Model.compatibility(config.interleaved) ?? model.compatibility
if (config.provider !== undefined) {
model.package = config.provider.npm ? Provider.aisdk(config.provider.npm) : undefined
if (config.provider.api) model.settings = { ...model.settings, baseURL: config.provider.api }
}
if (config.tool_call !== undefined) model.capabilities.tools = config.tool_call
if (config.modalities?.input !== undefined) model.capabilities.input = [...config.modalities.input]
if (config.modalities?.output !== undefined) model.capabilities.output = [...config.modalities.output]
model.headers = { ...model.headers, ...config.headers }
model.settings = { ...model.settings, ...ConfigProviderOptionsV1.model(withoutCredentials(config.options)) }
if (config.variants !== undefined) {
model.variants ??= []
for (const [id, options] of Object.entries(config.variants)) {
const variantID = Model.VariantID.make(id)
let existing = model.variants.find((item) => item.id === variantID)
if (!existing) {
existing = { id: variantID }
model.variants.push(existing)
}
existing.headers = { ...existing.headers, ...options.headers }
existing.settings = {
...existing.settings,
...ConfigProviderOptionsV1.model(withoutCredentials(options)),
}
if (config.api !== undefined) {
if (config.api.id !== undefined) model.modelID = config.api.id
model.package = Provider.aisdk(config.api.package)
model.settings = {
...model.settings,
...withoutCredentials(config.api.settings),
...(config.api.url !== undefined ? { baseURL: config.api.url } : {}),
}
}
if (config.release_date !== undefined) {
const released = Date.parse(config.release_date)
model.time.released = Number.isFinite(released) ? released : 0
if (config.capabilities !== undefined) {
model.capabilities.tools = config.capabilities.tools
model.capabilities.input = [...config.capabilities.input]
model.capabilities.output = [...config.capabilities.output]
}
if (config.request?.headers !== undefined)
model.headers = Provider.mergeHeaders(model.headers, config.request.headers)
if (config.request?.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.request.body)
if (config.variants !== undefined) {
model.variants ??= []
for (const variant of config.variants) {
let existing = model.variants.find((item) => item.id === variant.id)
if (!existing) {
existing = { id: variant.id }
model.variants.push(existing)
}
if (variant.headers !== undefined)
existing.headers = Provider.mergeHeaders(existing.headers, variant.headers)
if (variant.body !== undefined) existing.body = Provider.mergeOverlay(existing.body, variant.body)
}
}
if (config.cost !== undefined) {
model.cost = remoteCost(config.cost)
model.cost = config.cost.map((cost) => ({
...cost,
cache: {
read: cost.cache?.read ?? Money.USDPerMillionTokens.zero,
write: cost.cache?.write ?? Money.USDPerMillionTokens.zero,
},
}))
}
model.status = config.status ?? "active"
model.enabled = config.status !== "deprecated"
if (config.limit !== undefined) model.limit = { ...config.limit }
if (config.disabled !== undefined) model.enabled = !config.disabled
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
})
}
}
@@ -208,7 +266,7 @@ function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
const token = value.type === "oauth" ? value.access : value.key
return http
.execute(
HttpClientRequest.get(`${server}/api/config`).pipe(
HttpClientRequest.get(`${server}/api/v2/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(token),
HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}),
@@ -219,7 +277,7 @@ function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
if (response.status === 404) return Effect.undefined
return HttpClientResponse.filterStatusOk(response).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
Effect.map((remote) => remote.config.provider),
Effect.map((remote) => remote.providers),
)
}),
)
@@ -242,30 +300,6 @@ function normalizeServer(input: unknown) {
})
}
function remoteCost(input: NonNullable<(typeof ConfigProviderV1.Model.Type)["cost"]>) {
const base = {
input: Money.USDPerMillionTokens.make(input.input),
output: Money.USDPerMillionTokens.make(input.output),
cache: {
read: Money.USDPerMillionTokens.make(input.cache_read ?? 0),
write: Money.USDPerMillionTokens.make(input.cache_write ?? 0),
},
}
if (!input.context_over_200k) return [base]
return [
base,
{
tier: { type: "context" as const, size: 200_000 },
input: Money.USDPerMillionTokens.make(input.context_over_200k.input),
output: Money.USDPerMillionTokens.make(input.context_over_200k.output),
cache: {
read: Money.USDPerMillionTokens.make(input.context_over_200k.cache_read ?? 0),
write: Money.USDPerMillionTokens.make(input.context_over_200k.cache_write ?? 0),
},
},
]
}
function poll(http: HttpClient.HttpClient, server: string, deviceCode: string, interval: Duration.Duration) {
const loop = (wait: Duration.Duration): Effect.Effect<Credential.OAuth, unknown> =>
Effect.gen(function* () {
@@ -210,155 +210,265 @@ describe("OpencodePlugin", () => {
}),
)
it.live("loads providers and models from the connected OpenCode server", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const authorization: Array<string | null> = []
return {
authorization,
server: Bun.serve({
port: 0,
fetch: (request) => {
authorization.push(request.headers.get("authorization"))
const origin = new URL(request.url).origin
return Response.json({
config: {
enterprise: { url: origin },
provider: {
for (const type of ["key", "oauth"] as const) {
it.live(`loads Console V2 providers and models with ${type} credentials`, () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const authorization: Array<string | null> = []
const requests: Array<{ method: string; path: string; org: string | null }> = []
return {
authorization,
requests,
server: Bun.serve({
port: 0,
fetch: (request) => {
authorization.push(request.headers.get("authorization"))
requests.push({
method: request.method,
path: new URL(request.url).pathname,
org: request.headers.get("x-org-id"),
})
const origin = new URL(request.url).origin
return Response.json({
providers: {
remote: {
name: "Remote",
npm: "@ai-sdk/openai-compatible",
api: `${origin}/v1`,
api: {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: `${origin}/v1`,
settings: {
apiKey: "{env:REMOTE_API_KEY}",
headers: { "x-settings-secret": "provider-secret" },
custom: "value",
},
},
env: ["REMOTE_API_KEY"],
options: {
apiKey: "{env:REMOTE_API_KEY}",
headers: { "x-org-id": "org" },
custom: "value",
request: {
headers: { "x-org-id": "org", "x-shared": "provider" },
body: { provider: true, shared: "provider" },
},
models: {
model: {
name: "Remote Model",
family: "remote",
release_date: "2026-01-02",
tool_call: true,
modalities: { input: ["text", "image"], output: ["text"] },
options: { apiKey: "model-secret", temperature: 0.5 },
variants: { high: { apiKey: "variant-secret", temperature: 0.2 } },
cost: { input: 1, output: 2, cache_read: 0.1 },
api: {
id: "upstream-model",
type: "aisdk",
package: "@ai-sdk/openai-compatible",
settings: {
apiKey: "model-secret",
headers: { "x-settings-secret": "model-secret" },
temperature: 0.5,
},
},
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
request: {
headers: { "x-model": "true", "x-shared": "model" },
body: { model: true, shared: "model" },
},
variants: [
{
id: "high",
headers: { "x-variant": "high", "x-shared": "remote" },
body: { temperature: 0.2, shared: "remote" },
},
{ id: "low", body: { temperature: 0.8 } },
],
cost: [
{ input: 1, output: 2, cache: { read: 0.1 } },
{ tier: { type: "context", size: 250_000 }, input: 3, output: 4 },
],
limit: { context: 1000, output: 100 },
},
override: {
name: "Override",
provider: { npm: "@ai-sdk/anthropic", api: `${origin}/anthropic` },
api: { type: "aisdk", package: "@ai-sdk/anthropic", url: `${origin}/anthropic` },
limit: { input: 500 },
},
disabled: { name: "Disabled", status: "deprecated" },
disabled: { name: "Disabled", disabled: true },
},
},
},
},
})
},
}),
}
}),
({ authorization, server }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((draft) => {
draft.provider.update(Provider.ID.make("remote"), () => {})
draft.model.update(Provider.ID.make("remote"), Model.ID.make("model"), (model) => {
model.variants = [
{
id: Model.VariantID.make("custom"),
settings: {},
headers: { "x-custom": "true" },
body: { custom: true },
},
]
})
draft.model.update(Provider.ID.make("remote"), Model.ID.make("stale"), () => {})
})
const initial = yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: "secret",
metadata: { server: server.url.origin },
})
},
}),
})
yield* addPlugin()
expect(authorization).toEqual(["Bearer secret"])
const provider = required(yield* catalog.provider.get(Provider.ID.make("remote")))
expect(provider).toMatchObject({
name: "Remote",
integrationID: "opencode",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: `${server.url.origin}/v1`, custom: "value" },
headers: { "x-org-id": "org" },
})
expect(yield* (yield* Integration.Service).get(Integration.ID.make("remote"))).toBeUndefined()
const model = required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("model")))
expect(model).toMatchObject({
name: "Remote Model",
family: "remote",
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }],
limit: { context: 1000, output: 100 },
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: `${server.url.origin}/v1`, custom: "value", temperature: 0.5 },
headers: { "x-org-id": "org" },
})
const override = required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("override")))
expect(override.package).toBe(Provider.aisdk("@ai-sdk/anthropic"))
expect(override.settings?.baseURL).toBe(`${server.url.origin}/anthropic`)
expect(model.variants).toEqual([
{
id: Model.VariantID.make("custom"),
settings: {},
headers: { "x-custom": "true" },
body: { custom: true },
},
{
id: Model.VariantID.make("high"),
settings: { temperature: 0.2 },
headers: {},
},
])
expect(
required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("disabled"))).enabled,
).toBe(false)
expect(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("stale"))).toBeDefined()
yield* credentials.update(initial.id, { label: "Renamed" })
yield* Effect.yieldNow
expect(authorization).toEqual(["Bearer secret"])
const replacement = yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: "replacement",
metadata: { server: server.url.origin },
}),
})
yield* eventually(
Effect.sync(() => authorization.length),
(count) => count === 2,
)
expect(authorization).toEqual(["Bearer secret", "Bearer replacement"])
yield* credentials.remove(initial.id)
yield* Effect.yieldNow
expect(authorization).toEqual(["Bearer secret", "Bearer replacement"])
expect((yield* credentials.list(Integration.ID.make("opencode"))).at(-1)?.id).toBe(replacement.id)
}
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
({ authorization, requests, server }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((draft) => {
draft.provider.update(Provider.ID.make("remote"), () => {})
draft.model.update(Provider.ID.make("remote"), Model.ID.make("model"), (model) => {
model.variants = [
{
id: Model.VariantID.make("custom"),
settings: {},
headers: { "x-custom": "true" },
body: { custom: true },
},
{
id: Model.VariantID.make("high"),
settings: { local: true },
headers: { "x-local": "true", "x-shared": "local" },
body: { local: true, shared: "local" },
},
]
})
draft.model.update(Provider.ID.make("remote"), Model.ID.make("stale"), () => {})
})
const initial = yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value:
type === "key"
? Credential.Key.make({
type: "key",
key: "secret",
metadata: { server: `${server.url.origin}/console`, orgID: "org" },
})
: Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access: "secret",
refresh: "refresh",
expires: Date.now() + 600_000,
metadata: { server: `${server.url.origin}/console`, orgID: "org" },
}),
})
yield* addPlugin()
expect(authorization).toEqual(["Bearer secret"])
expect(requests).toEqual([{ method: "GET", path: "/console/api/v2/config", org: "org" }])
const provider = required(yield* catalog.provider.get(Provider.ID.make("remote")))
expect(provider).toMatchObject({
name: "Remote",
integrationID: "opencode",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: `${server.url.origin}/v1`, custom: "value" },
headers: { "x-org-id": "org", "x-shared": "provider" },
body: { provider: true, shared: "provider" },
})
expect(provider.settings).toEqual({ baseURL: `${server.url.origin}/v1`, custom: "value" })
expect(yield* (yield* Integration.Service).get(Integration.ID.make("remote"))).toBeUndefined()
const model = required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("model")))
expect(model).toMatchObject({
name: "Remote Model",
modelID: "upstream-model",
family: "remote",
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [
{ input: 1, output: 2, cache: { read: 0.1, write: 0 } },
{ tier: { type: "context", size: 250_000 }, input: 3, output: 4, cache: { read: 0, write: 0 } },
],
limit: { context: 1000, output: 100 },
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: `${server.url.origin}/v1`, custom: "value", temperature: 0.5 },
headers: { "x-org-id": "org", "x-model": "true", "x-shared": "model" },
body: { provider: true, model: true, shared: "model" },
})
expect(model.settings).toEqual({ baseURL: `${server.url.origin}/v1`, custom: "value", temperature: 0.5 })
const override = required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("override")))
expect(override.package).toBe(Provider.aisdk("@ai-sdk/anthropic"))
expect(override.settings?.baseURL).toBe(`${server.url.origin}/anthropic`)
expect(override.limit).toEqual({ context: 200_000, input: 500, output: 32_000 })
expect(model.variants).toEqual([
{
id: Model.VariantID.make("custom"),
settings: {},
headers: { "x-custom": "true" },
body: { custom: true },
},
{
id: Model.VariantID.make("high"),
settings: { local: true },
headers: { "x-local": "true", "x-variant": "high", "x-shared": "remote" },
body: { local: true, temperature: 0.2, shared: "remote" },
},
{ id: Model.VariantID.make("low"), body: { temperature: 0.8 } },
])
expect(
required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("disabled"))).enabled,
).toBe(false)
expect(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("stale"))).toBeDefined()
yield* credentials.update(initial.id, { label: "Renamed" })
yield* Effect.yieldNow
expect(authorization).toEqual(["Bearer secret"])
const replacement = yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: "replacement",
metadata: { server: `${server.url.origin}/console`, orgID: "org" },
}),
})
yield* eventually(
Effect.sync(() => authorization.length),
(count) => count === 2,
)
expect(authorization).toEqual(["Bearer secret", "Bearer replacement"])
expect(requests).toEqual([
{ method: "GET", path: "/console/api/v2/config", org: "org" },
{ method: "GET", path: "/console/api/v2/config", org: "org" },
])
yield* credentials.remove(initial.id)
yield* Effect.yieldNow
expect(authorization).toEqual(["Bearer secret", "Bearer replacement"])
expect((yield* credentials.list(Integration.ID.make("opencode"))).at(-1)?.id).toBe(replacement.id)
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
}
for (const status of [404, 503]) {
it.live(`keeps the catalog when Console V2 config returns ${status}`, () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const requests: string[] = []
return {
requests,
server: Bun.serve({
port: 0,
fetch: (request) => {
requests.push(`${request.method} ${new URL(request.url).pathname}`)
return Response.json({ providers: { remote: { name: "Not loaded" } } }, { status })
},
}),
}
}),
({ requests, server }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((draft) => {
draft.provider.update(Provider.ID.make("existing"), (provider) => {
provider.name = "Existing"
})
})
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: "secret",
metadata: { server: `${server.url.origin}/console` },
}),
})
yield* addPlugin()
expect(requests).toEqual(["GET /console/api/v2/config"])
expect(yield* catalog.provider.get(Provider.ID.make("remote"))).toBeUndefined()
expect(required(yield* catalog.provider.get(Provider.ID.make("existing"))).name).toBe("Existing")
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
}
it.effect("uses a public key and disables paid models without credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () =>