feat(core): map providers to integrations (#33562)
This commit is contained in:
parent
c556bddda3
commit
4898263dec
@ -73,7 +73,7 @@ export const layer = Layer.effect(
|
|||||||
if (provider.disabled) return false
|
if (provider.disabled) return false
|
||||||
if (typeof provider.request.body.apiKey === "string") return true
|
if (typeof provider.request.body.apiKey === "string") return true
|
||||||
if (integration?.connections.length) return true
|
if (integration?.connections.length) return true
|
||||||
return !integration
|
return provider.integrationID === undefined && !integration
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
|
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
|
||||||
@ -184,7 +184,7 @@ export const layer = Layer.effect(
|
|||||||
available: Effect.fn("CatalogV2.provider.available")(function* () {
|
available: Effect.fn("CatalogV2.provider.available")(function* () {
|
||||||
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
|
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
|
||||||
return (yield* result.provider.all()).filter((provider) =>
|
return (yield* result.provider.all()).filter((provider) =>
|
||||||
available(provider, active.get(Integration.ID.make(provider.id))),
|
available(provider, active.get(provider.integrationID ?? Integration.ID.make(provider.id))),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -10,58 +10,13 @@ import { Integration } from "../../integration"
|
|||||||
import { ModelV2 } from "../../model"
|
import { ModelV2 } from "../../model"
|
||||||
import { ModelRequest } from "../../model-request"
|
import { ModelRequest } from "../../model-request"
|
||||||
import { ProviderV2 } from "../../provider"
|
import { ProviderV2 } from "../../provider"
|
||||||
|
import { ConfigProviderV1 } from "../../v1/config/provider"
|
||||||
|
import { ConfigV1 } from "../../v1/config/config"
|
||||||
|
|
||||||
const defaultServer = "https://console.opencode.ai"
|
const defaultServer = "https://console.opencode.ai"
|
||||||
const clientID = "opencode-cli"
|
const clientID = "opencode-cli"
|
||||||
const methodID = Integration.MethodID.make("device")
|
const methodID = Integration.MethodID.make("device")
|
||||||
const RemoteRequest = Schema.Struct({
|
const RemoteResponse = Schema.Struct({ config: ConfigV1.Info })
|
||||||
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
|
||||||
body: Schema.Record(Schema.String, Schema.Any).pipe(Schema.optional),
|
|
||||||
})
|
|
||||||
const RemoteModelApi = Schema.Union([
|
|
||||||
Schema.Struct({ id: ModelV2.ID.pipe(Schema.optional), ...ProviderV2.AISDK.fields }),
|
|
||||||
Schema.Struct({ id: ModelV2.ID.pipe(Schema.optional), ...ProviderV2.Native.fields }),
|
|
||||||
Schema.Struct({ id: ModelV2.ID }),
|
|
||||||
])
|
|
||||||
const RemoteCost = Schema.Struct({
|
|
||||||
tier: Schema.Struct({ type: Schema.Literal("context"), size: Schema.Int }).pipe(Schema.optional),
|
|
||||||
input: Schema.Finite,
|
|
||||||
output: Schema.Finite,
|
|
||||||
cache: Schema.Struct({
|
|
||||||
read: Schema.Finite.pipe(Schema.optional),
|
|
||||||
write: Schema.Finite.pipe(Schema.optional),
|
|
||||||
}).pipe(Schema.optional),
|
|
||||||
})
|
|
||||||
const RemoteModel = Schema.Struct({
|
|
||||||
family: ModelV2.Family.pipe(Schema.optional),
|
|
||||||
name: Schema.String.pipe(Schema.optional),
|
|
||||||
api: RemoteModelApi.pipe(Schema.optional),
|
|
||||||
capabilities: ModelV2.Capabilities.pipe(Schema.optional),
|
|
||||||
request: Schema.Struct({ ...RemoteRequest.fields, variant: Schema.String.pipe(Schema.optional) }).pipe(
|
|
||||||
Schema.optional,
|
|
||||||
),
|
|
||||||
variants: Schema.Struct({
|
|
||||||
id: ModelV2.VariantID,
|
|
||||||
...RemoteRequest.fields,
|
|
||||||
}).pipe(Schema.Array, Schema.optional),
|
|
||||||
cost: Schema.Union([RemoteCost, Schema.Array(RemoteCost)]).pipe(Schema.optional),
|
|
||||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
|
||||||
limit: Schema.Struct({
|
|
||||||
context: Schema.Int.pipe(Schema.optional),
|
|
||||||
input: Schema.Int.pipe(Schema.optional),
|
|
||||||
output: Schema.Int.pipe(Schema.optional),
|
|
||||||
}).pipe(Schema.optional),
|
|
||||||
})
|
|
||||||
const RemoteProvider = Schema.Struct({
|
|
||||||
name: Schema.String.pipe(Schema.optional),
|
|
||||||
api: ProviderV2.Api.pipe(Schema.optional),
|
|
||||||
request: RemoteRequest.pipe(Schema.optional),
|
|
||||||
models: Schema.Record(Schema.String, RemoteModel).pipe(Schema.optional),
|
|
||||||
})
|
|
||||||
const RemoteConfig = Schema.Struct({
|
|
||||||
providers: Schema.Record(Schema.String, RemoteProvider),
|
|
||||||
})
|
|
||||||
const RemoteResponse = Schema.Struct({ config: RemoteConfig })
|
|
||||||
const Device = Schema.Struct({
|
const Device = Schema.Struct({
|
||||||
device_code: Schema.String,
|
device_code: Schema.String,
|
||||||
user_code: Schema.String,
|
user_code: Schema.String,
|
||||||
@ -125,7 +80,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
|
|||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const http = yield* HttpClient.HttpClient
|
const http = yield* HttpClient.HttpClient
|
||||||
let connected = false
|
let connected = false
|
||||||
let providers: typeof RemoteConfig.Type.providers | undefined
|
let providers: typeof ConfigV1.Info.Type.provider | undefined
|
||||||
|
|
||||||
const load = Effect.fn("OpencodePlugin.load")(function* () {
|
const load = Effect.fn("OpencodePlugin.load")(function* () {
|
||||||
const connection = yield* ctx.integration.connection.active("opencode")
|
const connection = yield* ctx.integration.connection.active("opencode")
|
||||||
@ -154,59 +109,60 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
|
|||||||
yield* ctx.catalog.transform((catalog) => {
|
yield* ctx.catalog.transform((catalog) => {
|
||||||
for (const [providerID, item] of Object.entries(providers ?? {})) {
|
for (const [providerID, item] of Object.entries(providers ?? {})) {
|
||||||
catalog.provider.update(providerID, (provider) => {
|
catalog.provider.update(providerID, (provider) => {
|
||||||
|
provider.integrationID = Integration.ID.make("opencode")
|
||||||
if (item.name !== undefined) provider.name = item.name
|
if (item.name !== undefined) provider.name = item.name
|
||||||
if (item.api !== undefined) provider.api = { ...item.api }
|
provider.api = item.npm
|
||||||
if (item.request !== undefined) {
|
? { type: "aisdk", package: item.npm, url: item.api }
|
||||||
Object.assign(provider.request.headers, item.request.headers)
|
: { type: "native", url: item.api, settings: {} }
|
||||||
Object.assign(provider.request.body, item.request.body)
|
Object.assign(provider.request.headers, item.options?.headers)
|
||||||
}
|
Object.assign(provider.request.body, withoutCredentials(item.options))
|
||||||
})
|
})
|
||||||
const providerApi = catalog.provider.get(providerID)?.provider.api
|
|
||||||
const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined
|
const modelIDs = new Set(Object.keys(item.models ?? {}))
|
||||||
|
for (const model of catalog.provider.get(providerID)?.models.values() ?? []) {
|
||||||
|
if (!modelIDs.has(model.id)) catalog.model.remove(providerID, model.id)
|
||||||
|
}
|
||||||
|
|
||||||
for (const [modelID, config] of Object.entries(item.models ?? {})) {
|
for (const [modelID, config] of Object.entries(item.models ?? {})) {
|
||||||
catalog.model.update(providerID, modelID, (model) => {
|
catalog.model.update(providerID, modelID, (model) => {
|
||||||
if (config.family !== undefined) model.family = config.family
|
if (config.family !== undefined) model.family = config.family
|
||||||
if (config.name !== undefined) model.name = config.name
|
if (config.name !== undefined) model.name = config.name
|
||||||
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
|
if (config.id !== undefined) model.api.id = config.id
|
||||||
const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage
|
if (config.provider !== undefined) {
|
||||||
if (config.capabilities !== undefined) {
|
model.api = config.provider.npm
|
||||||
model.capabilities = {
|
? {
|
||||||
tools: config.capabilities.tools,
|
id: model.api.id,
|
||||||
input: [...config.capabilities.input],
|
type: "aisdk",
|
||||||
output: [...config.capabilities.output],
|
package: config.provider.npm,
|
||||||
|
url: config.provider.api,
|
||||||
}
|
}
|
||||||
|
: { id: model.api.id, type: "native", url: config.provider.api, settings: {} }
|
||||||
}
|
}
|
||||||
if (config.request !== undefined) {
|
if (config.tool_call !== undefined) model.capabilities.tools = config.tool_call
|
||||||
|
if (config.modalities?.input !== undefined) model.capabilities.input = [...config.modalities.input]
|
||||||
|
if (config.modalities?.output !== undefined) model.capabilities.output = [...config.modalities.output]
|
||||||
|
const packageName = config.provider?.npm ?? item.npm
|
||||||
ModelRequest.assign(model.request, {
|
ModelRequest.assign(model.request, {
|
||||||
headers: config.request.headers,
|
headers: config.headers,
|
||||||
...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}),
|
...ModelRequest.normalizeAiSdkOptions(packageName, withoutCredentials(config.options)),
|
||||||
})
|
})
|
||||||
if (config.request.variant !== undefined) model.request.variant = config.request.variant
|
|
||||||
}
|
|
||||||
if (config.variants !== undefined) {
|
if (config.variants !== undefined) {
|
||||||
for (const variant of config.variants) {
|
model.variants = Object.entries(config.variants).map(([id, options]) => ({
|
||||||
let existing = model.variants.find((item) => item.id === variant.id)
|
id: ModelV2.VariantID.make(id),
|
||||||
if (!existing) {
|
headers: { ...(options.headers ?? {}) },
|
||||||
existing = { id: variant.id, headers: {}, body: {}, generation: {}, options: {} }
|
...ModelRequest.normalizeAiSdkOptions(packageName, withoutCredentials(options)),
|
||||||
model.variants.push(existing)
|
|
||||||
}
|
|
||||||
ModelRequest.assign(existing, {
|
|
||||||
headers: variant.headers,
|
|
||||||
...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (config.cost !== undefined) {
|
|
||||||
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
|
|
||||||
tier: cost.tier && { ...cost.tier },
|
|
||||||
input: cost.input,
|
|
||||||
output: cost.output,
|
|
||||||
cache: { read: cost.cache?.read ?? 0, write: cost.cache?.write ?? 0 },
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
if (config.disabled !== undefined) model.enabled = !config.disabled
|
if (config.release_date !== undefined) {
|
||||||
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
|
const released = Date.parse(config.release_date)
|
||||||
|
model.time.released = Number.isFinite(released) ? released : 0
|
||||||
|
}
|
||||||
|
if (config.cost !== undefined) {
|
||||||
|
model.cost = remoteCost(config.cost)
|
||||||
|
}
|
||||||
|
model.status = config.status ?? "active"
|
||||||
|
model.enabled = config.status !== "deprecated"
|
||||||
|
if (config.limit !== undefined) model.limit = { ...config.limit }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -252,12 +208,37 @@ function fetchProviders(http: HttpClient.HttpClient, value: CredentialValue) {
|
|||||||
if (response.status === 404) return Effect.succeed(undefined)
|
if (response.status === 404) return Effect.succeed(undefined)
|
||||||
return HttpClientResponse.filterStatusOk(response).pipe(
|
return HttpClientResponse.filterStatusOk(response).pipe(
|
||||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
|
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
|
||||||
Effect.map((remote) => remote.config.providers),
|
Effect.map((remote) => remote.config.provider),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withoutCredentials(body: Readonly<Record<string, unknown>> | undefined) {
|
||||||
|
return Object.fromEntries(Object.entries(body ?? {}).filter(([key]) => key !== "apiKey" && key !== "headers"))
|
||||||
|
}
|
||||||
|
|
||||||
|
function remoteCost(input: NonNullable<(typeof ConfigProviderV1.Model.Type)["cost"]>) {
|
||||||
|
const base = {
|
||||||
|
input: input.input,
|
||||||
|
output: input.output,
|
||||||
|
cache: { read: input.cache_read ?? 0, write: input.cache_write ?? 0 },
|
||||||
|
}
|
||||||
|
if (!input.context_over_200k) return [base]
|
||||||
|
return [
|
||||||
|
base,
|
||||||
|
{
|
||||||
|
tier: { type: "context" as const, size: 200_000 },
|
||||||
|
input: input.context_over_200k.input,
|
||||||
|
output: input.context_over_200k.output,
|
||||||
|
cache: {
|
||||||
|
read: input.context_over_200k.cache_read ?? 0,
|
||||||
|
write: input.context_over_200k.cache_write ?? 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
function poll(http: HttpClient.HttpClient, server: string, deviceCode: string, interval: Duration.Duration) {
|
function poll(http: HttpClient.HttpClient, server: string, deviceCode: string, interval: Duration.Duration) {
|
||||||
const loop = (wait: Duration.Duration): Effect.Effect<Credential.OAuth, unknown> =>
|
const loop = (wait: Duration.Duration): Effect.Effect<Credential.OAuth, unknown> =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
export * as ProviderV2 from "./provider"
|
export * as ProviderV2 from "./provider"
|
||||||
|
|
||||||
import { withStatics } from "./schema"
|
import { withStatics } from "./schema"
|
||||||
|
import { IntegrationSchema } from "./integration/schema"
|
||||||
import { Schema, Types } from "effect"
|
import { Schema, Types } from "effect"
|
||||||
|
|
||||||
export const ID = Schema.String.pipe(
|
export const ID = Schema.String.pipe(
|
||||||
@ -49,6 +50,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,
|
||||||
|
integrationID: IntegrationSchema.ID.pipe(Schema.optional),
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||||
api: Api,
|
api: Api,
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import { produce } from "immer"
|
|||||||
import { Catalog } from "../../catalog"
|
import { Catalog } from "../../catalog"
|
||||||
import { Credential } from "../../credential"
|
import { Credential } from "../../credential"
|
||||||
import { Integration } from "../../integration"
|
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 { ProviderV2 } from "../../provider"
|
import { ProviderV2 } from "../../provider"
|
||||||
@ -65,7 +64,12 @@ export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Error = ModelNotSelectedError | ModelUnavailableError | VariantUnavailableError | UnsupportedApiError
|
export type Error =
|
||||||
|
| ModelNotSelectedError
|
||||||
|
| ModelUnavailableError
|
||||||
|
| VariantUnavailableError
|
||||||
|
| UnsupportedApiError
|
||||||
|
| Integration.AuthorizationError
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Model, Error>
|
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Model, Error>
|
||||||
@ -76,12 +80,11 @@ 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, connection?: IntegrationConnection.Info, credential?: Credential.Info) => {
|
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
|
||||||
if (credential?.value.type === "key") return Auth.value(credential.value.key)
|
if (credential?.type === "key") return Auth.value(credential.key)
|
||||||
if (credential?.value.type === "oauth") return Auth.value(credential.value.access)
|
if (credential?.type === "oauth") return Auth.value(credential.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 connection?.type === "env" ? Auth.config(connection.name) : undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
|
const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
|
||||||
@ -130,16 +133,15 @@ const apiName = (model: ModelV2.Info) =>
|
|||||||
|
|
||||||
export const fromCatalogModel = (
|
export const fromCatalogModel = (
|
||||||
model: ModelV2.Info,
|
model: ModelV2.Info,
|
||||||
connection?: IntegrationConnection.Info,
|
credential?: Credential.Value,
|
||||||
credential?: Credential.Info,
|
|
||||||
): Effect.Effect<Model, UnsupportedApiError> => {
|
): Effect.Effect<Model, UnsupportedApiError> => {
|
||||||
const resolved =
|
const resolved =
|
||||||
credential?.value.metadata === undefined
|
credential?.metadata === undefined
|
||||||
? model
|
? model
|
||||||
: produce(model, (draft) => {
|
: produce(model, (draft) => {
|
||||||
Object.assign(draft.request.body, credential.value.metadata)
|
Object.assign(draft.request.body, credential.metadata)
|
||||||
})
|
})
|
||||||
const key = apiKey(resolved, connection, credential)
|
const key = apiKey(resolved, credential)
|
||||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") {
|
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") {
|
||||||
return Effect.succeed(
|
return Effect.succeed(
|
||||||
withDefaults(resolved, OpenAIResponses.route)
|
withDefaults(resolved, OpenAIResponses.route)
|
||||||
@ -173,11 +175,10 @@ export const fromCatalogModel = (
|
|||||||
export const resolve = (
|
export const resolve = (
|
||||||
session: SessionSchema.Info,
|
session: SessionSchema.Info,
|
||||||
model: ModelV2.Info,
|
model: ModelV2.Info,
|
||||||
connection?: IntegrationConnection.Info,
|
credential?: Credential.Value,
|
||||||
credential?: Credential.Info,
|
|
||||||
) =>
|
) =>
|
||||||
withVariant(model, session.model?.variant).pipe(
|
withVariant(model, session.model?.variant).pipe(
|
||||||
Effect.flatMap((model) => fromCatalogModel(model, connection, credential)),
|
Effect.flatMap((model) => fromCatalogModel(model, credential)),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const supported = (model: ModelV2.Info) =>
|
export const supported = (model: ModelV2.Info) =>
|
||||||
@ -191,7 +192,6 @@ 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 integrations = yield* Integration.Service
|
||||||
return Service.of({
|
return Service.of({
|
||||||
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
|
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
|
||||||
@ -210,12 +210,14 @@ export const locationLayer = Layer.effect(
|
|||||||
modelID: session.model.id,
|
modelID: session.model.id,
|
||||||
})
|
})
|
||||||
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
|
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
|
||||||
const connection = yield* integrations.connection.active(Integration.ID.make(selected.providerID))
|
const provider = yield* catalog.provider.get(selected.providerID)
|
||||||
|
const connection = yield* integrations.connection.active(
|
||||||
|
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
||||||
|
)
|
||||||
return yield* resolve(
|
return yield* resolve(
|
||||||
session,
|
session,
|
||||||
selected,
|
selected,
|
||||||
connection,
|
connection ? yield* integrations.connection.resolve(connection) : undefined,
|
||||||
connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined,
|
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -76,6 +76,35 @@ describe("CatalogV2", () => {
|
|||||||
}).pipe(Effect.provide(layer))
|
}).pipe(Effect.provide(layer))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.effect("derives availability from a provider's integration", () => {
|
||||||
|
const integrationID = Integration.ID.make("gateway")
|
||||||
|
const providerID = ProviderV2.ID.make("remote")
|
||||||
|
const layer = Catalog.locationLayer.pipe(
|
||||||
|
Layer.fresh,
|
||||||
|
Layer.provideMerge(EventV2.defaultLayer),
|
||||||
|
Layer.provideMerge(locationLayer),
|
||||||
|
Layer.provideMerge(Credential.defaultLayer.pipe(Layer.fresh)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
yield* (yield* Integration.Service).transform((editor) => editor.update(integrationID, () => {}))
|
||||||
|
yield* catalog.transform((editor) =>
|
||||||
|
editor.provider.update(providerID, (provider) => {
|
||||||
|
provider.integrationID = integrationID
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(yield* catalog.provider.available()).toEqual([])
|
||||||
|
|
||||||
|
yield* (yield* Credential.Service).create({
|
||||||
|
integrationID,
|
||||||
|
value: new Credential.Key({ type: "key", key: "secret" }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([providerID])
|
||||||
|
}).pipe(Effect.provide(layer))
|
||||||
|
})
|
||||||
|
|
||||||
it.effect("projects environment connections without a catalog plugin", () =>
|
it.effect("projects environment connections without a catalog plugin", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
|
|||||||
@ -73,13 +73,34 @@ describe("OpencodePlugin", () => {
|
|||||||
port: 0,
|
port: 0,
|
||||||
fetch: (request) => {
|
fetch: (request) => {
|
||||||
authorization.push(request.headers.get("authorization"))
|
authorization.push(request.headers.get("authorization"))
|
||||||
|
const origin = new URL(request.url).origin
|
||||||
return Response.json({
|
return Response.json({
|
||||||
config: {
|
config: {
|
||||||
providers: {
|
enterprise: { url: origin },
|
||||||
|
provider: {
|
||||||
remote: {
|
remote: {
|
||||||
name: "Remote",
|
name: "Remote",
|
||||||
|
npm: "@ai-sdk/openai-compatible",
|
||||||
|
api: `${origin}/v1`,
|
||||||
|
env: ["REMOTE_API_KEY"],
|
||||||
|
options: {
|
||||||
|
apiKey: "{env:REMOTE_API_KEY}",
|
||||||
|
headers: { "x-org-id": "org" },
|
||||||
|
custom: "value",
|
||||||
|
},
|
||||||
models: {
|
models: {
|
||||||
model: { name: "Remote Model" },
|
model: {
|
||||||
|
name: "Remote Model",
|
||||||
|
family: "remote",
|
||||||
|
release_date: "2026-01-02",
|
||||||
|
tool_call: true,
|
||||||
|
modalities: { input: ["text", "image"], output: ["text"] },
|
||||||
|
options: { apiKey: "model-secret", temperature: 0.5 },
|
||||||
|
variants: { high: { apiKey: "variant-secret", temperature: 0.2 } },
|
||||||
|
cost: { input: 1, output: 2, cache_read: 0.1 },
|
||||||
|
limit: { context: 1000, output: 100 },
|
||||||
|
},
|
||||||
|
disabled: { name: "Disabled", status: "deprecated" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -92,6 +113,11 @@ describe("OpencodePlugin", () => {
|
|||||||
({ authorization, server }) =>
|
({ authorization, server }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const credentials = yield* Credential.Service
|
const credentials = yield* Credential.Service
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
yield* catalog.transform((draft) => {
|
||||||
|
draft.provider.update(ProviderV2.ID.make("remote"), () => {})
|
||||||
|
draft.model.update(ProviderV2.ID.make("remote"), ModelV2.ID.make("stale"), () => {})
|
||||||
|
})
|
||||||
yield* credentials.create({
|
yield* credentials.create({
|
||||||
integrationID: Integration.ID.make("opencode"),
|
integrationID: Integration.ID.make("opencode"),
|
||||||
value: new Credential.Key({
|
value: new Credential.Key({
|
||||||
@ -103,11 +129,42 @@ describe("OpencodePlugin", () => {
|
|||||||
|
|
||||||
yield* addPlugin()
|
yield* addPlugin()
|
||||||
|
|
||||||
const catalog = yield* Catalog.Service
|
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("remote")))
|
||||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("remote")))?.name).toBe("Remote")
|
expect(provider).toMatchObject({
|
||||||
expect((yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("model")))?.name).toBe(
|
name: "Remote",
|
||||||
"Remote Model",
|
integrationID: "opencode",
|
||||||
)
|
api: {
|
||||||
|
type: "aisdk",
|
||||||
|
package: "@ai-sdk/openai-compatible",
|
||||||
|
url: `${server.url.origin}/v1`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(provider.request).toEqual({ headers: { "x-org-id": "org" }, body: { custom: "value" } })
|
||||||
|
expect(yield* (yield* Integration.Service).get(Integration.ID.make("remote"))).toBeUndefined()
|
||||||
|
|
||||||
|
const model = required(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("model")))
|
||||||
|
expect(model).toMatchObject({
|
||||||
|
name: "Remote Model",
|
||||||
|
family: "remote",
|
||||||
|
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||||
|
cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }],
|
||||||
|
limit: { context: 1000, output: 100 },
|
||||||
|
})
|
||||||
|
expect(model.request).toMatchObject({ body: { custom: "value" }, generation: { temperature: 0.5 } })
|
||||||
|
expect(model.request.body).toEqual({ custom: "value" })
|
||||||
|
expect(model.variants).toEqual([
|
||||||
|
{
|
||||||
|
id: ModelV2.VariantID.make("high"),
|
||||||
|
headers: {},
|
||||||
|
body: {},
|
||||||
|
generation: { temperature: 0.2 },
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(
|
||||||
|
required(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("disabled"))).enabled,
|
||||||
|
).toBe(false)
|
||||||
|
expect(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("stale"))).toBeUndefined()
|
||||||
expect(authorization).toContain("Bearer secret")
|
expect(authorization).toContain("Bearer secret")
|
||||||
}),
|
}),
|
||||||
({ server }) => Effect.promise(() => server.stop(true)),
|
({ server }) => Effect.promise(() => server.stop(true)),
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { LLM } from "@opencode-ai/llm"
|
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 { DateTime, Effect } from "effect"
|
||||||
import { Headers } from "effect/unstable/http"
|
import { Headers } from "effect/unstable/http"
|
||||||
import { Credential } from "@opencode-ai/core/credential"
|
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"
|
||||||
@ -262,27 +261,23 @@ describe("SessionRunnerModel", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("preserves environment-backed bearer auth", () =>
|
it.effect("uses resolved credentials for bearer auth", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||||
new ModelV2.Info({
|
new ModelV2.Info({
|
||||||
...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: {} },
|
||||||
}),
|
}),
|
||||||
{ type: "env", name: "TEST_PROVIDER_API_KEY" },
|
new Credential.Key({ type: "key", key: "secret" }),
|
||||||
)
|
)
|
||||||
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.apply({
|
||||||
.apply({
|
|
||||||
request,
|
request,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "https://openai.example/v1/responses",
|
url: "https://openai.example/v1/responses",
|
||||||
body: "{}",
|
body: "{}",
|
||||||
headers: Headers.empty,
|
headers: Headers.empty,
|
||||||
})
|
})
|
||||||
.pipe(
|
|
||||||
Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { TEST_PROVIDER_API_KEY: "secret" } }))),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(headers.authorization).toBe("Bearer secret")
|
expect(headers.authorization).toBe("Bearer secret")
|
||||||
}),
|
}),
|
||||||
@ -290,18 +285,12 @@ describe("SessionRunnerModel", () => {
|
|||||||
|
|
||||||
it.effect("prefers stored credentials over configured auth", () =>
|
it.effect("prefers stored credentials over configured auth", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const credential = new Credential.Info({
|
const credential = new Credential.Key({ type: "key", key: "stored-secret", metadata: { tenant: "work" } })
|
||||||
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(
|
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||||
new ModelV2.Info({
|
new ModelV2.Info({
|
||||||
...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: { apiKey: "configured-secret" }, generation: {}, options: {} },
|
request: { headers: {}, body: { apiKey: "configured-secret" }, generation: {}, options: {} },
|
||||||
}),
|
}),
|
||||||
{ type: "credential", id: credential.id, label: credential.label },
|
|
||||||
credential,
|
credential,
|
||||||
)
|
)
|
||||||
const headers = yield* resolved.route.auth.apply({
|
const headers = yield* resolved.route.auth.apply({
|
||||||
|
|||||||
@ -4082,6 +4082,7 @@ export type ModelV2Info = {
|
|||||||
|
|
||||||
export type ProviderV2Info = {
|
export type ProviderV2Info = {
|
||||||
id: string
|
id: string
|
||||||
|
integrationID?: string
|
||||||
name: string
|
name: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
api:
|
api:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user