mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 18:26:09 +00:00
fix(core): refresh console auth before catalog load
This commit is contained in:
@@ -168,6 +168,12 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
readonly resolve: (
|
||||
connection: IntegrationConnection.Info,
|
||||
) => Effect.Effect<Credential.Value | undefined, AuthorizationError>
|
||||
/** Resolves with a refresh implementation that may not be materialized in the registry yet. */
|
||||
readonly resolveWithRefresh: (
|
||||
connection: IntegrationConnection.Info,
|
||||
methodID: MethodID,
|
||||
refresh: NonNullable<OAuthImplementation["refresh"]>,
|
||||
) => Effect.Effect<Credential.Value | undefined, AuthorizationError>
|
||||
/** Runs a key method and stores the resulting credential. */
|
||||
readonly key: (input: {
|
||||
/** Integration receiving the credential. */
|
||||
@@ -656,6 +662,34 @@ const layer = Layer.effect(
|
||||
return CommandAttempt.make({ attemptID, time })
|
||||
})
|
||||
|
||||
const resolveConnection = Effect.fnUntraced(function* (
|
||||
connection: IntegrationConnection.Info,
|
||||
fallback?: {
|
||||
readonly methodID: MethodID
|
||||
readonly refresh: NonNullable<OAuthImplementation["refresh"]>
|
||||
},
|
||||
) {
|
||||
if (connection.type === "env") {
|
||||
const key = process.env[connection.name]
|
||||
return key ? Credential.Key.make({ type: "key", key }) : undefined
|
||||
}
|
||||
const credential = yield* credentials.get(connection.id)
|
||||
if (!credential) return undefined
|
||||
if (credential.value.type === "key") return credential.value
|
||||
const implementation = state
|
||||
.get()
|
||||
.integrations.get(credential.integrationID)
|
||||
?.implementations.get(credential.value.methodID)
|
||||
const refresh =
|
||||
implementation?.refresh ?? (fallback?.methodID === credential.value.methodID ? fallback.refresh : undefined)
|
||||
if (!refresh) return credential.value
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (credential.value.expires > now + Duration.toMillis(Duration.minutes(5))) return credential.value
|
||||
const value = yield* authorize(refresh(credential.value))
|
||||
yield* credentials.update(credential.id, { value })
|
||||
return value
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
@@ -675,25 +709,10 @@ const layer = Layer.effect(
|
||||
const entry = state.get().integrations.get(id)
|
||||
return resolveConnections(entry, yield* credentials.list(id))[0]
|
||||
}),
|
||||
resolve: Effect.fn("Integration.connection.resolve")(function* (connection) {
|
||||
if (connection.type === "env") {
|
||||
const key = process.env[connection.name]
|
||||
return key ? Credential.Key.make({ type: "key", key }) : undefined
|
||||
}
|
||||
const credential = yield* credentials.get(connection.id)
|
||||
if (!credential) return undefined
|
||||
if (credential.value.type === "key") return credential.value
|
||||
const implementation = state
|
||||
.get()
|
||||
.integrations.get(credential.integrationID)
|
||||
?.implementations.get(credential.value.methodID)
|
||||
if (!implementation?.refresh) return credential.value
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (credential.value.expires > now + Duration.toMillis(Duration.minutes(5))) return credential.value
|
||||
const value = yield* authorize(implementation.refresh(credential.value))
|
||||
yield* credentials.update(credential.id, { value })
|
||||
return value
|
||||
}),
|
||||
resolve: Effect.fn("Integration.connection.resolve")((connection) => resolveConnection(connection)),
|
||||
resolveWithRefresh: Effect.fn("Integration.connection.resolveWithRefresh")((connection, methodID, refresh) =>
|
||||
resolveConnection(connection, { methodID, refresh }),
|
||||
),
|
||||
key: Effect.fn("Integration.connection.key")(function* (input) {
|
||||
const method = state
|
||||
.get()
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Duration, Effect, Schema, Semaphore, Stream } from "effect"
|
||||
import type { Scope } from "effect"
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import type { CredentialValue } from "@opencode-ai/sdk/v2/types"
|
||||
import type { CredentialOAuth, CredentialValue } from "@opencode-ai/sdk/v2/types"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { EventV2 } from "../../event"
|
||||
import { Credential } from "../../credential"
|
||||
@@ -79,24 +79,40 @@ function oauth(http: HttpClient.HttpClient) {
|
||||
label: (credential) => {
|
||||
return typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined
|
||||
},
|
||||
} satisfies IntegrationOAuthMethodRegistration
|
||||
} satisfies Integration.OAuthImplementation
|
||||
}
|
||||
|
||||
export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | Scope.Scope>({
|
||||
export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | Integration.Service | Scope.Scope>({
|
||||
id: "opencode.provider.opencode",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const events = yield* EventV2.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const integrations = yield* Integration.Service
|
||||
const method = oauth(http)
|
||||
const coreCredential = (credential: CredentialOAuth) =>
|
||||
Credential.OAuth.make({ ...credential, methodID: Integration.MethodID.make(credential.methodID) })
|
||||
const registration = {
|
||||
...method,
|
||||
refresh: (credential: CredentialOAuth) => method.refresh(coreCredential(credential)),
|
||||
label: (credential: CredentialOAuth) => method.label(coreCredential(credential)),
|
||||
} satisfies IntegrationOAuthMethodRegistration
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
let connected = false
|
||||
let remoteConfigRequired = false
|
||||
let providers: typeof ConfigV1.Info.Type.provider | undefined
|
||||
|
||||
const load = Effect.fn("OpencodePlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("opencode")
|
||||
const connection = yield* integrations.connection.active(Integration.ID.make("opencode"))
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
? yield* integrations.connection
|
||||
.resolveWithRefresh(connection, method.method.id, method.refresh)
|
||||
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
: undefined
|
||||
connected = connection !== undefined
|
||||
// Console credentials require its provider overlay; only legacy Zen `sk-` keys may use the models.dev fallback.
|
||||
remoteConfigRequired =
|
||||
connection !== undefined &&
|
||||
(credential === undefined || credential.type === "oauth" || credential.key.startsWith("oc_sk_"))
|
||||
providers = credential
|
||||
? yield* fetchProviders(http, credential).pipe(
|
||||
Effect.catch((cause) =>
|
||||
@@ -110,7 +126,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
|
||||
draft.update("opencode", (integration) => {
|
||||
integration.name = "OpenCode"
|
||||
})
|
||||
draft.method.update(oauth(http))
|
||||
draft.method.update(registration)
|
||||
draft.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } })
|
||||
})
|
||||
|
||||
@@ -175,6 +191,12 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
|
||||
|
||||
const item = catalog.provider.get(ProviderV2.ID.opencode)
|
||||
if (!item) return
|
||||
if (remoteConfigRequired && providers?.[ProviderV2.ID.opencode] === undefined) {
|
||||
catalog.provider.update(item.provider.id, (provider) => {
|
||||
provider.disabled = true
|
||||
})
|
||||
return
|
||||
}
|
||||
const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.settings?.apiKey)
|
||||
catalog.provider.update(item.provider.id, (provider) => {
|
||||
if (!hasKey) provider.settings = { ...provider.settings, apiKey: "public" }
|
||||
|
||||
@@ -188,6 +188,7 @@ function resourceMcpLayer(url: string, onFormCreated?: (form: Form.Info) => Effe
|
||||
connection: {
|
||||
active: unusedIntegration,
|
||||
resolve: unusedIntegration,
|
||||
resolveWithRefresh: unusedIntegration,
|
||||
key: unusedIntegration,
|
||||
update: unusedIntegration,
|
||||
remove: unusedIntegration,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
@@ -20,9 +21,11 @@ const addPlugin = Effect.fn(function* () {
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
const events = yield* EventV2.Service
|
||||
const integration = yield* Integration.Service
|
||||
yield* OpencodePlugin.effect(host).pipe(
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provideService(Integration.Service, integration),
|
||||
yield* State.batch(
|
||||
OpencodePlugin.effect(host).pipe(
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provideService(Integration.Service, integration),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -77,6 +80,50 @@ const cost = (input: number, output = 0) => [
|
||||
},
|
||||
]
|
||||
|
||||
const fableID = ModelV2.ID.make("claude-fable-5")
|
||||
|
||||
const seedFallback = Effect.fnUntraced(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const provider = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: { baseURL: "https://opencode.ai/zen/v1" },
|
||||
})
|
||||
const model = ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(provider.id, fableID),
|
||||
modelID: fableID,
|
||||
package: ProviderV2.aisdk("@ai-sdk/anthropic"),
|
||||
cost: cost(1, 1),
|
||||
})
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(provider.id, (current) => Object.assign(current, provider))
|
||||
draft.model.update(provider.id, model.id, (current) => Object.assign(current, model))
|
||||
})
|
||||
})
|
||||
|
||||
const remoteConfig = (origin: string) => ({
|
||||
config: {
|
||||
enterprise: { url: origin },
|
||||
provider: {
|
||||
opencode: {
|
||||
name: "OpenCode",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
api: `${origin}/inference/openai/v1`,
|
||||
env: ["OPENCODE_API_KEY"],
|
||||
options: { apiKey: "{env:OPENCODE_API_KEY}" },
|
||||
models: {
|
||||
"claude-fable-5": {
|
||||
name: "Claude Fable 5",
|
||||
provider: { npm: "@ai-sdk/anthropic", api: `${origin}/inference/anthropic/v1` },
|
||||
cost: { input: 1, output: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
describe("OpencodePlugin", () => {
|
||||
it.effect("registers account and service account methods", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -283,6 +330,176 @@ describe("OpencodePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("refreshes expired OAuth before loading the Console catalog", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const requests: Array<{ path: string; authorization: string | null }> = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
const path = new URL(request.url).pathname
|
||||
requests.push({ path, authorization: request.headers.get("authorization") })
|
||||
if (path === "/auth/device/token")
|
||||
return Response.json({
|
||||
access_token: "fresh-access",
|
||||
refresh_token: "fresh-refresh",
|
||||
expires_in: 3600,
|
||||
})
|
||||
if (path === "/api/config") {
|
||||
if (request.headers.get("authorization") !== "Bearer fresh-access")
|
||||
return new Response("Unauthorized", { status: 401 })
|
||||
return Response.json(remoteConfig(new URL(request.url).origin))
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
})
|
||||
return { requests, server }
|
||||
}),
|
||||
({ requests, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
yield* seedFallback()
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
access: "expired-access",
|
||||
refresh: "expired-refresh",
|
||||
expires: 1,
|
||||
metadata: { server: server.url.origin },
|
||||
}),
|
||||
})
|
||||
|
||||
yield* addPlugin()
|
||||
|
||||
expect(requests).toEqual([
|
||||
{ path: "/auth/device/token", authorization: null },
|
||||
{ path: "/api/config", authorization: "Bearer fresh-access" },
|
||||
])
|
||||
expect((yield* credentials.list(Integration.ID.make("opencode")))[0]?.value).toMatchObject({
|
||||
type: "oauth",
|
||||
access: "fresh-access",
|
||||
refresh: "fresh-refresh",
|
||||
})
|
||||
expect(
|
||||
required(yield* (yield* Catalog.Service).model.get(ProviderV2.ID.opencode, fableID)).settings?.baseURL,
|
||||
).toBe(`${server.url.origin}/inference/anthropic/v1`)
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not expose the legacy fallback when Console OAuth config is unavailable", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
port: 0,
|
||||
fetch: () => new Response("Unavailable", { status: 503 }),
|
||||
}),
|
||||
),
|
||||
(server) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* seedFallback()
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60 * 60 * 1000,
|
||||
metadata: { server: server.url.origin },
|
||||
}),
|
||||
})
|
||||
|
||||
yield* addPlugin()
|
||||
|
||||
expect((yield* catalog.provider.available()).some((provider) => provider.id === ProviderV2.ID.opencode)).toBe(
|
||||
false,
|
||||
)
|
||||
expect(
|
||||
(yield* catalog.model.available()).some(
|
||||
(model) => model.providerID === ProviderV2.ID.opencode && model.id === fableID,
|
||||
),
|
||||
).toBe(false)
|
||||
}),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not expose the legacy fallback when Console service-account config is unavailable", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
port: 0,
|
||||
fetch: () => new Response("Unavailable", { status: 503 }),
|
||||
}),
|
||||
),
|
||||
(server) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* seedFallback()
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({
|
||||
type: "key",
|
||||
key: "oc_sk_0123456789ab_secret",
|
||||
metadata: { server: server.url.origin },
|
||||
}),
|
||||
})
|
||||
|
||||
yield* addPlugin()
|
||||
|
||||
expect((yield* catalog.provider.available()).some((provider) => provider.id === ProviderV2.ID.opencode)).toBe(
|
||||
false,
|
||||
)
|
||||
expect(
|
||||
(yield* catalog.model.available()).some(
|
||||
(model) => model.providerID === ProviderV2.ID.opencode && model.id === fableID,
|
||||
),
|
||||
).toBe(false)
|
||||
}),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("keeps the legacy fallback for an API key when remote provider config is unavailable", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
port: 0,
|
||||
fetch: () => new Response("Unauthorized", { status: 401 }),
|
||||
}),
|
||||
),
|
||||
(server) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* seedFallback()
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({
|
||||
type: "key",
|
||||
key: "sk-legacy-key",
|
||||
metadata: { server: server.url.origin },
|
||||
}),
|
||||
})
|
||||
|
||||
yield* addPlugin()
|
||||
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).disabled).toBeUndefined()
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, fableID)).settings?.baseURL).toBe(
|
||||
"https://opencode.ai/zen/v1",
|
||||
)
|
||||
}),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses a public key and disables paid models without credentials", () =>
|
||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
Reference in New Issue
Block a user