refactor(core): derive catalog availability from integrations (#32272)
This commit is contained in:
parent
4810df0a71
commit
0cf3ee4406
29 changed files with 622 additions and 509 deletions
|
|
@ -10,8 +10,7 @@ import { Location } from "./location"
|
||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
import { Policy } from "./policy"
|
import { Policy } from "./policy"
|
||||||
import { State } from "./state"
|
import { State } from "./state"
|
||||||
import { Credential } from "./credential"
|
import { Integration } from "./integration"
|
||||||
import { IntegrationSchema } from "./integration/schema"
|
|
||||||
|
|
||||||
export type ProviderRecord = {
|
export type ProviderRecord = {
|
||||||
provider: ProviderV2.Info
|
provider: ProviderV2.Info
|
||||||
|
|
@ -35,12 +34,7 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundErr
|
||||||
export const PolicyActions = Schema.Literals(["provider.use"])
|
export const PolicyActions = Schema.Literals(["provider.use"])
|
||||||
|
|
||||||
export const Event = {
|
export const Event = {
|
||||||
ModelUpdated: EventV2.define({
|
Updated: EventV2.define({ type: "catalog.updated", schema: {} }),
|
||||||
type: "catalog.model.updated",
|
|
||||||
schema: {
|
|
||||||
model: ModelV2.Info,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Data = {
|
type Data = {
|
||||||
|
|
@ -96,26 +90,21 @@ export const layer = Layer.effect(
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const policy = yield* Policy.Service
|
const policy = yield* Policy.Service
|
||||||
const credentials = yield* Credential.Service
|
const integrations = yield* Integration.Service
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
|
|
||||||
const project = (provider: ProviderV2.Info, active: Map<IntegrationSchema.ID, Credential.Stored>) => {
|
const available = (
|
||||||
const credential = active.get(IntegrationSchema.ID.make(provider.id))
|
provider: ProviderV2.Info,
|
||||||
if (!credential) return provider
|
integration: Integration.Info | undefined,
|
||||||
const body = { ...provider.request.body }
|
connected: boolean,
|
||||||
if (credential.value.type === "key") {
|
) => {
|
||||||
body.apiKey = credential.value.key
|
if (provider.disabled) return false
|
||||||
Object.assign(body, credential.value.metadata ?? {})
|
if (typeof provider.request.body.apiKey === "string") return true
|
||||||
}
|
if (connected) return true
|
||||||
if (credential.value.type === "oauth") body.apiKey = credential.value.access
|
return !integration
|
||||||
return new ProviderV2.Info({
|
|
||||||
...provider,
|
|
||||||
enabled: { via: "credential", credentialID: credential.id },
|
|
||||||
request: { ...provider.request, body },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolve = (model: ModelV2.Info, provider: ProviderV2.Info) => {
|
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
|
||||||
const api =
|
const api =
|
||||||
model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0
|
model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0
|
||||||
? { ...provider.api, id: model.api.id }
|
? { ...provider.api, id: model.api.id }
|
||||||
|
|
@ -203,18 +192,16 @@ export const layer = Layer.effect(
|
||||||
},
|
},
|
||||||
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) {
|
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) {
|
||||||
if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid)
|
if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid)
|
||||||
if (!policy.hasStatements()) return
|
if (policy.hasStatements()) {
|
||||||
for (const record of [...catalog.provider.list()]) {
|
for (const record of [...catalog.provider.list()]) {
|
||||||
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
|
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
|
||||||
catalog.provider.remove(record.provider.id)
|
catalog.provider.remove(record.provider.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
yield* events.publish(Event.Updated, {})
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const active = Effect.fn("CatalogV2.active")(function* () {
|
|
||||||
return new Map((yield* credentials.all()).map((credential) => [credential.integrationID, credential]))
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* events.subscribe(PluginV2.Event.Added).pipe(
|
yield* events.subscribe(PluginV2.Event.Added).pipe(
|
||||||
// Plugin registries are location scoped even though the event bus is process scoped.
|
// Plugin registries are location scoped even though the event bus is process scoped.
|
||||||
Stream.filter(
|
Stream.filter(
|
||||||
|
|
@ -233,18 +220,23 @@ export const layer = Layer.effect(
|
||||||
provider: {
|
provider: {
|
||||||
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
|
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
|
||||||
const record = yield* getRecord(providerID)
|
const record = yield* getRecord(providerID)
|
||||||
return project(record.provider, yield* active())
|
return record.provider
|
||||||
}),
|
}),
|
||||||
|
|
||||||
all: Effect.fn("CatalogV2.provider.all")(function* () {
|
all: Effect.fn("CatalogV2.provider.all")(function* () {
|
||||||
const credentials = yield* active()
|
return Array.fromIterable(state.get().providers.values()).map((record) => record.provider)
|
||||||
return Array.fromIterable(state.get().providers.values()).map((record) =>
|
|
||||||
project(record.provider, credentials),
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
available: Effect.fn("CatalogV2.provider.available")(function* () {
|
available: Effect.fn("CatalogV2.provider.available")(function* () {
|
||||||
return (yield* result.provider.all()).filter((provider) => provider.enabled)
|
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
|
||||||
|
const connections = yield* integrations.connection.list()
|
||||||
|
return (yield* result.provider.all()).filter((provider) =>
|
||||||
|
available(
|
||||||
|
provider,
|
||||||
|
active.get(Integration.ID.make(provider.id)),
|
||||||
|
connections.has(Integration.ID.make(provider.id)),
|
||||||
|
),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -253,33 +245,32 @@ export const layer = Layer.effect(
|
||||||
const record = yield* getRecord(providerID)
|
const record = yield* getRecord(providerID)
|
||||||
const model = record.models.get(modelID)
|
const model = record.models.get(modelID)
|
||||||
if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
|
if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
|
||||||
return resolve(model, project(record.provider, yield* active()))
|
return projectModel(model, record.provider)
|
||||||
}),
|
}),
|
||||||
|
|
||||||
all: Effect.fn("CatalogV2.model.all")(function* () {
|
all: Effect.fn("CatalogV2.model.all")(function* () {
|
||||||
const credentials = yield* active()
|
|
||||||
return pipe(
|
return pipe(
|
||||||
Array.fromIterable(state.get().providers.values()),
|
Array.fromIterable(state.get().providers.values()),
|
||||||
Array.flatMap((record) => {
|
Array.flatMap((record) => {
|
||||||
const provider = project(record.provider, credentials)
|
return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider))
|
||||||
return Array.fromIterable(record.models.values()).map((model) => resolve(model, provider))
|
|
||||||
}),
|
}),
|
||||||
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
|
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
|
||||||
available: Effect.fn("CatalogV2.model.available")(function* () {
|
available: Effect.fn("CatalogV2.model.available")(function* () {
|
||||||
const providers = new Map((yield* result.provider.all()).map((provider) => [provider.id, provider]))
|
const providers = new Set((yield* result.provider.available()).map((provider) => provider.id))
|
||||||
return (yield* result.model.all()).filter(
|
return (yield* result.model.all()).filter((model) => providers.has(model.providerID) && model.enabled)
|
||||||
(model) => providers.get(model.providerID)?.enabled !== false && model.enabled,
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
default: Effect.fn("CatalogV2.model.default")(function* () {
|
default: Effect.fn("CatalogV2.model.default")(function* () {
|
||||||
const defaultModel = state.get().defaultModel
|
const defaultModel = state.get().defaultModel
|
||||||
if (defaultModel) {
|
if (defaultModel) {
|
||||||
const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option)
|
const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option)
|
||||||
if (Option.isSome(provider) && provider.value.enabled !== false) {
|
if (
|
||||||
|
Option.isSome(provider) &&
|
||||||
|
(yield* result.provider.available()).some((item) => item.id === provider.value.id)
|
||||||
|
) {
|
||||||
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
|
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
|
||||||
if (Option.isSome(model) && model.value.enabled) return model
|
if (Option.isSome(model) && model.value.enabled) return model
|
||||||
}
|
}
|
||||||
|
|
@ -295,11 +286,11 @@ export const layer = Layer.effect(
|
||||||
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
|
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
|
||||||
const record = state.get().providers.get(providerID)
|
const record = state.get().providers.get(providerID)
|
||||||
if (!record) return Option.none<ModelV2.Info>()
|
if (!record) return Option.none<ModelV2.Info>()
|
||||||
const provider = project(record.provider, yield* active())
|
const provider = record.provider
|
||||||
|
|
||||||
if (providerID === ProviderV2.ID.opencode) {
|
if (providerID === ProviderV2.ID.opencode) {
|
||||||
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
|
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
|
||||||
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano, provider))
|
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(projectModel(gpt5Nano, provider))
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidates = pipe(
|
const candidates = pipe(
|
||||||
|
|
@ -327,7 +318,7 @@ export const layer = Layer.effect(
|
||||||
return pipe(
|
return pipe(
|
||||||
items,
|
items,
|
||||||
Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number),
|
Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number),
|
||||||
Array.map((item) => resolve(item.model, provider)),
|
Array.map((item) => projectModel(item.model, provider)),
|
||||||
Array.head,
|
Array.head,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -348,6 +339,7 @@ export const layer = Layer.effect(
|
||||||
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
|
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
|
||||||
|
|
||||||
export const locationLayer = layer.pipe(
|
export const locationLayer = layer.pipe(
|
||||||
|
Layer.provideMerge(Integration.locationLayer),
|
||||||
Layer.provideMerge(PluginV2.locationLayer),
|
Layer.provideMerge(PluginV2.locationLayer),
|
||||||
Layer.provideMerge(Policy.locationLayer),
|
Layer.provideMerge(Policy.locationLayer),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ export * as ConfigProviderPlugin from "./provider"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Catalog } from "../../catalog"
|
import { Catalog } from "../../catalog"
|
||||||
import { Config } from "../../config"
|
import { Config } from "../../config"
|
||||||
|
import { Integration } from "../../integration"
|
||||||
import { ModelV2 } from "../../model"
|
import { ModelV2 } from "../../model"
|
||||||
import { ModelRequest } from "../../model-request"
|
import { ModelRequest } from "../../model-request"
|
||||||
import { PluginV2 } from "../../plugin"
|
import { PluginV2 } from "../../plugin"
|
||||||
|
|
@ -13,9 +14,33 @@ export const Plugin = PluginV2.define({
|
||||||
effect: Effect.gen(function* () {
|
effect: Effect.gen(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
|
const integrations = yield* Integration.Service
|
||||||
const transform = yield* catalog.transform()
|
const transform = yield* catalog.transform()
|
||||||
|
const integrationTransform = yield* integrations.transform()
|
||||||
const entries = yield* config.entries()
|
const entries = yield* config.entries()
|
||||||
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
|
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
|
||||||
|
const configuredIntegrations = new Set(
|
||||||
|
files.flatMap((file) =>
|
||||||
|
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
yield* integrationTransform((integrations) => {
|
||||||
|
for (const file of files) {
|
||||||
|
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||||
|
const integrationID = Integration.ID.make(id)
|
||||||
|
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
|
||||||
|
integrations.update(integrationID, (integration) => {
|
||||||
|
integration.name = item.name ?? integration.name
|
||||||
|
})
|
||||||
|
if (item.env !== undefined) {
|
||||||
|
integrations.method.update({
|
||||||
|
integrationID,
|
||||||
|
method: { type: "env", names: [...item.env] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
yield* transform((catalog) => {
|
yield* transform((catalog) => {
|
||||||
const configuredDefault = Config.latest(entries, "model")
|
const configuredDefault = Config.latest(entries, "model")
|
||||||
|
|
@ -28,8 +53,6 @@ export const Plugin = PluginV2.define({
|
||||||
const providerID = ProviderV2.ID.make(id)
|
const providerID = ProviderV2.ID.make(id)
|
||||||
catalog.provider.update(providerID, (provider) => {
|
catalog.provider.update(providerID, (provider) => {
|
||||||
if (item.name !== undefined) provider.name = item.name
|
if (item.name !== undefined) provider.name = item.name
|
||||||
if (item.env !== undefined) provider.env = [...item.env]
|
|
||||||
provider.enabled = { via: "custom", data: {} }
|
|
||||||
if (item.api !== undefined) provider.api = { ...item.api }
|
if (item.api !== undefined) provider.api = { ...item.api }
|
||||||
if (item.request !== undefined) {
|
if (item.request !== undefined) {
|
||||||
Object.assign(provider.request.headers, item.request.headers)
|
Object.assign(provider.request.headers, item.request.headers)
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ export interface Interface {
|
||||||
readonly all: () => Effect.Effect<Stored[]>
|
readonly all: () => Effect.Effect<Stored[]>
|
||||||
/** Returns stored credentials belonging to one integration. */
|
/** Returns stored credentials belonging to one integration. */
|
||||||
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Stored[]>
|
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Stored[]>
|
||||||
|
/** Returns one stored credential by ID. */
|
||||||
|
readonly get: (id: ID) => Effect.Effect<Stored | undefined>
|
||||||
/** Replaces any credential for an integration and returns the new record. */
|
/** Replaces any credential for an integration and returns the new record. */
|
||||||
readonly create: (input: {
|
readonly create: (input: {
|
||||||
readonly integrationID: IntegrationSchema.ID
|
readonly integrationID: IntegrationSchema.ID
|
||||||
|
|
@ -99,6 +101,10 @@ export const layer = Layer.effect(
|
||||||
return credential ? [credential] : []
|
return credential ? [credential] : []
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
get: Effect.fn("Credential.get")(function* (id) {
|
||||||
|
const row = yield* db.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get().pipe(Effect.orDie)
|
||||||
|
return row ? stored(row) : undefined
|
||||||
|
}),
|
||||||
create: Effect.fn("Credential.create")(function* (input) {
|
create: Effect.fn("Credential.create")(function* (input) {
|
||||||
const credential = new Stored({
|
const credential = new Stored({
|
||||||
id: ID.create(),
|
id: ID.create(),
|
||||||
|
|
|
||||||
|
|
@ -29,15 +29,16 @@ export const When = Schema.Struct({
|
||||||
}).annotate({ identifier: "Integration.When" })
|
}).annotate({ identifier: "Integration.When" })
|
||||||
export type When = typeof When.Type
|
export type When = typeof When.Type
|
||||||
|
|
||||||
export class TextPrompt extends Schema.Class<TextPrompt>("Integration.TextPrompt")({
|
export const TextPrompt = Schema.Struct({
|
||||||
type: Schema.Literal("text"),
|
type: Schema.Literal("text"),
|
||||||
key: Schema.String,
|
key: Schema.String,
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
placeholder: Schema.optional(Schema.String),
|
placeholder: Schema.optional(Schema.String),
|
||||||
when: Schema.optional(When),
|
when: Schema.optional(When),
|
||||||
}) {}
|
}).annotate({ identifier: "Integration.TextPrompt" })
|
||||||
|
export type TextPrompt = typeof TextPrompt.Type
|
||||||
|
|
||||||
export class SelectPrompt extends Schema.Class<SelectPrompt>("Integration.SelectPrompt")({
|
export const SelectPrompt = Schema.Struct({
|
||||||
type: Schema.Literal("select"),
|
type: Schema.Literal("select"),
|
||||||
key: Schema.String,
|
key: Schema.String,
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
|
|
@ -49,27 +50,31 @@ export class SelectPrompt extends Schema.Class<SelectPrompt>("Integration.Select
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
when: Schema.optional(When),
|
when: Schema.optional(When),
|
||||||
}) {}
|
}).annotate({ identifier: "Integration.SelectPrompt" })
|
||||||
|
export type SelectPrompt = typeof SelectPrompt.Type
|
||||||
|
|
||||||
export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type"))
|
export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type"))
|
||||||
export type Prompt = typeof Prompt.Type
|
export type Prompt = typeof Prompt.Type
|
||||||
|
|
||||||
export class OAuthMethod extends Schema.Class<OAuthMethod>("Integration.OAuthMethod")({
|
export const OAuthMethod = Schema.Struct({
|
||||||
id: MethodID,
|
id: MethodID,
|
||||||
type: Schema.Literal("oauth"),
|
type: Schema.Literal("oauth"),
|
||||||
label: Schema.String,
|
label: Schema.String,
|
||||||
prompts: Schema.optional(Schema.Array(Prompt)),
|
prompts: Schema.optional(Schema.Array(Prompt)),
|
||||||
}) {}
|
}).annotate({ identifier: "Integration.OAuthMethod" })
|
||||||
|
export type OAuthMethod = typeof OAuthMethod.Type
|
||||||
|
|
||||||
export class KeyMethod extends Schema.Class<KeyMethod>("Integration.KeyMethod")({
|
export const KeyMethod = Schema.Struct({
|
||||||
type: Schema.Literal("key"),
|
type: Schema.Literal("key"),
|
||||||
label: Schema.optional(Schema.String),
|
label: Schema.optional(Schema.String),
|
||||||
}) {}
|
}).annotate({ identifier: "Integration.KeyMethod" })
|
||||||
|
export type KeyMethod = typeof KeyMethod.Type
|
||||||
|
|
||||||
export class EnvMethod extends Schema.Class<EnvMethod>("Integration.EnvMethod")({
|
export const EnvMethod = Schema.Struct({
|
||||||
type: Schema.Literal("env"),
|
type: Schema.Literal("env"),
|
||||||
names: Schema.Array(Schema.String),
|
names: Schema.Array(Schema.String),
|
||||||
}) {}
|
}).annotate({ identifier: "Integration.EnvMethod" })
|
||||||
|
export type EnvMethod = typeof EnvMethod.Type
|
||||||
|
|
||||||
export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod]).pipe(Schema.toTaggedUnion("type"))
|
export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod]).pipe(Schema.toTaggedUnion("type"))
|
||||||
export type Method = typeof Method.Type
|
export type Method = typeof Method.Type
|
||||||
|
|
@ -197,7 +202,11 @@ export interface Interface {
|
||||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||||
/** Returns all integrations with their methods and current connections. */
|
/** Returns all integrations with their methods and current connections. */
|
||||||
readonly list: () => Effect.Effect<Info[]>
|
readonly list: () => Effect.Effect<Info[]>
|
||||||
readonly connect: {
|
readonly connection: {
|
||||||
|
/** Returns active connections for every registered or credential-backed integration. */
|
||||||
|
readonly list: () => Effect.Effect<Map<ID, IntegrationConnection.Info>>
|
||||||
|
/** Returns the active connection for one integration. */
|
||||||
|
readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
|
||||||
/** Runs a key method and stores the resulting credential. */
|
/** Runs a key method and stores the resulting credential. */
|
||||||
readonly key: (input: {
|
readonly key: (input: {
|
||||||
/** Integration receiving the credential. */
|
/** Integration receiving the credential. */
|
||||||
|
|
@ -218,6 +227,10 @@ export interface Interface {
|
||||||
/** User-facing label for the credential created on completion. */
|
/** User-facing label for the credential created on completion. */
|
||||||
readonly label?: string
|
readonly label?: string
|
||||||
}) => Effect.Effect<Attempt, AuthorizationError>
|
}) => Effect.Effect<Attempt, AuthorizationError>
|
||||||
|
/** Updates a stored credential exposed as a connection. */
|
||||||
|
readonly update: (credentialID: Credential.ID, updates: Partial<Pick<Credential.Stored, "label">>) => Effect.Effect<void>
|
||||||
|
/** Removes a stored credential connection. */
|
||||||
|
readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
readonly attempt: {
|
readonly attempt: {
|
||||||
/** Returns the current state of an OAuth attempt. */
|
/** Returns the current state of an OAuth attempt. */
|
||||||
|
|
@ -327,22 +340,29 @@ export const locationLayer = Layer.effect(
|
||||||
|
|
||||||
const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => {
|
const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => {
|
||||||
const connected = saved.map(
|
const connected = saved.map(
|
||||||
(credential) =>
|
(credential) => ({ type: "credential" as const, id: credential.id, label: credential.label }),
|
||||||
new IntegrationConnection.CredentialInfo({ type: "credential", id: credential.id, label: credential.label }),
|
|
||||||
)
|
)
|
||||||
const detected = entry.methods
|
const detected = entry.methods
|
||||||
.filter((method) => method.type === "env")
|
.filter((method) => method.type === "env")
|
||||||
.flatMap((method) => method.names.filter((name) => process.env[name]))
|
.flatMap((method) => method.names.filter((name) => process.env[name]))
|
||||||
.map(
|
.map((name) => ({ type: "env" as const, name }))
|
||||||
(name, index) =>
|
|
||||||
new IntegrationConnection.EnvInfo({
|
|
||||||
type: "env",
|
|
||||||
name,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return [...connected, ...detected]
|
return [...connected, ...detected]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeConnection = (
|
||||||
|
entry: Entry | undefined,
|
||||||
|
saved: readonly Credential.Stored[],
|
||||||
|
): IntegrationConnection.Info | undefined => {
|
||||||
|
const credential = saved.at(-1)
|
||||||
|
if (credential) return { type: "credential", id: credential.id, label: credential.label }
|
||||||
|
if (!entry) return
|
||||||
|
const name = entry.methods
|
||||||
|
.filter((method) => method.type === "env")
|
||||||
|
.flatMap((method) => method.names)
|
||||||
|
.find((name) => process.env[name])
|
||||||
|
if (name) return { type: "env", name }
|
||||||
|
}
|
||||||
|
|
||||||
const project = (entry: Entry, saved: readonly Credential.Stored[]) =>
|
const project = (entry: Entry, saved: readonly Credential.Stored[]) =>
|
||||||
new Info({
|
new Info({
|
||||||
id: entry.ref.id,
|
id: entry.ref.id,
|
||||||
|
|
@ -382,6 +402,7 @@ export const locationLayer = Layer.effect(
|
||||||
? new Credential.OAuth({ ...exit.value, methodID: result.methodID })
|
? new Credential.OAuth({ ...exit.value, methodID: result.methodID })
|
||||||
: exit.value,
|
: exit.value,
|
||||||
})
|
})
|
||||||
|
yield* events.publish(Event.Updated, {})
|
||||||
}
|
}
|
||||||
yield* close(result.scope)
|
yield* close(result.scope)
|
||||||
})
|
})
|
||||||
|
|
@ -421,8 +442,23 @@ export const locationLayer = Layer.effect(
|
||||||
}),
|
}),
|
||||||
)).toSorted((a, b) => a.name.localeCompare(b.name))
|
)).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||||
}),
|
}),
|
||||||
connect: {
|
connection: {
|
||||||
key: Effect.fn("Integration.connect.key")(function* (input) {
|
list: Effect.fn("Integration.connection.list")(function* () {
|
||||||
|
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
|
||||||
|
return new Map(
|
||||||
|
new Set([...state.get().integrations.keys(), ...saved.keys()])
|
||||||
|
.values()
|
||||||
|
.flatMap((id) => {
|
||||||
|
const connection = activeConnection(state.get().integrations.get(id), saved.get(id) ?? [])
|
||||||
|
return connection ? [[id, connection] as const] : []
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
|
||||||
|
const entry = state.get().integrations.get(id)
|
||||||
|
return activeConnection(entry, yield* credentials.list(id))
|
||||||
|
}),
|
||||||
|
key: Effect.fn("Integration.connection.key")(function* (input) {
|
||||||
const method = state
|
const method = state
|
||||||
.get()
|
.get()
|
||||||
.integrations.get(input.integrationID)
|
.integrations.get(input.integrationID)
|
||||||
|
|
@ -433,8 +469,9 @@ export const locationLayer = Layer.effect(
|
||||||
label: input.label,
|
label: input.label,
|
||||||
value: new Credential.Key({ type: "key", key: input.key }),
|
value: new Credential.Key({ type: "key", key: input.key }),
|
||||||
})
|
})
|
||||||
|
yield* events.publish(Event.Updated, {})
|
||||||
}),
|
}),
|
||||||
oauth: Effect.fn("Integration.connect.oauth")(function* (input) {
|
oauth: Effect.fn("Integration.connection.oauth")(function* (input) {
|
||||||
const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID)
|
const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID)
|
||||||
if (!method) {
|
if (!method) {
|
||||||
return yield* Effect.die(`OAuth method not found: ${input.integrationID}/${input.methodID}`)
|
return yield* Effect.die(`OAuth method not found: ${input.integrationID}/${input.methodID}`)
|
||||||
|
|
@ -474,6 +511,14 @@ export const locationLayer = Layer.effect(
|
||||||
time,
|
time,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) {
|
||||||
|
yield* credentials.update(credentialID, updates)
|
||||||
|
yield* events.publish(Event.Updated, {})
|
||||||
|
}),
|
||||||
|
remove: Effect.fn("Integration.connection.remove")(function* (credentialID) {
|
||||||
|
yield* credentials.remove(credentialID)
|
||||||
|
yield* events.publish(Event.Updated, {})
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
attempt: {
|
attempt: {
|
||||||
status: Effect.fn("Integration.attempt.status")(function* (attemptID) {
|
status: Effect.fn("Integration.attempt.status")(function* (attemptID) {
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,18 @@ export * as IntegrationConnection from "./connection"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { Credential } from "../credential"
|
import { Credential } from "../credential"
|
||||||
|
|
||||||
export class CredentialInfo extends Schema.Class<CredentialInfo>("Connection.CredentialInfo")({
|
export const CredentialInfo = Schema.Struct({
|
||||||
type: Schema.Literal("credential"),
|
type: Schema.Literal("credential"),
|
||||||
id: Credential.ID,
|
id: Credential.ID,
|
||||||
label: Schema.String,
|
label: Schema.String,
|
||||||
}) {}
|
}).annotate({ identifier: "Connection.CredentialInfo" })
|
||||||
|
export type CredentialInfo = typeof CredentialInfo.Type
|
||||||
|
|
||||||
export class EnvInfo extends Schema.Class<EnvInfo>("Connection.EnvInfo")({
|
export const EnvInfo = Schema.Struct({
|
||||||
type: Schema.Literal("env"),
|
type: Schema.Literal("env"),
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
}) {}
|
}).annotate({ identifier: "Connection.EnvInfo" })
|
||||||
|
export type EnvInfo = typeof EnvInfo.Type
|
||||||
|
|
||||||
export const Info = Schema.Union([CredentialInfo, EnvInfo])
|
export const Info = Schema.Union([CredentialInfo, EnvInfo])
|
||||||
.pipe(Schema.toTaggedUnion("type"))
|
.pipe(Schema.toTaggedUnion("type"))
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ import { AgentPlugin } from "./agent"
|
||||||
import { CommandPlugin } from "./command"
|
import { CommandPlugin } from "./command"
|
||||||
import { SkillPlugin } from "./skill"
|
import { SkillPlugin } from "./skill"
|
||||||
import { ConfigProviderPlugin } from "../config/plugin/provider"
|
import { ConfigProviderPlugin } from "../config/plugin/provider"
|
||||||
import { EnvPlugin } from "./env"
|
|
||||||
import { ModelsDevPlugin } from "./models-dev"
|
import { ModelsDevPlugin } from "./models-dev"
|
||||||
import { ProviderPlugins } from "./provider"
|
import { ProviderPlugins } from "./provider"
|
||||||
import { SkillV2 } from "../skill"
|
import { SkillV2 } from "../skill"
|
||||||
|
|
@ -99,7 +98,6 @@ export const layer = Layer.effect(
|
||||||
})
|
})
|
||||||
|
|
||||||
const boot = Effect.gen(function* () {
|
const boot = Effect.gen(function* () {
|
||||||
yield* add(EnvPlugin)
|
|
||||||
yield* add(AgentPlugin.Plugin)
|
yield* add(AgentPlugin.Plugin)
|
||||||
yield* add(CommandPlugin.Plugin)
|
yield* add(CommandPlugin.Plugin)
|
||||||
yield* add(SkillPlugin.Plugin)
|
yield* add(SkillPlugin.Plugin)
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { Effect } from "effect"
|
|
||||||
import { PluginV2 } from "../plugin"
|
|
||||||
|
|
||||||
export const EnvPlugin = PluginV2.define({
|
|
||||||
id: PluginV2.ID.make("env"),
|
|
||||||
effect: Effect.gen(function* () {
|
|
||||||
return {
|
|
||||||
"catalog.transform": Effect.fn(function* (evt) {
|
|
||||||
for (const item of evt.provider.list()) {
|
|
||||||
const key = item.provider.env.find((env) => process.env[env])
|
|
||||||
if (!key) continue
|
|
||||||
evt.provider.update(item.provider.id, (provider) => {
|
|
||||||
provider.enabled = {
|
|
||||||
via: "env",
|
|
||||||
name: key,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
@ -70,16 +70,11 @@ export const ModelsDevPlugin = PluginV2.define({
|
||||||
integrations.update(integrationID, (integration) => (integration.name = item.name))
|
integrations.update(integrationID, (integration) => (integration.name = item.name))
|
||||||
integrations.method.update({
|
integrations.method.update({
|
||||||
integrationID,
|
integrationID,
|
||||||
method: new Integration.KeyMethod({
|
method: { type: "key" },
|
||||||
type: "key",
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
integrations.method.update({
|
integrations.method.update({
|
||||||
integrationID,
|
integrationID,
|
||||||
method: new Integration.EnvMethod({
|
method: { type: "env", names: [...item.env] },
|
||||||
type: "env",
|
|
||||||
names: [...item.env],
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -88,7 +83,6 @@ export const ModelsDevPlugin = PluginV2.define({
|
||||||
const providerID = ProviderV2.ID.make(item.id)
|
const providerID = ProviderV2.ID.make(item.id)
|
||||||
catalog.provider.update(providerID, (provider) => {
|
catalog.provider.update(providerID, (provider) => {
|
||||||
provider.name = item.name
|
provider.name = item.name
|
||||||
provider.env = [...item.env]
|
|
||||||
provider.api = item.npm
|
provider.api = item.npm
|
||||||
? {
|
? {
|
||||||
type: "aisdk",
|
type: "aisdk",
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
import { Integration } from "../../integration"
|
||||||
import { PluginV2 } from "../../plugin"
|
import { PluginV2 } from "../../plugin"
|
||||||
|
|
||||||
export const LLMGatewayPlugin = PluginV2.define({
|
export const LLMGatewayPlugin = PluginV2.define({
|
||||||
id: PluginV2.ID.make("llmgateway"),
|
id: PluginV2.ID.make("llmgateway"),
|
||||||
effect: Effect.gen(function* () {
|
effect: Effect.gen(function* () {
|
||||||
|
const integrations = yield* Integration.Service
|
||||||
return {
|
return {
|
||||||
"catalog.transform": Effect.fn(function* (evt) {
|
"catalog.transform": Effect.fn(function* (evt) {
|
||||||
for (const item of evt.provider.list()) {
|
for (const item of evt.provider.list()) {
|
||||||
if (item.provider.enabled === false) continue
|
if (item.provider.disabled) continue
|
||||||
|
if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue
|
||||||
if (item.provider.api.type !== "aisdk") continue
|
if (item.provider.api.type !== "aisdk") continue
|
||||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||||
if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue
|
if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,11 @@ const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
|
||||||
|
|
||||||
export const browser = {
|
export const browser = {
|
||||||
integrationID: Integration.ID.make("openai"),
|
integrationID: Integration.ID.make("openai"),
|
||||||
method: new Integration.OAuthMethod({
|
method: {
|
||||||
id: browserMethodID,
|
id: browserMethodID,
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
label: "ChatGPT Pro/Plus (browser)",
|
label: "ChatGPT Pro/Plus (browser)",
|
||||||
}),
|
},
|
||||||
authorize: () =>
|
authorize: () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const pkce = yield* Effect.promise(generatePKCE)
|
const pkce = yield* Effect.promise(generatePKCE)
|
||||||
|
|
@ -89,11 +89,11 @@ export const browser = {
|
||||||
|
|
||||||
export const headless = {
|
export const headless = {
|
||||||
integrationID: Integration.ID.make("openai"),
|
integrationID: Integration.ID.make("openai"),
|
||||||
method: new Integration.OAuthMethod({
|
method: {
|
||||||
id: headlessMethodID,
|
id: headlessMethodID,
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
label: "ChatGPT Pro/Plus (headless)",
|
label: "ChatGPT Pro/Plus (headless)",
|
||||||
}),
|
},
|
||||||
authorize: () =>
|
authorize: () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>(
|
const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>(
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,22 @@
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
import { Integration } from "../../integration"
|
||||||
import { PluginV2 } from "../../plugin"
|
import { PluginV2 } from "../../plugin"
|
||||||
import { ProviderV2 } from "../../provider"
|
import { ProviderV2 } from "../../provider"
|
||||||
|
|
||||||
export const OpencodePlugin = PluginV2.define({
|
export const OpencodePlugin = PluginV2.define({
|
||||||
id: PluginV2.ID.make("opencode"),
|
id: PluginV2.ID.make("opencode"),
|
||||||
effect: Effect.gen(function* () {
|
effect: Effect.gen(function* () {
|
||||||
|
const integrations = yield* Integration.Service
|
||||||
let hasKey = false
|
let hasKey = false
|
||||||
return {
|
return {
|
||||||
"catalog.transform": Effect.fn(function* (evt) {
|
"catalog.transform": Effect.fn(function* (evt) {
|
||||||
const item = evt.provider.get(ProviderV2.ID.opencode)
|
const item = evt.provider.get(ProviderV2.ID.opencode)
|
||||||
if (!item) return
|
if (!item) return
|
||||||
|
const integration = yield* integrations.get(Integration.ID.make(item.provider.id))
|
||||||
hasKey = Boolean(
|
hasKey = Boolean(
|
||||||
process.env.OPENCODE_API_KEY ||
|
process.env.OPENCODE_API_KEY ||
|
||||||
item.provider.env.some((env) => process.env[env]) ||
|
integration?.connections.length ||
|
||||||
item.provider.request.body.apiKey ||
|
item.provider.request.body.apiKey,
|
||||||
(item.provider.enabled && item.provider.enabled.via === "credential"),
|
|
||||||
)
|
)
|
||||||
evt.provider.update(item.provider.id, (provider) => {
|
evt.provider.update(item.provider.id, (provider) => {
|
||||||
if (!hasKey) provider.request.body.apiKey = "public"
|
if (!hasKey) provider.request.body.apiKey = "public"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ export * as ProviderV2 from "./provider"
|
||||||
|
|
||||||
import { withStatics } from "./schema"
|
import { withStatics } from "./schema"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { Credential } from "./credential"
|
|
||||||
|
|
||||||
export const ID = Schema.String.pipe(
|
export const ID = Schema.String.pipe(
|
||||||
Schema.brand("ProviderV2.ID"),
|
Schema.brand("ProviderV2.ID"),
|
||||||
|
|
@ -48,22 +47,7 @@ export type Request = typeof Request.Type
|
||||||
export class Info extends Schema.Class<Info>("ProviderV2.Info")({
|
export class Info extends Schema.Class<Info>("ProviderV2.Info")({
|
||||||
id: ID,
|
id: ID,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
enabled: Schema.Union([
|
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||||
Schema.Literal(false),
|
|
||||||
Schema.Struct({
|
|
||||||
via: Schema.Literal("env"),
|
|
||||||
name: Schema.String,
|
|
||||||
}),
|
|
||||||
Schema.Struct({
|
|
||||||
via: Schema.Literal("credential"),
|
|
||||||
credentialID: Credential.ID,
|
|
||||||
}),
|
|
||||||
Schema.Struct({
|
|
||||||
via: Schema.Literal("custom"),
|
|
||||||
data: Schema.Record(Schema.String, Schema.Any),
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
env: Schema.String.pipe(Schema.Array),
|
|
||||||
api: Api,
|
api: Api,
|
||||||
request: Request,
|
request: Request,
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -71,8 +55,6 @@ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
|
||||||
return new Info({
|
return new Info({
|
||||||
id: providerID,
|
id: providerID,
|
||||||
name: providerID,
|
name: providerID,
|
||||||
enabled: false,
|
|
||||||
env: [],
|
|
||||||
api: {
|
api: {
|
||||||
type: "native",
|
type: "native",
|
||||||
settings: {},
|
settings: {},
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ import { Auth, type AnyRoute } from "@opencode-ai/llm/route"
|
||||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||||
import { produce } from "immer"
|
import { produce } from "immer"
|
||||||
import { Catalog } from "../../catalog"
|
import { Catalog } from "../../catalog"
|
||||||
|
import { Credential } from "../../credential"
|
||||||
|
import { Integration } from "../../integration"
|
||||||
|
import { IntegrationConnection } from "../../integration/connection"
|
||||||
import { ModelV2 } from "../../model"
|
import { ModelV2 } from "../../model"
|
||||||
import { ModelRequest } from "../../model-request"
|
import { ModelRequest } from "../../model-request"
|
||||||
import { PluginBoot } from "../../plugin/boot"
|
import { PluginBoot } from "../../plugin/boot"
|
||||||
|
|
@ -45,10 +48,16 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||||
/** Test or embedding seam for supplying a model resolver directly. */
|
/** Test or embedding seam for supplying a model resolver directly. */
|
||||||
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
|
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
|
||||||
|
|
||||||
const apiKey = (model: ModelV2.Info, provider?: ProviderV2.Info) => {
|
const apiKey = (
|
||||||
|
model: ModelV2.Info,
|
||||||
|
connection?: IntegrationConnection.Info,
|
||||||
|
credential?: Credential.Stored,
|
||||||
|
) => {
|
||||||
|
if (credential?.value.type === "key") return Auth.value(credential.value.key)
|
||||||
|
if (credential?.value.type === "oauth") return Auth.value(credential.value.access)
|
||||||
const value = model.request.body.apiKey ?? model.api.settings?.apiKey
|
const value = model.request.body.apiKey ?? model.api.settings?.apiKey
|
||||||
if (typeof value === "string") return Auth.value(value)
|
if (typeof value === "string") return Auth.value(value)
|
||||||
return provider?.enabled !== false && provider?.enabled.via === "env" ? Auth.config(provider.enabled.name) : undefined
|
return connection?.type === "env" ? Auth.config(connection.name) : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
|
const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
|
||||||
|
|
@ -83,41 +92,48 @@ const apiName = (model: ModelV2.Info) =>
|
||||||
|
|
||||||
export const fromCatalogModel = (
|
export const fromCatalogModel = (
|
||||||
model: ModelV2.Info,
|
model: ModelV2.Info,
|
||||||
provider?: ProviderV2.Info,
|
connection?: IntegrationConnection.Info,
|
||||||
|
credential?: Credential.Stored,
|
||||||
): Effect.Effect<Model, UnsupportedApiError> => {
|
): Effect.Effect<Model, UnsupportedApiError> => {
|
||||||
const key = apiKey(model, provider)
|
const resolved =
|
||||||
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai") {
|
credential?.value.metadata === undefined
|
||||||
|
? model
|
||||||
|
: produce(model, (draft) => {
|
||||||
|
Object.assign(draft.request.body, credential.value.metadata)
|
||||||
|
})
|
||||||
|
const key = apiKey(resolved, connection, credential)
|
||||||
|
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") {
|
||||||
return Effect.succeed(
|
return Effect.succeed(
|
||||||
withDefaults(model, OpenAIResponses.route)
|
withDefaults(resolved, OpenAIResponses.route)
|
||||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||||
.model({ id: model.api.id }),
|
.model({ id: resolved.api.id }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/anthropic") {
|
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/anthropic") {
|
||||||
return Effect.succeed(
|
return Effect.succeed(
|
||||||
withDefaults(model, AnthropicMessages.route)
|
withDefaults(resolved, AnthropicMessages.route)
|
||||||
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
|
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
|
||||||
.model({ id: model.api.id }),
|
.model({ id: resolved.api.id }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai-compatible" && model.api.url) {
|
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai-compatible" && resolved.api.url) {
|
||||||
return Effect.succeed(
|
return Effect.succeed(
|
||||||
withDefaults(model, OpenAICompatibleChat.route)
|
withDefaults(resolved, OpenAICompatibleChat.route)
|
||||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||||
.model({ id: model.api.id }),
|
.model({ id: resolved.api.id }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return Effect.fail(
|
return Effect.fail(
|
||||||
new UnsupportedApiError({
|
new UnsupportedApiError({
|
||||||
providerID: model.providerID,
|
providerID: resolved.providerID,
|
||||||
modelID: model.id,
|
modelID: resolved.id,
|
||||||
api: apiName(model),
|
api: apiName(resolved),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, provider?: ProviderV2.Info) =>
|
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info) =>
|
||||||
fromCatalogModel(withVariant(model, session.model?.variant), provider)
|
fromCatalogModel(withVariant(model, session.model?.variant))
|
||||||
|
|
||||||
export const supported = (model: ModelV2.Info) =>
|
export const supported = (model: ModelV2.Info) =>
|
||||||
model.api.type === "aisdk" &&
|
model.api.type === "aisdk" &&
|
||||||
|
|
@ -130,6 +146,8 @@ export const locationLayer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
|
const credentials = yield* Credential.Service
|
||||||
|
const integrations = yield* Integration.Service
|
||||||
const boot = yield* PluginBoot.Service
|
const boot = yield* PluginBoot.Service
|
||||||
return Service.of({
|
return Service.of({
|
||||||
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
|
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
|
||||||
|
|
@ -140,7 +158,12 @@ export const locationLayer = Layer.effect(
|
||||||
: (Option.getOrUndefined((yield* catalog.model.default()).pipe(Option.filter(supported))) ??
|
: (Option.getOrUndefined((yield* catalog.model.default()).pipe(Option.filter(supported))) ??
|
||||||
(yield* catalog.model.available()).find(supported))
|
(yield* catalog.model.available()).find(supported))
|
||||||
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
|
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
|
||||||
return yield* resolve(session, selected, yield* catalog.provider.get(selected.providerID))
|
const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID))
|
||||||
|
return yield* fromCatalogModel(
|
||||||
|
withVariant(selected, session.model?.variant),
|
||||||
|
connection,
|
||||||
|
connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined,
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { DateTime, Effect, Layer, Option } from "effect"
|
import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { Integration } from "@opencode-ai/core/integration"
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { Credential } from "@opencode-ai/core/credential"
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
|
|
@ -25,13 +25,29 @@ const it = testEffect(
|
||||||
Layer.provideMerge(
|
Layer.provideMerge(
|
||||||
Layer.mock(Credential.Service)({
|
Layer.mock(Credential.Service)({
|
||||||
all: () => Effect.succeed([]),
|
all: () => Effect.succeed([]),
|
||||||
|
list: () => Effect.succeed([]),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
describe("CatalogV2", () => {
|
describe("CatalogV2", () => {
|
||||||
it.effect("projects active credentials without rebuilding catalog state", () => {
|
it.effect("publishes an updated event after catalog changes", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const updated = yield* events.subscribe(Catalog.Event.Updated).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||||
|
yield* Effect.yieldNow
|
||||||
|
|
||||||
|
yield* (yield* catalog.transform())((editor) =>
|
||||||
|
editor.provider.update(ProviderV2.ID.make("test"), () => {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect((yield* Fiber.join(updated)).length).toBe(1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("derives availability from active credentials without changing provider state", () => {
|
||||||
const integrationID = Integration.ID.make("test")
|
const integrationID = Integration.ID.make("test")
|
||||||
const first = {
|
const first = {
|
||||||
id: Credential.ID.create(),
|
id: Credential.ID.create(),
|
||||||
|
|
@ -53,6 +69,7 @@ describe("CatalogV2", () => {
|
||||||
Layer.provideMerge(
|
Layer.provideMerge(
|
||||||
Layer.mock(Credential.Service)({
|
Layer.mock(Credential.Service)({
|
||||||
all: () => Effect.sync(() => [active]),
|
all: () => Effect.sync(() => [active]),
|
||||||
|
list: () => Effect.sync(() => [active]),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -62,18 +79,44 @@ describe("CatalogV2", () => {
|
||||||
const transform = yield* catalog.transform()
|
const transform = yield* catalog.transform()
|
||||||
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
||||||
|
|
||||||
expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({
|
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
|
||||||
enabled: { via: "credential", credentialID: first.id },
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
|
||||||
request: { body: { apiKey: "first", tenant: "one" } },
|
|
||||||
})
|
|
||||||
active = second
|
active = second
|
||||||
expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({
|
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
|
||||||
enabled: { via: "credential", credentialID: second.id },
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
|
||||||
request: { body: { apiKey: "second", tenant: "two" } },
|
|
||||||
})
|
|
||||||
}).pipe(Effect.provide(layer))
|
}).pipe(Effect.provide(layer))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.effect("projects environment connections without a catalog plugin", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() => {
|
||||||
|
const previous = process.env.CATALOG_TEST_API_KEY
|
||||||
|
process.env.CATALOG_TEST_API_KEY = "secret"
|
||||||
|
return previous
|
||||||
|
}),
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
const integrations = yield* Integration.Service
|
||||||
|
const providerID = ProviderV2.ID.make("test")
|
||||||
|
yield* integrations.update((editor) =>
|
||||||
|
editor.method.update({
|
||||||
|
integrationID: Integration.ID.make(providerID),
|
||||||
|
method: { type: "env", names: ["CATALOG_TEST_API_KEY"] },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
yield* (yield* catalog.transform())((editor) => editor.provider.update(providerID, () => {}))
|
||||||
|
|
||||||
|
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
|
||||||
|
}),
|
||||||
|
(previous) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (previous === undefined) delete process.env.CATALOG_TEST_API_KEY
|
||||||
|
else process.env.CATALOG_TEST_API_KEY = previous
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("normalizes provider baseURL into api url", () =>
|
it.effect("normalizes provider baseURL into api url", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
|
|
@ -292,9 +335,7 @@ describe("CatalogV2", () => {
|
||||||
const transform = yield* catalog.transform()
|
const transform = yield* catalog.transform()
|
||||||
|
|
||||||
yield* transform((catalog) => {
|
yield* transform((catalog) => {
|
||||||
catalog.provider.update(providerID, (provider) => {
|
catalog.provider.update(providerID, () => {})
|
||||||
provider.enabled = { via: "custom", data: {} }
|
|
||||||
})
|
|
||||||
catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => {
|
catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => {
|
||||||
model.time.released = DateTime.makeUnsafe(1000)
|
model.time.released = DateTime.makeUnsafe(1000)
|
||||||
})
|
})
|
||||||
|
|
@ -316,9 +357,7 @@ describe("CatalogV2", () => {
|
||||||
const transform = yield* catalog.transform()
|
const transform = yield* catalog.transform()
|
||||||
|
|
||||||
const models = (catalog: Catalog.Editor) => {
|
const models = (catalog: Catalog.Editor) => {
|
||||||
catalog.provider.update(providerID, (provider) => {
|
catalog.provider.update(providerID, () => {})
|
||||||
provider.enabled = { via: "custom", data: {} }
|
|
||||||
})
|
|
||||||
catalog.model.update(providerID, old, (model) => {
|
catalog.model.update(providerID, old, (model) => {
|
||||||
model.time.released = DateTime.makeUnsafe(1000)
|
model.time.released = DateTime.makeUnsafe(1000)
|
||||||
})
|
})
|
||||||
|
|
@ -349,12 +388,10 @@ describe("CatalogV2", () => {
|
||||||
|
|
||||||
yield* transform((catalog) => {
|
yield* transform((catalog) => {
|
||||||
catalog.provider.update(disabledProvider, (provider) => {
|
catalog.provider.update(disabledProvider, (provider) => {
|
||||||
provider.enabled = false
|
provider.disabled = true
|
||||||
})
|
})
|
||||||
catalog.model.update(disabledProvider, disabledModel, () => {})
|
catalog.model.update(disabledProvider, disabledModel, () => {})
|
||||||
catalog.provider.update(enabledProvider, (provider) => {
|
catalog.provider.update(enabledProvider, () => {})
|
||||||
provider.enabled = { via: "custom", data: {} }
|
|
||||||
})
|
|
||||||
catalog.model.update(enabledProvider, fallbackModel, () => {})
|
catalog.model.update(enabledProvider, fallbackModel, () => {})
|
||||||
catalog.model.default.set(disabledProvider, disabledModel)
|
catalog.model.default.set(disabledProvider, disabledModel)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,11 @@ import { Effect, Option, Schema } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { Config } from "@opencode-ai/core/config"
|
import { Config } from "@opencode-ai/core/config"
|
||||||
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
|
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
|
||||||
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { it } from "../plugin/provider-helper"
|
import { it, withEnv } from "../plugin/provider-helper"
|
||||||
|
|
||||||
function request(headers: Record<string, string>, variant?: string) {
|
function request(headers: Record<string, string>, variant?: string) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -21,6 +22,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||||
it.effect("partitions existing model variant bodies without changing config shape", () =>
|
it.effect("partitions existing model variant bodies without changing config shape", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
|
const integrations = yield* Integration.Service
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const providerID = ProviderV2.ID.opencode
|
const providerID = ProviderV2.ID.opencode
|
||||||
const modelID = ModelV2.ID.make("alpha-gpt-next")
|
const modelID = ModelV2.ID.make("alpha-gpt-next")
|
||||||
|
|
@ -59,6 +61,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||||
effect: ConfigProviderPlugin.Plugin.effect.pipe(
|
effect: ConfigProviderPlugin.Plugin.effect.pipe(
|
||||||
Effect.provideService(Config.Service, config),
|
Effect.provideService(Config.Service, config),
|
||||||
Effect.provideService(Catalog.Service, catalog),
|
Effect.provideService(Catalog.Service, catalog),
|
||||||
|
Effect.provideService(Integration.Service, integrations),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -80,6 +83,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||||
it.effect("uses the effective provider package across layered config", () =>
|
it.effect("uses the effective provider package across layered config", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
|
const integrations = yield* Integration.Service
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const providerID = ProviderV2.ID.opencode
|
const providerID = ProviderV2.ID.opencode
|
||||||
const modelID = ModelV2.ID.make("alpha-gpt-next")
|
const modelID = ModelV2.ID.make("alpha-gpt-next")
|
||||||
|
|
@ -118,6 +122,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||||
effect: ConfigProviderPlugin.Plugin.effect.pipe(
|
effect: ConfigProviderPlugin.Plugin.effect.pipe(
|
||||||
Effect.provideService(Config.Service, config),
|
Effect.provideService(Config.Service, config),
|
||||||
Effect.provideService(Catalog.Service, catalog),
|
Effect.provideService(Catalog.Service, catalog),
|
||||||
|
Effect.provideService(Integration.Service, integrations),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -131,8 +136,9 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("loads configured providers and applies later model overrides", () =>
|
it.effect("loads configured providers and applies later model overrides", () =>
|
||||||
Effect.gen(function* () {
|
withEnv({ CUSTOM_API_KEY: "secret" }, () => Effect.gen(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
|
const integrations = yield* Integration.Service
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const providerID = ProviderV2.ID.make("custom")
|
const providerID = ProviderV2.ID.make("custom")
|
||||||
const modelID = ModelV2.ID.make("chat")
|
const modelID = ModelV2.ID.make("chat")
|
||||||
|
|
@ -218,6 +224,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||||
effect: ConfigProviderPlugin.Plugin.effect.pipe(
|
effect: ConfigProviderPlugin.Plugin.effect.pipe(
|
||||||
Effect.provideService(Config.Service, config),
|
Effect.provideService(Config.Service, config),
|
||||||
Effect.provideService(Catalog.Service, catalog),
|
Effect.provideService(Catalog.Service, catalog),
|
||||||
|
Effect.provideService(Integration.Service, integrations),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -225,8 +232,11 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||||
const model = yield* catalog.model.get(providerID, modelID)
|
const model = yield* catalog.model.get(providerID, modelID)
|
||||||
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
|
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
|
||||||
expect(provider.name).toBe("Renamed")
|
expect(provider.name).toBe("Renamed")
|
||||||
expect(provider.env).toEqual(["CUSTOM_API_KEY"])
|
expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual(
|
||||||
expect(provider.enabled).toEqual({ via: "custom", data: {} })
|
{ type: "env", names: ["CUSTOM_API_KEY"] },
|
||||||
|
)
|
||||||
|
expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed")
|
||||||
|
expect(provider.disabled).toBeUndefined()
|
||||||
expect(provider.api).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" })
|
expect(provider.api).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" })
|
||||||
expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||||
expect(model.api.id).toBe(ModelV2.ID.make("api-chat"))
|
expect(model.api.id).toBe(ModelV2.ID.make("api-chat"))
|
||||||
|
|
@ -243,6 +253,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||||
])
|
])
|
||||||
expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||||
expect(model.variants[1]?.headers).toEqual({ slow: "slow" })
|
expect(model.variants[1]?.headers).toEqual({ slow: "slow" })
|
||||||
}),
|
})),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Duration, Effect, Exit, Layer, Scope } from "effect"
|
import { Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
|
||||||
import * as TestClock from "effect/testing/TestClock"
|
import * as TestClock from "effect/testing/TestClock"
|
||||||
import { Integration } from "@opencode-ai/core/integration"
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { IntegrationConnection } from "@opencode-ai/core/integration/connection"
|
|
||||||
import { Credential } from "@opencode-ai/core/credential"
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { it } from "./lib/effect"
|
import { it } from "./lib/effect"
|
||||||
|
|
@ -25,7 +24,7 @@ function connectionLayer(
|
||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
return Integration.locationLayer.pipe(
|
return Integration.locationLayer.pipe(
|
||||||
Layer.provide(EventV2.defaultLayer),
|
Layer.provideMerge(EventV2.defaultLayer),
|
||||||
Layer.provide(
|
Layer.provide(
|
||||||
Layer.mock(Credential.Service)({
|
Layer.mock(Credential.Service)({
|
||||||
create: (input) =>
|
create: (input) =>
|
||||||
|
|
@ -103,7 +102,7 @@ describe("Integration", () => {
|
||||||
.update((editor) =>
|
.update((editor) =>
|
||||||
editor.method.update({
|
editor.method.update({
|
||||||
integrationID,
|
integrationID,
|
||||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
|
method: { id: methodID, type: "oauth", label: "ChatGPT" },
|
||||||
authorize,
|
authorize,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -117,7 +116,7 @@ describe("Integration", () => {
|
||||||
])
|
])
|
||||||
editor.method.update({
|
editor.method.update({
|
||||||
integrationID,
|
integrationID,
|
||||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT Override" }),
|
method: { id: methodID, type: "oauth", label: "ChatGPT Override" },
|
||||||
authorize,
|
authorize,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -140,15 +139,22 @@ describe("Integration", () => {
|
||||||
}> = []
|
}> = []
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const integrations = yield* Integration.Service
|
const integrations = yield* Integration.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
const integrationID = Integration.ID.make("openai")
|
const integrationID = Integration.ID.make("openai")
|
||||||
yield* integrations.update((editor) =>
|
yield* integrations.update((editor) =>
|
||||||
editor.method.update({
|
editor.method.update({
|
||||||
integrationID,
|
integrationID,
|
||||||
method: new Integration.KeyMethod({ type: "key", label: "API key" }),
|
method: { type: "key", label: "API key" },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
const updated = yield* events.subscribe(Integration.Event.Updated).pipe(
|
||||||
|
Stream.take(1),
|
||||||
|
Stream.runCollect,
|
||||||
|
Effect.forkScoped,
|
||||||
|
)
|
||||||
|
yield* Effect.yieldNow
|
||||||
|
|
||||||
yield* integrations.connect.key({
|
yield* integrations.connection.key({
|
||||||
integrationID,
|
integrationID,
|
||||||
key: "secret",
|
key: "secret",
|
||||||
label: "Work",
|
label: "Work",
|
||||||
|
|
@ -161,6 +167,7 @@ describe("Integration", () => {
|
||||||
value: new Credential.Key({ type: "key", key: "secret" }),
|
value: new Credential.Key({ type: "key", key: "secret" }),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
expect((yield* Fiber.join(updated)).length).toBe(1)
|
||||||
}).pipe(Effect.provide(connectionLayer(created)))
|
}).pipe(Effect.provide(connectionLayer(created)))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -177,7 +184,7 @@ describe("Integration", () => {
|
||||||
yield* integrations.update((editor) =>
|
yield* integrations.update((editor) =>
|
||||||
editor.method.update({
|
editor.method.update({
|
||||||
integrationID,
|
integrationID,
|
||||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
|
method: { id: methodID, type: "oauth", label: "ChatGPT" },
|
||||||
authorize: () =>
|
authorize: () =>
|
||||||
Effect.succeed({
|
Effect.succeed({
|
||||||
mode: "code" as const,
|
mode: "code" as const,
|
||||||
|
|
@ -198,7 +205,7 @@ describe("Integration", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const attempt = yield* integrations.connect.oauth({
|
const attempt = yield* integrations.connection.oauth({
|
||||||
integrationID,
|
integrationID,
|
||||||
methodID,
|
methodID,
|
||||||
inputs: {},
|
inputs: {},
|
||||||
|
|
@ -236,7 +243,7 @@ describe("Integration", () => {
|
||||||
yield* integrations.update((editor) =>
|
yield* integrations.update((editor) =>
|
||||||
editor.method.update({
|
editor.method.update({
|
||||||
integrationID,
|
integrationID,
|
||||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
|
method: { id: methodID, type: "oauth", label: "ChatGPT" },
|
||||||
authorize: () =>
|
authorize: () =>
|
||||||
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
|
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
|
||||||
Effect.as({
|
Effect.as({
|
||||||
|
|
@ -249,7 +256,7 @@ describe("Integration", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
|
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
|
||||||
expect(yield* integrations.attempt.complete({ attemptID: attempt.attemptID }).pipe(Effect.flip)).toBeInstanceOf(
|
expect(yield* integrations.attempt.complete({ attemptID: attempt.attemptID }).pipe(Effect.flip)).toBeInstanceOf(
|
||||||
Integration.CodeRequiredError,
|
Integration.CodeRequiredError,
|
||||||
)
|
)
|
||||||
|
|
@ -273,7 +280,7 @@ describe("Integration", () => {
|
||||||
yield* integrations.update((editor) =>
|
yield* integrations.update((editor) =>
|
||||||
editor.method.update({
|
editor.method.update({
|
||||||
integrationID,
|
integrationID,
|
||||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }),
|
method: { id: methodID, type: "oauth", label: "Browser" },
|
||||||
authorize: () =>
|
authorize: () =>
|
||||||
Effect.succeed({
|
Effect.succeed({
|
||||||
mode: "auto" as const,
|
mode: "auto" as const,
|
||||||
|
|
@ -286,7 +293,7 @@ describe("Integration", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
|
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
|
||||||
yield* Effect.yieldNow
|
yield* Effect.yieldNow
|
||||||
expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({
|
expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({
|
||||||
status: "complete",
|
status: "complete",
|
||||||
|
|
@ -310,7 +317,7 @@ describe("Integration", () => {
|
||||||
yield* integrations.update((editor) =>
|
yield* integrations.update((editor) =>
|
||||||
editor.method.update({
|
editor.method.update({
|
||||||
integrationID,
|
integrationID,
|
||||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }),
|
method: { id: methodID, type: "oauth", label: "Browser" },
|
||||||
authorize: () =>
|
authorize: () =>
|
||||||
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
|
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
|
||||||
Effect.as({
|
Effect.as({
|
||||||
|
|
@ -323,7 +330,7 @@ describe("Integration", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
|
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
|
||||||
expect(attempt.time.expires - attempt.time.created).toBe(Duration.toMillis(Duration.minutes(10)))
|
expect(attempt.time.expires - attempt.time.created).toBe(Duration.toMillis(Duration.minutes(10)))
|
||||||
yield* TestClock.adjust(Duration.minutes(10))
|
yield* TestClock.adjust(Duration.minutes(10))
|
||||||
yield* Effect.yieldNow
|
yield* Effect.yieldNow
|
||||||
|
|
@ -373,23 +380,28 @@ describe("Integration", () => {
|
||||||
yield* integrations.update((editor) =>
|
yield* integrations.update((editor) =>
|
||||||
editor.method.update({
|
editor.method.update({
|
||||||
integrationID,
|
integrationID,
|
||||||
method: new Integration.EnvMethod({
|
method: {
|
||||||
type: "env",
|
type: "env",
|
||||||
names: ["INTEGRATION_TEST_ACME_KEY", "INTEGRATION_TEST_ACME_MISSING"],
|
names: ["INTEGRATION_TEST_ACME_KEY", "INTEGRATION_TEST_ACME_MISSING"],
|
||||||
}),
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Stored credentials and detected env vars appear as connections.
|
// Stored credentials and detected env vars appear as connections.
|
||||||
expect((yield* integrations.get(integrationID))?.connections).toEqual([
|
expect((yield* integrations.get(integrationID))?.connections).toEqual([
|
||||||
new IntegrationConnection.CredentialInfo({ type: "credential", id: rows[0]!.id, label: "Work" }),
|
{ type: "credential", id: rows[0]!.id, label: "Work" },
|
||||||
new IntegrationConnection.CredentialInfo({
|
{
|
||||||
type: "credential",
|
type: "credential",
|
||||||
id: rows[1]!.id,
|
id: rows[1]!.id,
|
||||||
label: "Personal",
|
label: "Personal",
|
||||||
}),
|
},
|
||||||
new IntegrationConnection.EnvInfo({ type: "env", name: "INTEGRATION_TEST_ACME_KEY" }),
|
{ type: "env", name: "INTEGRATION_TEST_ACME_KEY" },
|
||||||
])
|
])
|
||||||
|
expect(yield* integrations.connection.forIntegration(integrationID)).toEqual({
|
||||||
|
type: "credential",
|
||||||
|
id: rows[1]!.id,
|
||||||
|
label: "Personal",
|
||||||
|
})
|
||||||
}).pipe(Effect.provide(projectionLayer)),
|
}).pipe(Effect.provide(projectionLayer)),
|
||||||
(previous) =>
|
(previous) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,10 @@ const connections = Credential.layer.pipe(
|
||||||
Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)),
|
Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)),
|
||||||
Layer.provide(events),
|
Layer.provide(events),
|
||||||
)
|
)
|
||||||
const catalog = Catalog.layer.pipe(Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections)))
|
|
||||||
const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections))
|
const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections))
|
||||||
|
const catalog = Catalog.layer.pipe(
|
||||||
|
Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections, integrations)),
|
||||||
|
)
|
||||||
const layer = Layer.mergeAll(
|
const layer = Layer.mergeAll(
|
||||||
catalog.pipe(Layer.provide(connections)),
|
catalog.pipe(Layer.provide(connections)),
|
||||||
integrations,
|
integrations,
|
||||||
|
|
@ -61,11 +63,11 @@ describe("ModelsDevPlugin", () => {
|
||||||
id: Integration.ID.make("acme"),
|
id: Integration.ID.make("acme"),
|
||||||
name: "Acme",
|
name: "Acme",
|
||||||
methods: [
|
methods: [
|
||||||
new Integration.KeyMethod({ type: "key" }),
|
{ type: "key" },
|
||||||
new Integration.EnvMethod({
|
{
|
||||||
type: "env",
|
type: "env",
|
||||||
names: ["ACME_API_KEY"],
|
names: ["ACME_API_KEY"],
|
||||||
}),
|
},
|
||||||
],
|
],
|
||||||
connections: [],
|
connections: [],
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ const integrations = Integration.locationLayer.pipe(
|
||||||
Layer.provide(
|
Layer.provide(
|
||||||
Layer.mock(Credential.Service)({
|
Layer.mock(Credential.Service)({
|
||||||
create: () => Effect.die("unexpected credential creation"),
|
create: () => Effect.die("unexpected credential creation"),
|
||||||
|
all: () => Effect.succeed([]),
|
||||||
list: () => Effect.succeed([]),
|
list: () => Effect.succeed([]),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||||
import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway"
|
import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway"
|
||||||
|
|
@ -8,6 +9,14 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { expectPluginRegistered, it, provider } from "./provider-helper"
|
import { expectPluginRegistered, it, provider } from "./provider-helper"
|
||||||
|
|
||||||
describe("LLMGatewayPlugin", () => {
|
describe("LLMGatewayPlugin", () => {
|
||||||
|
const add = Effect.fnUntraced(function* (plugin: PluginV2.Interface) {
|
||||||
|
const integrations = yield* Integration.Service
|
||||||
|
yield* plugin.add({
|
||||||
|
...LLMGatewayPlugin,
|
||||||
|
effect: LLMGatewayPlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||||
Effect.sync(() =>
|
Effect.sync(() =>
|
||||||
expectPluginRegistered(
|
expectPluginRegistered(
|
||||||
|
|
@ -21,25 +30,23 @@ describe("LLMGatewayPlugin", () => {
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* plugin.add(LLMGatewayPlugin)
|
yield* add(plugin)
|
||||||
|
const integrations = yield* Integration.Service
|
||||||
|
yield* integrations.update((editor) => {
|
||||||
|
editor.update(Integration.ID.make("llmgateway"), () => {})
|
||||||
|
editor.update(Integration.ID.make("openrouter"), () => {})
|
||||||
|
})
|
||||||
const transform = yield* catalog.transform()
|
const transform = yield* catalog.transform()
|
||||||
yield* transform((catalog) => {
|
yield* transform((catalog) => {
|
||||||
const llmgateway = provider("llmgateway", {
|
const llmgateway = provider("llmgateway", {
|
||||||
enabled: { via: "env", name: "LLMGATEWAY_API_KEY" },
|
|
||||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
|
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
|
||||||
request: { headers: { Existing: "value" }, body: {} },
|
request: { headers: { Existing: "value" }, body: {} },
|
||||||
})
|
})
|
||||||
catalog.provider.update(llmgateway.id, (draft) => {
|
catalog.provider.update(llmgateway.id, (draft) => {
|
||||||
draft.enabled = llmgateway.enabled
|
|
||||||
draft.api = llmgateway.api
|
draft.api = llmgateway.api
|
||||||
draft.request = llmgateway.request
|
draft.request = llmgateway.request
|
||||||
})
|
})
|
||||||
const openrouter = provider("openrouter", {
|
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
||||||
enabled: { via: "env", name: "OPENROUTER_API_KEY" },
|
|
||||||
})
|
|
||||||
catalog.provider.update(openrouter.id, (draft) => {
|
|
||||||
draft.enabled = openrouter.enabled
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({
|
||||||
Existing: "value",
|
Existing: "value",
|
||||||
|
|
@ -55,7 +62,7 @@ describe("LLMGatewayPlugin", () => {
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* plugin.add(LLMGatewayPlugin)
|
yield* add(plugin)
|
||||||
const transform = yield* catalog.transform()
|
const transform = yield* catalog.transform()
|
||||||
yield* transform((catalog) => {
|
yield* transform((catalog) => {
|
||||||
const item = provider("llmgateway", {
|
const item = provider("llmgateway", {
|
||||||
|
|
@ -66,7 +73,7 @@ describe("LLMGatewayPlugin", () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).enabled).toBe(false)
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).disabled).toBeUndefined()
|
||||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({})
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -21,16 +21,16 @@ describe("OpenAIPlugin", () => {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
yield* add(plugin, yield* Integration.Service)
|
yield* add(plugin, yield* Integration.Service)
|
||||||
expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([
|
expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([
|
||||||
new Integration.OAuthMethod({
|
{
|
||||||
id: Integration.MethodID.make("chatgpt-browser"),
|
id: Integration.MethodID.make("chatgpt-browser"),
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
label: "ChatGPT Pro/Plus (browser)",
|
label: "ChatGPT Pro/Plus (browser)",
|
||||||
}),
|
},
|
||||||
new Integration.OAuthMethod({
|
{
|
||||||
id: Integration.MethodID.make("chatgpt-headless"),
|
id: Integration.MethodID.make("chatgpt-headless"),
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
label: "ChatGPT Pro/Plus (headless)",
|
label: "ChatGPT Pro/Plus (headless)",
|
||||||
}),
|
},
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { DateTime, Effect, Layer, Option } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { Credential } from "@opencode-ai/core/credential"
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
|
|
@ -18,13 +19,18 @@ const locationLayer = Layer.succeed(
|
||||||
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
|
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const pluginWithIntegrations = (integrations: Integration.Interface) => ({
|
||||||
|
...OpencodePlugin,
|
||||||
|
effect: OpencodePlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)),
|
||||||
|
})
|
||||||
|
|
||||||
describe("OpencodePlugin", () => {
|
describe("OpencodePlugin", () => {
|
||||||
it.effect("uses a public key and disables paid models without credentials", () =>
|
it.effect("uses a public key and disables paid models without credentials", () =>
|
||||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* plugin.add(OpencodePlugin)
|
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
|
||||||
const transform = yield* catalog.transform()
|
const transform = yield* catalog.transform()
|
||||||
yield* transform((catalog) => {
|
yield* transform((catalog) => {
|
||||||
const item = provider("opencode")
|
const item = provider("opencode")
|
||||||
|
|
@ -45,7 +51,7 @@ describe("OpencodePlugin", () => {
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* plugin.add(OpencodePlugin)
|
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
|
||||||
const transform = yield* catalog.transform()
|
const transform = yield* catalog.transform()
|
||||||
yield* transform((catalog) => {
|
yield* transform((catalog) => {
|
||||||
const item = provider("opencode")
|
const item = provider("opencode")
|
||||||
|
|
@ -66,7 +72,7 @@ describe("OpencodePlugin", () => {
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* plugin.add(OpencodePlugin)
|
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
|
||||||
const transform = yield* catalog.transform()
|
const transform = yield* catalog.transform()
|
||||||
yield* transform((catalog) => {
|
yield* transform((catalog) => {
|
||||||
const item = provider("opencode")
|
const item = provider("opencode")
|
||||||
|
|
@ -87,7 +93,7 @@ describe("OpencodePlugin", () => {
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* plugin.add(OpencodePlugin)
|
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
|
||||||
const transform = yield* catalog.transform()
|
const transform = yield* catalog.transform()
|
||||||
yield* transform((catalog) => {
|
yield* transform((catalog) => {
|
||||||
const item = provider("opencode")
|
const item = provider("opencode")
|
||||||
|
|
@ -108,13 +114,18 @@ describe("OpencodePlugin", () => {
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* plugin.add(OpencodePlugin)
|
const integrations = yield* Integration.Service
|
||||||
|
yield* plugin.add(pluginWithIntegrations(integrations))
|
||||||
|
yield* integrations.update((editor) => {
|
||||||
|
editor.method.update({
|
||||||
|
integrationID: Integration.ID.make("opencode"),
|
||||||
|
method: { type: "env", names: ["CUSTOM_OPENCODE_API_KEY"] },
|
||||||
|
})
|
||||||
|
})
|
||||||
const transform = yield* catalog.transform()
|
const transform = yield* catalog.transform()
|
||||||
yield* transform((catalog) => {
|
yield* transform((catalog) => {
|
||||||
const item = provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] })
|
const item = provider("opencode")
|
||||||
catalog.provider.update(item.id, (draft) => {
|
catalog.provider.update(item.id, () => {})
|
||||||
draft.env = [...item.env]
|
|
||||||
})
|
|
||||||
const paid = model("opencode", "paid", { cost: cost(1) })
|
const paid = model("opencode", "paid", { cost: cost(1) })
|
||||||
catalog.model.update(item.id, paid.id, (draft) => {
|
catalog.model.update(item.id, paid.id, (draft) => {
|
||||||
draft.cost = [...paid.cost]
|
draft.cost = [...paid.cost]
|
||||||
|
|
@ -131,7 +142,7 @@ describe("OpencodePlugin", () => {
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* plugin.add(OpencodePlugin)
|
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
|
||||||
const transform = yield* catalog.transform()
|
const transform = yield* catalog.transform()
|
||||||
yield* transform((catalog) => {
|
yield* transform((catalog) => {
|
||||||
const item = provider("opencode", {
|
const item = provider("opencode", {
|
||||||
|
|
@ -154,37 +165,12 @@ describe("OpencodePlugin", () => {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("uses auth-enabled providers as credentials", () =>
|
|
||||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const plugin = yield* PluginV2.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
yield* plugin.add(OpencodePlugin)
|
|
||||||
const transform = yield* catalog.transform()
|
|
||||||
yield* transform((catalog) => {
|
|
||||||
const item = provider("opencode", {
|
|
||||||
enabled: { via: "credential", credentialID: Credential.ID.make("credential") },
|
|
||||||
})
|
|
||||||
catalog.provider.update(item.id, (draft) => {
|
|
||||||
draft.enabled = item.enabled
|
|
||||||
})
|
|
||||||
const paid = model("opencode", "paid", { cost: cost(1) })
|
|
||||||
catalog.model.update(item.id, paid.id, (draft) => {
|
|
||||||
draft.cost = [...paid.cost]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
|
|
||||||
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("ignores non-opencode providers and models", () =>
|
it.effect("ignores non-opencode providers and models", () =>
|
||||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* plugin.add(OpencodePlugin)
|
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
|
||||||
const transform = yield* catalog.transform()
|
const transform = yield* catalog.transform()
|
||||||
yield* transform((catalog) => {
|
yield* transform((catalog) => {
|
||||||
const item = provider("openai")
|
const item = provider("openai")
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { LLM } from "@opencode-ai/llm"
|
||||||
import { LLMClient } from "@opencode-ai/llm/route"
|
import { LLMClient } from "@opencode-ai/llm/route"
|
||||||
import { ConfigProvider, DateTime, Effect } from "effect"
|
import { ConfigProvider, DateTime, Effect } from "effect"
|
||||||
import { Headers } from "effect/unstable/http"
|
import { Headers } from "effect/unstable/http"
|
||||||
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||||
|
|
@ -45,8 +47,6 @@ const provider = (api: ProviderV2.Info["api"]) =>
|
||||||
new ProviderV2.Info({
|
new ProviderV2.Info({
|
||||||
id: ProviderV2.ID.make("test-provider"),
|
id: ProviderV2.ID.make("test-provider"),
|
||||||
name: "Test provider",
|
name: "Test provider",
|
||||||
enabled: { via: "env", name: "TEST_PROVIDER_API_KEY" },
|
|
||||||
env: ["TEST_PROVIDER_API_KEY"],
|
|
||||||
api,
|
api,
|
||||||
request: { headers: {}, body: {} },
|
request: { headers: {}, body: {} },
|
||||||
})
|
})
|
||||||
|
|
@ -247,7 +247,7 @@ describe("SessionRunnerModel", () => {
|
||||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||||
request: { headers: {}, body: {}, generation: {}, options: {} },
|
request: { headers: {}, body: {}, generation: {}, options: {} },
|
||||||
}),
|
}),
|
||||||
provider({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
{ type: "env", name: "TEST_PROVIDER_API_KEY" },
|
||||||
)
|
)
|
||||||
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||||
const headers = yield* resolved.route.auth
|
const headers = yield* resolved.route.auth
|
||||||
|
|
@ -266,6 +266,35 @@ describe("SessionRunnerModel", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("prefers stored credentials over configured auth", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const credential = new Credential.Stored({
|
||||||
|
id: Credential.ID.create(),
|
||||||
|
integrationID: Integration.ID.make("test-provider"),
|
||||||
|
label: "Work",
|
||||||
|
value: new Credential.Key({ type: "key", key: "stored-secret", metadata: { tenant: "work" } }),
|
||||||
|
})
|
||||||
|
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||||
|
new ModelV2.Info({
|
||||||
|
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||||
|
request: { headers: {}, body: { apiKey: "configured-secret" }, generation: {}, options: {} },
|
||||||
|
}),
|
||||||
|
{ type: "credential", id: credential.id, label: credential.label },
|
||||||
|
credential,
|
||||||
|
)
|
||||||
|
const headers = yield* resolved.route.auth.apply({
|
||||||
|
request: LLM.request({ model: resolved, prompt: "Hello" }),
|
||||||
|
method: "POST",
|
||||||
|
url: "https://openai.example/v1/responses",
|
||||||
|
body: "{}",
|
||||||
|
headers: Headers.empty,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(headers.authorization).toBe("Bearer stored-secret")
|
||||||
|
expect(resolved.route.defaults.http?.body).toEqual({ tenant: "work" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("rejects catalog APIs without a native route", () =>
|
it.effect("rejects catalog APIs without a native route", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const failure = yield* SessionRunnerModel.fromCatalogModel(
|
const failure = yield* SessionRunnerModel.fromCatalogModel(
|
||||||
|
|
|
||||||
|
|
@ -5812,10 +5812,24 @@ export class Credential extends HeyApiClient {
|
||||||
public remove<ThrowOnError extends boolean = false>(
|
public remove<ThrowOnError extends boolean = false>(
|
||||||
parameters: {
|
parameters: {
|
||||||
credentialID: string
|
credentialID: string
|
||||||
|
location?: {
|
||||||
|
directory?: string
|
||||||
|
workspace?: string
|
||||||
|
}
|
||||||
},
|
},
|
||||||
options?: Options<never, ThrowOnError>,
|
options?: Options<never, ThrowOnError>,
|
||||||
) {
|
) {
|
||||||
const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "credentialID" }] }])
|
const params = buildClientParams(
|
||||||
|
[parameters],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
args: [
|
||||||
|
{ in: "path", key: "credentialID" },
|
||||||
|
{ in: "query", key: "location" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
return (options?.client ?? this.client).delete<V2CredentialRemoveResponses, V2CredentialRemoveErrors, ThrowOnError>(
|
return (options?.client ?? this.client).delete<V2CredentialRemoveResponses, V2CredentialRemoveErrors, ThrowOnError>(
|
||||||
{
|
{
|
||||||
url: "/api/credential/{credentialID}",
|
url: "/api/credential/{credentialID}",
|
||||||
|
|
@ -5833,6 +5847,10 @@ export class Credential extends HeyApiClient {
|
||||||
public update<ThrowOnError extends boolean = false>(
|
public update<ThrowOnError extends boolean = false>(
|
||||||
parameters: {
|
parameters: {
|
||||||
credentialID: string
|
credentialID: string
|
||||||
|
location?: {
|
||||||
|
directory?: string
|
||||||
|
workspace?: string
|
||||||
|
}
|
||||||
label?: string
|
label?: string
|
||||||
},
|
},
|
||||||
options?: Options<never, ThrowOnError>,
|
options?: Options<never, ThrowOnError>,
|
||||||
|
|
@ -5843,6 +5861,7 @@ export class Credential extends HeyApiClient {
|
||||||
{
|
{
|
||||||
args: [
|
args: [
|
||||||
{ in: "path", key: "credentialID" },
|
{ in: "path", key: "credentialID" },
|
||||||
|
{ in: "query", key: "location" },
|
||||||
{ in: "body", key: "label" },
|
{ in: "body", key: "label" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ export type ClientOptions = {
|
||||||
export type Event =
|
export type Event =
|
||||||
| EventModelsDevRefreshed
|
| EventModelsDevRefreshed
|
||||||
| EventPluginAdded
|
| EventPluginAdded
|
||||||
| EventCatalogModelUpdated
|
| EventIntegrationUpdated
|
||||||
|
| EventCatalogUpdated
|
||||||
| EventSessionCreated
|
| EventSessionCreated
|
||||||
| EventSessionUpdated
|
| EventSessionUpdated
|
||||||
| EventSessionDeleted
|
| EventSessionDeleted
|
||||||
|
|
@ -52,7 +53,6 @@ export type Event =
|
||||||
| EventInstallationUpdated
|
| EventInstallationUpdated
|
||||||
| EventInstallationUpdateAvailable
|
| EventInstallationUpdateAvailable
|
||||||
| EventFileEdited
|
| EventFileEdited
|
||||||
| EventIntegrationUpdated
|
|
||||||
| EventPermissionV2Asked
|
| EventPermissionV2Asked
|
||||||
| EventPermissionV2Replied
|
| EventPermissionV2Replied
|
||||||
| EventReferenceUpdated
|
| EventReferenceUpdated
|
||||||
|
|
@ -746,9 +746,16 @@ export type GlobalEvent = {
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
id: string
|
id: string
|
||||||
type: "catalog.model.updated"
|
type: "integration.updated"
|
||||||
properties: {
|
properties: {
|
||||||
model: ModelV2Info
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string
|
||||||
|
type: "catalog.updated"
|
||||||
|
properties: {
|
||||||
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|
@ -1257,13 +1264,6 @@ export type GlobalEvent = {
|
||||||
file: string
|
file: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
id: string
|
|
||||||
type: "integration.updated"
|
|
||||||
properties: {
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
id: string
|
id: string
|
||||||
type: "permission.v2.asked"
|
type: "permission.v2.asked"
|
||||||
|
|
@ -2836,102 +2836,6 @@ export type MoveSessionDestination = {
|
||||||
directory: string
|
directory: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ModelV2Info = {
|
|
||||||
id: string
|
|
||||||
providerID: string
|
|
||||||
family?: string
|
|
||||||
name: string
|
|
||||||
api:
|
|
||||||
| {
|
|
||||||
id: string
|
|
||||||
type: "aisdk"
|
|
||||||
package: string
|
|
||||||
url?: string
|
|
||||||
settings?: {
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
id: string
|
|
||||||
type: "native"
|
|
||||||
url?: string
|
|
||||||
settings: {
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
capabilities: {
|
|
||||||
tools: boolean
|
|
||||||
input: Array<string>
|
|
||||||
output: Array<string>
|
|
||||||
}
|
|
||||||
request: {
|
|
||||||
headers: {
|
|
||||||
[key: string]: string
|
|
||||||
}
|
|
||||||
body: {
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
generation?: {
|
|
||||||
maxTokens?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
|
||||||
temperature?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
|
||||||
topP?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
|
||||||
topK?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
|
||||||
frequencyPenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
|
||||||
presencePenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
|
||||||
seed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
|
||||||
stop?: Array<string>
|
|
||||||
}
|
|
||||||
options?: {
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
variant?: string
|
|
||||||
}
|
|
||||||
variants: Array<{
|
|
||||||
id: string
|
|
||||||
headers: {
|
|
||||||
[key: string]: string
|
|
||||||
}
|
|
||||||
body: {
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
generation?: {
|
|
||||||
maxTokens?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
|
||||||
temperature?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
|
||||||
topP?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
|
||||||
topK?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
|
||||||
frequencyPenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
|
||||||
presencePenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
|
||||||
seed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
|
||||||
stop?: Array<string>
|
|
||||||
}
|
|
||||||
options?: {
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
}>
|
|
||||||
time: {
|
|
||||||
released: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
|
||||||
}
|
|
||||||
cost: Array<{
|
|
||||||
tier?: {
|
|
||||||
type: "context"
|
|
||||||
size: number
|
|
||||||
}
|
|
||||||
input: number
|
|
||||||
output: number
|
|
||||||
cache: {
|
|
||||||
read: number
|
|
||||||
write: number
|
|
||||||
}
|
|
||||||
}>
|
|
||||||
status: "alpha" | "beta" | "deprecated" | "active"
|
|
||||||
enabled: boolean
|
|
||||||
limit: {
|
|
||||||
context: number
|
|
||||||
input?: number
|
|
||||||
output: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LocationRef = {
|
export type LocationRef = {
|
||||||
directory: string
|
directory: string
|
||||||
workspaceID?: string
|
workspaceID?: string
|
||||||
|
|
@ -4022,26 +3926,106 @@ export type SessionMessage =
|
||||||
| SessionMessageAssistant
|
| SessionMessageAssistant
|
||||||
| SessionMessageCompaction
|
| SessionMessageCompaction
|
||||||
|
|
||||||
export type ProviderV2Info = {
|
export type ModelV2Info = {
|
||||||
id: string
|
id: string
|
||||||
|
providerID: string
|
||||||
|
family?: string
|
||||||
name: string
|
name: string
|
||||||
enabled:
|
api:
|
||||||
| false
|
|
||||||
| {
|
| {
|
||||||
via: "env"
|
id: string
|
||||||
name: string
|
type: "aisdk"
|
||||||
}
|
package: string
|
||||||
| {
|
url?: string
|
||||||
via: "credential"
|
settings?: {
|
||||||
credentialID: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
via: "custom"
|
|
||||||
data: {
|
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
env: Array<string>
|
| {
|
||||||
|
id: string
|
||||||
|
type: "native"
|
||||||
|
url?: string
|
||||||
|
settings: {
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
capabilities: {
|
||||||
|
tools: boolean
|
||||||
|
input: Array<string>
|
||||||
|
output: Array<string>
|
||||||
|
}
|
||||||
|
request: {
|
||||||
|
headers: {
|
||||||
|
[key: string]: string
|
||||||
|
}
|
||||||
|
body: {
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
generation?: {
|
||||||
|
maxTokens?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||||
|
temperature?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||||
|
topP?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||||
|
topK?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||||
|
frequencyPenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||||
|
presencePenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||||
|
seed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||||
|
stop?: Array<string>
|
||||||
|
}
|
||||||
|
options?: {
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
variant?: string
|
||||||
|
}
|
||||||
|
variants: Array<{
|
||||||
|
id: string
|
||||||
|
headers: {
|
||||||
|
[key: string]: string
|
||||||
|
}
|
||||||
|
body: {
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
generation?: {
|
||||||
|
maxTokens?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||||
|
temperature?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||||
|
topP?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||||
|
topK?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||||
|
frequencyPenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||||
|
presencePenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||||
|
seed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||||
|
stop?: Array<string>
|
||||||
|
}
|
||||||
|
options?: {
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
time: {
|
||||||
|
released: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||||
|
}
|
||||||
|
cost: Array<{
|
||||||
|
tier?: {
|
||||||
|
type: "context"
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
input: number
|
||||||
|
output: number
|
||||||
|
cache: {
|
||||||
|
read: number
|
||||||
|
write: number
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
status: "alpha" | "beta" | "deprecated" | "active"
|
||||||
|
enabled: boolean
|
||||||
|
limit: {
|
||||||
|
context: number
|
||||||
|
input?: number
|
||||||
|
output: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProviderV2Info = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
disabled?: boolean
|
||||||
api:
|
api:
|
||||||
| {
|
| {
|
||||||
type: "aisdk"
|
type: "aisdk"
|
||||||
|
|
@ -4248,107 +4232,19 @@ export type EventPluginAdded = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ModelV2Info1 = {
|
export type EventIntegrationUpdated = {
|
||||||
id: string
|
id: string
|
||||||
providerID: string
|
type: "integration.updated"
|
||||||
family?: string
|
properties: {
|
||||||
name: string
|
[key: string]: unknown
|
||||||
api:
|
|
||||||
| {
|
|
||||||
id: string
|
|
||||||
type: "aisdk"
|
|
||||||
package: string
|
|
||||||
url?: string
|
|
||||||
settings?: {
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
id: string
|
|
||||||
type: "native"
|
|
||||||
url?: string
|
|
||||||
settings: {
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
capabilities: {
|
|
||||||
tools: boolean
|
|
||||||
input: Array<string>
|
|
||||||
output: Array<string>
|
|
||||||
}
|
|
||||||
request: {
|
|
||||||
headers: {
|
|
||||||
[key: string]: string
|
|
||||||
}
|
|
||||||
body: {
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
generation?: {
|
|
||||||
maxTokens?: number | "NaN" | "Infinity" | "-Infinity"
|
|
||||||
temperature?: number | "NaN" | "Infinity" | "-Infinity"
|
|
||||||
topP?: number | "NaN" | "Infinity" | "-Infinity"
|
|
||||||
topK?: number | "NaN" | "Infinity" | "-Infinity"
|
|
||||||
frequencyPenalty?: number | "NaN" | "Infinity" | "-Infinity"
|
|
||||||
presencePenalty?: number | "NaN" | "Infinity" | "-Infinity"
|
|
||||||
seed?: number | "NaN" | "Infinity" | "-Infinity"
|
|
||||||
stop?: Array<string>
|
|
||||||
}
|
|
||||||
options?: {
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
variant?: string
|
|
||||||
}
|
|
||||||
variants: Array<{
|
|
||||||
id: string
|
|
||||||
headers: {
|
|
||||||
[key: string]: string
|
|
||||||
}
|
|
||||||
body: {
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
generation?: {
|
|
||||||
maxTokens?: number | "NaN" | "Infinity" | "-Infinity"
|
|
||||||
temperature?: number | "NaN" | "Infinity" | "-Infinity"
|
|
||||||
topP?: number | "NaN" | "Infinity" | "-Infinity"
|
|
||||||
topK?: number | "NaN" | "Infinity" | "-Infinity"
|
|
||||||
frequencyPenalty?: number | "NaN" | "Infinity" | "-Infinity"
|
|
||||||
presencePenalty?: number | "NaN" | "Infinity" | "-Infinity"
|
|
||||||
seed?: number | "NaN" | "Infinity" | "-Infinity"
|
|
||||||
stop?: Array<string>
|
|
||||||
}
|
|
||||||
options?: {
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
}>
|
|
||||||
time: {
|
|
||||||
released: number | "NaN" | "Infinity" | "-Infinity"
|
|
||||||
}
|
|
||||||
cost: Array<{
|
|
||||||
tier?: {
|
|
||||||
type: "context"
|
|
||||||
size: number
|
|
||||||
}
|
|
||||||
input: number
|
|
||||||
output: number
|
|
||||||
cache: {
|
|
||||||
read: number
|
|
||||||
write: number
|
|
||||||
}
|
|
||||||
}>
|
|
||||||
status: "alpha" | "beta" | "deprecated" | "active"
|
|
||||||
enabled: boolean
|
|
||||||
limit: {
|
|
||||||
context: number
|
|
||||||
input?: number
|
|
||||||
output: number
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EventCatalogModelUpdated = {
|
export type EventCatalogUpdated = {
|
||||||
id: string
|
id: string
|
||||||
type: "catalog.model.updated"
|
type: "catalog.updated"
|
||||||
properties: {
|
properties: {
|
||||||
model: ModelV2Info1
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4902,14 +4798,6 @@ export type EventFileEdited = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EventIntegrationUpdated = {
|
|
||||||
id: string
|
|
||||||
type: "integration.updated"
|
|
||||||
properties: {
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EventPermissionV2Asked = {
|
export type EventPermissionV2Asked = {
|
||||||
id: string
|
id: string
|
||||||
type: "permission.v2.asked"
|
type: "permission.v2.asked"
|
||||||
|
|
@ -10251,7 +10139,12 @@ export type V2CredentialRemoveData = {
|
||||||
path: {
|
path: {
|
||||||
credentialID: string
|
credentialID: string
|
||||||
}
|
}
|
||||||
query?: never
|
query?: {
|
||||||
|
location?: {
|
||||||
|
directory?: string
|
||||||
|
workspace?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
url: "/api/credential/{credentialID}"
|
url: "/api/credential/{credentialID}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -10284,7 +10177,12 @@ export type V2CredentialUpdateData = {
|
||||||
path: {
|
path: {
|
||||||
credentialID: string
|
credentialID: string
|
||||||
}
|
}
|
||||||
query?: never
|
query?: {
|
||||||
|
location?: {
|
||||||
|
directory?: string
|
||||||
|
workspace?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
url: "/api/credential/{credentialID}"
|
url: "/api/credential/{credentialID}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,38 @@
|
||||||
import { Credential } from "@opencode-ai/core/credential"
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||||
|
import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location"
|
||||||
|
|
||||||
export const CredentialGroup = HttpApiGroup.make("server.credential")
|
export const CredentialGroup = HttpApiGroup.make("server.credential")
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.patch("credential.update", "/api/credential/:credentialID", {
|
HttpApiEndpoint.patch("credential.update", "/api/credential/:credentialID", {
|
||||||
params: { credentialID: Credential.ID },
|
params: { credentialID: Credential.ID },
|
||||||
|
query: LocationQuery,
|
||||||
payload: Schema.Struct({ label: Schema.String }),
|
payload: Schema.Struct({ label: Schema.String }),
|
||||||
success: HttpApiSchema.NoContent,
|
success: HttpApiSchema.NoContent,
|
||||||
}).annotateMerge(
|
})
|
||||||
OpenApi.annotations({
|
.annotateMerge(locationQueryOpenApi)
|
||||||
identifier: "v2.credential.update",
|
.annotateMerge(
|
||||||
summary: "Update credential",
|
OpenApi.annotations({
|
||||||
description: "Update a stored credential label.",
|
identifier: "v2.credential.update",
|
||||||
}),
|
summary: "Update credential",
|
||||||
),
|
description: "Update a stored credential label.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.delete("credential.remove", "/api/credential/:credentialID", {
|
HttpApiEndpoint.delete("credential.remove", "/api/credential/:credentialID", {
|
||||||
params: { credentialID: Credential.ID },
|
params: { credentialID: Credential.ID },
|
||||||
|
query: LocationQuery,
|
||||||
success: HttpApiSchema.NoContent,
|
success: HttpApiSchema.NoContent,
|
||||||
}).annotateMerge(
|
})
|
||||||
OpenApi.annotations({
|
.annotateMerge(locationQueryOpenApi)
|
||||||
identifier: "v2.credential.remove",
|
.annotateMerge(
|
||||||
summary: "Remove credential",
|
OpenApi.annotations({
|
||||||
description: "Remove a stored integration credential.",
|
identifier: "v2.credential.remove",
|
||||||
}),
|
summary: "Remove credential",
|
||||||
),
|
description: "Remove a stored integration credential.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
.middleware(LocationMiddleware)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Credential } from "@opencode-ai/core/credential"
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||||
import { Api } from "../api"
|
import { Api } from "../api"
|
||||||
|
|
@ -8,14 +8,14 @@ export const CredentialHandler = HttpApiBuilder.group(Api, "server.credential",
|
||||||
.handle(
|
.handle(
|
||||||
"credential.update",
|
"credential.update",
|
||||||
Effect.fn(function* (ctx) {
|
Effect.fn(function* (ctx) {
|
||||||
yield* (yield* Credential.Service).update(ctx.params.credentialID, { label: ctx.payload.label })
|
yield* (yield* Integration.Service).connection.update(ctx.params.credentialID, { label: ctx.payload.label })
|
||||||
return HttpApiSchema.NoContent.make()
|
return HttpApiSchema.NoContent.make()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.handle(
|
.handle(
|
||||||
"credential.remove",
|
"credential.remove",
|
||||||
Effect.fn(function* (ctx) {
|
Effect.fn(function* (ctx) {
|
||||||
yield* (yield* Credential.Service).remove(ctx.params.credentialID)
|
yield* (yield* Integration.Service).connection.remove(ctx.params.credentialID)
|
||||||
return HttpApiSchema.NoContent.make()
|
return HttpApiSchema.NoContent.make()
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
|
||||||
Effect.fn(function* (ctx) {
|
Effect.fn(function* (ctx) {
|
||||||
const service = yield* Integration.Service
|
const service = yield* Integration.Service
|
||||||
yield* authorize(
|
yield* authorize(
|
||||||
service.connect.key({
|
service.connection.key({
|
||||||
integrationID: ctx.params.integrationID,
|
integrationID: ctx.params.integrationID,
|
||||||
key: ctx.payload.key,
|
key: ctx.payload.key,
|
||||||
label: ctx.payload.label,
|
label: ctx.payload.label,
|
||||||
|
|
@ -53,7 +53,7 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
|
||||||
const service = yield* Integration.Service
|
const service = yield* Integration.Service
|
||||||
return yield* response(
|
return yield* response(
|
||||||
authorize(
|
authorize(
|
||||||
service.connect.oauth({
|
service.connection.oauth({
|
||||||
integrationID: ctx.params.integrationID,
|
integrationID: ctx.params.integrationID,
|
||||||
methodID: ctx.payload.methodID,
|
methodID: ctx.payload.methodID,
|
||||||
inputs: ctx.payload.inputs,
|
inputs: ctx.payload.inputs,
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
|
|
||||||
event.subscribe((event, metadata) => {
|
event.subscribe((event, metadata) => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
|
case "catalog.updated":
|
||||||
|
void Promise.all([
|
||||||
|
result.location.model.refresh({ directory: metadata.directory, workspaceID: metadata.workspace }),
|
||||||
|
result.location.provider.refresh({ directory: metadata.directory, workspaceID: metadata.workspace }),
|
||||||
|
])
|
||||||
|
break
|
||||||
case "session.next.agent.switched":
|
case "session.next.agent.switched":
|
||||||
message.update(event.properties.sessionID, (draft) => {
|
message.update(event.properties.sessionID, (draft) => {
|
||||||
message.prepend(draft, {
|
message.prepend(draft, {
|
||||||
|
|
@ -424,7 +430,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
void result.location.reference.refresh()
|
void result.location.reference.refresh()
|
||||||
break
|
break
|
||||||
case "integration.updated":
|
case "integration.updated":
|
||||||
void result.location.integration.refresh({ directory: metadata.directory, workspaceID: metadata.workspace })
|
void Promise.all([
|
||||||
|
result.location.integration.refresh({ directory: metadata.directory, workspaceID: metadata.workspace }),
|
||||||
|
result.location.model.refresh({ directory: metadata.directory, workspaceID: metadata.workspace }),
|
||||||
|
result.location.provider.refresh({ directory: metadata.directory, workspaceID: metadata.workspace }),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -94,14 +94,22 @@ test("refreshes resources into reactive getters", async () => {
|
||||||
|
|
||||||
test("refreshes integrations after integration updates", async () => {
|
test("refreshes integrations after integration updates", async () => {
|
||||||
const events = createEventSource()
|
const events = createEventSource()
|
||||||
let requests = 0
|
const requests = { integration: 0, model: 0, provider: 0 }
|
||||||
const calls = createFetch((url) => {
|
const calls = createFetch((url) => {
|
||||||
|
if (url.pathname === "/api/model") {
|
||||||
|
requests.model++
|
||||||
|
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/provider") {
|
||||||
|
requests.provider++
|
||||||
|
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
|
||||||
|
}
|
||||||
if (url.pathname !== "/api/integration") return
|
if (url.pathname !== "/api/integration") return
|
||||||
requests++
|
requests.integration++
|
||||||
return json({
|
return json({
|
||||||
location: { directory, project: { id: "proj_test", directory } },
|
location: { directory, project: { id: "proj_test", directory } },
|
||||||
data:
|
data:
|
||||||
requests === 1
|
requests.integration === 1
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
|
|
@ -140,15 +148,53 @@ test("refreshes integrations after integration updates", async () => {
|
||||||
await mounted
|
await mounted
|
||||||
await wait(() => data.location.integration.list() !== undefined)
|
await wait(() => data.location.integration.list() !== undefined)
|
||||||
expect(data.location.integration.list()).toEqual([])
|
expect(data.location.integration.list()).toEqual([])
|
||||||
|
const before = { ...requests }
|
||||||
|
|
||||||
emitEvent(events, { id: "evt_integration", type: "integration.updated", properties: {} })
|
emitEvent(events, { id: "evt_integration", type: "integration.updated", properties: {} })
|
||||||
await wait(() => data.location.integration.list()?.length === 1)
|
await wait(() => data.location.integration.list()?.length === 1)
|
||||||
|
await wait(() => requests.model > before.model && requests.provider > before.provider)
|
||||||
expect(data.location.integration.list()?.[0]).toMatchObject({ id: "openai", name: "OpenAI" })
|
expect(data.location.integration.list()?.[0]).toMatchObject({ id: "openai", name: "OpenAI" })
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("refreshes effective catalog data after catalog updates", async () => {
|
||||||
|
const events = createEventSource()
|
||||||
|
const requests = { model: 0, provider: 0 }
|
||||||
|
const calls = createFetch((url) => {
|
||||||
|
if (url.pathname === "/api/model") {
|
||||||
|
requests.model++
|
||||||
|
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/provider") {
|
||||||
|
requests.provider++
|
||||||
|
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<TestTuiContexts>
|
||||||
|
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||||
|
<ProjectProvider>
|
||||||
|
<DataProvider>
|
||||||
|
<box />
|
||||||
|
</DataProvider>
|
||||||
|
</ProjectProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
</TestTuiContexts>
|
||||||
|
))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await wait(() => requests.model > 0 && requests.provider > 0)
|
||||||
|
const before = { ...requests }
|
||||||
|
emitEvent(events, { id: "evt_catalog", type: "catalog.updated", properties: {} })
|
||||||
|
await wait(() => requests.model > before.model && requests.provider > before.provider)
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("refreshes references after updates", async () => {
|
test("refreshes references after updates", async () => {
|
||||||
const events = createEventSource()
|
const events = createEventSource()
|
||||||
let requests = 0
|
let requests = 0
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue