Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/client/src/promise/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ export type GenerateTextResponse = { data: { text: string } }

export type ProviderInfo = {
id: string
canonical?: string
integrationID?: string
name: string
activation: "auto" | "enabled" | "disabled"
Expand Down Expand Up @@ -1801,6 +1802,7 @@ export type ModelInfo = {
id: string
modelID: string
providerID: string
canonical?: string
family?: string
name: string
compatibility?: ModelCompatibility
Expand Down Expand Up @@ -1978,6 +1980,7 @@ export type ConfigEntry =
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
providers?: {
[x: string]: {
canonical?: string
name?: string
env?: Array<string>
package?: string
Expand Down
13 changes: 8 additions & 5 deletions packages/core/src/aisdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
function prepareOptions(model: Info, pkg: string) {
const projected = mapBodyToProviderOptions(model, pkg)
const options: Record<string, any> = {
name: model.providerID,
name: model.canonical ?? model.providerID,
...(model.settings ?? {}),
headers: model.headers,
body: projected.body,
Expand Down Expand Up @@ -249,6 +249,7 @@ export const locationLayer = Layer.effect(
language: Effect.fn("AISDK.language")(function* (model) {
const key = cacheKey({
providerID: model.providerID,
canonical: model.canonical,
id: model.id,
modelID: model.modelID,
package: model.package,
Expand All @@ -269,6 +270,7 @@ export const locationLayer = Layer.effect(
const options = prepareOptions(model, packageName)
const sdkKey = cacheKey({
providerID: model.providerID,
canonical: model.canonical,
package: packageName,
settings: model.settings,
headers: model.headers,
Expand Down Expand Up @@ -301,10 +303,11 @@ export const locationLayer = Layer.effect(
function modelFromLanguage(info: Info, language: LanguageModelV3) {
const packageName = Provider.packageName(info.package!)
const projected = mapBodyToProviderOptions(info, packageName)
const optionKey = providerOptionKey(packageName, info.providerID)
const providerID = info.canonical ?? info.providerID
const optionKey = providerOptionKey(packageName, providerID)
const route: AnyRoute = {
id: `ai-sdk:${packageName}`,
provider: ProviderID.make(info.providerID),
provider: ProviderID.make(providerID),
providerMetadataKey: optionKey,
protocol: "ai-sdk",
endpoint: Endpoint.path("/", { baseURL: "https://ai-sdk.local" }),
Expand All @@ -331,13 +334,13 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
},
with: () => route,
model: (input) =>
LanguageModel.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }),
LanguageModel.make({ ...input, provider: "provider" in input ? input.provider : providerID, route }),
prepareTransport: (body) => Effect.succeed(body),
streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions),
}
return LanguageModel.make({
id: info.modelID ?? info.id,
provider: info.providerID,
provider: providerID,
route,
compatibility: info.compatibility,
})
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ const layer = Layer.effect(
const projectModel = (model: Model.Info, provider: Provider.Info) => {
return {
...model,
...(provider.canonical === undefined ? {} : { canonical: provider.canonical }),
package: model.package ?? provider.package,
settings: Provider.mergeOverlay(provider.settings, model.settings),
headers: Provider.mergeHeaders(provider.headers, model.headers),
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/config/plugin/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,32 @@ export const Plugin = define({
catalog.model.default.set(configuredDefault.providerID, configuredDefault.model)
for (const [id, item] of configuredProviders(loaded.entries)) {
const providerID = id
const current = catalog.provider.get(providerID)
const source = catalog.provider.get(item.canonical ?? current?.provider.canonical ?? providerID)
const changed = item.canonical !== undefined && item.canonical !== current?.provider.canonical
catalog.provider.update(providerID, (provider) => {
if (changed && source && source.provider !== provider)
Object.assign(provider, structuredClone(source.provider), {
id: provider.id,
integrationID: provider.integrationID,
})
provider.activation = "enabled"
if (item.canonical !== undefined) provider.canonical = item.canonical
if (item.name !== undefined) provider.name = item.name
if (item.package !== undefined) provider.package = item.package
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
})
for (const [id, config] of Object.entries(item.models ?? {})) {
const base = source?.models.get(config.modelID ?? id) ?? source?.models.get(id)
const inherit = changed || !catalog.model.get(providerID, id)
catalog.model.update(providerID, id, (model) => {
if (inherit && base) {
Object.assign(model, structuredClone(base))
if (item.package !== undefined) model.package = undefined
if (item.settings?.baseURL !== undefined && model.settings) delete model.settings.baseURL
}
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.modelID !== undefined) model.modelID = config.modelID
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/model-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
packageName,
settings: configured,
modelID: resolved.modelID ?? resolved.id,
providerID: resolved.providerID,
providerID: resolved.canonical ?? resolved.providerID,
})
: undefined
const native = mapping?.package ?? resolved.package
Expand Down Expand Up @@ -161,6 +161,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
)
const settings = {
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
...(resolved.canonical === undefined ? {} : { provider: resolved.canonical }),
...nativeCredentialSettings(specifier, credential),
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
body: Provider.mergeOverlay(mapping?.body, resolved.body),
Expand All @@ -169,7 +170,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
try: () => {
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
return LanguageModel.update(runtime, {
provider: resolved.providerID,
provider: resolved.canonical ?? resolved.providerID,
compatibility: resolved.compatibility
? Object.assign({}, runtime.compatibility, resolved.compatibility)
: runtime.compatibility,
Expand Down
140 changes: 63 additions & 77 deletions packages/core/src/plugin/provider/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,14 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import { ConfigProviderV1 } from "../../v1/config/provider.js"
import { ConfigProvider } from "@opencode-ai/schema/config/provider"
import { Money } from "@opencode-ai/schema/money"
import { ConfigProviderOptionsV1 } from "../../v1/config/provider-options.js"
import { ConfigV1 } from "../../v1/config/config.js"

const defaultServer = "https://opencode.ai/console"
const clientID = "opencode-cli"
const methodID = Integration.MethodID.make("device")
const RemoteResponse = Schema.Struct({ config: ConfigV1.Info })
const RemoteResponse = Schema.Struct({ providers: Schema.Record(Schema.String, ConfigProvider.Info) })
const Device = Schema.Struct({
device_code: Schema.String,
user_code: Schema.String,
Expand Down Expand Up @@ -89,7 +86,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
const http = yield* HttpClient.HttpClient
const loading = Semaphore.makeUnsafe(1)
let connected = false
let providers: typeof ConfigV1.Info.Type.provider | undefined
let providers: typeof RemoteResponse.Type.providers | undefined

const load = Effect.fn("OpencodePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("opencode")
Expand Down Expand Up @@ -117,59 +114,69 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
yield* load()
yield* ctx.catalog.transform((catalog) => {
for (const [providerID, item] of Object.entries(providers ?? {})) {
const source = catalog.provider.get(item.canonical ?? providerID)
catalog.provider.update(providerID, (provider) => {
if (source && source.provider !== provider)
Object.assign(provider, structuredClone(source.provider), { id: provider.id })
provider.integrationID = Integration.ID.make("opencode")
if (item.canonical !== undefined) provider.canonical = item.canonical
if (item.name !== undefined) provider.name = item.name
provider.package = item.npm ? Provider.aisdk(item.npm) : ""
provider.settings = {
...provider.settings,
...withoutCredentials(item.options),
...(item.api ? { baseURL: item.api } : {}),
}
provider.headers = { ...provider.headers, ...item.options?.headers }
provider.package = item.package ?? provider.package
provider.settings = Provider.mergeOverlay(
withoutCredentials(provider.settings),
withoutCredentials(item.settings),
)
provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
provider.body = Provider.mergeOverlay(provider.body, item.body)
})

for (const [modelID, config] of Object.entries(item.models ?? {})) {
const base = source?.models.get(config.modelID ?? modelID) ?? source?.models.get(modelID)
catalog.model.update(providerID, modelID, (model) => {
if (config.family !== undefined) model.family = Model.Family.make(config.family)
Object.assign(model, structuredClone(base ?? model))
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.id !== undefined) model.modelID = Model.ID.make(config.id)
model.compatibility = Model.compatibility(config.interleaved) ?? model.compatibility
if (config.provider !== undefined) {
model.package = config.provider.npm ? Provider.aisdk(config.provider.npm) : undefined
if (config.provider.api) model.settings = { ...model.settings, baseURL: config.provider.api }
}
if (config.tool_call !== undefined) model.capabilities.tools = config.tool_call
if (config.modalities?.input !== undefined) model.capabilities.input = [...config.modalities.input]
if (config.modalities?.output !== undefined) model.capabilities.output = [...config.modalities.output]
model.headers = { ...model.headers, ...config.headers }
model.settings = { ...model.settings, ...ConfigProviderOptionsV1.model(withoutCredentials(config.options)) }
if (config.variants !== undefined) {
model.variants ??= []
for (const [id, options] of Object.entries(config.variants)) {
const variantID = Model.VariantID.make(id)
let existing = model.variants.find((item) => item.id === variantID)
if (!existing) {
existing = { id: variantID }
model.variants.push(existing)
}
existing.headers = { ...existing.headers, ...options.headers }
existing.settings = {
...existing.settings,
...ConfigProviderOptionsV1.model(withoutCredentials(options)),
}
if (config.modelID !== undefined) model.modelID = config.modelID
if (config.compatibility !== undefined)
model.compatibility = { ...model.compatibility, ...config.compatibility }
model.package = config.package ?? (item.package !== undefined ? undefined : model.package)
if (item.settings?.baseURL !== undefined && model.settings) delete model.settings.baseURL
if (config.capabilities !== undefined)
model.capabilities = {
...config.capabilities,
input: [...config.capabilities.input],
output: [...config.capabilities.output],
}
model.settings = Provider.mergeOverlay(
withoutCredentials(model.settings),
withoutCredentials(config.settings),
)
model.headers = Provider.mergeHeaders(model.headers, config.headers)
model.body = Provider.mergeOverlay(model.body, config.body)
for (const variant of config.variants ?? []) {
let existing = model.variants.find((item) => item.id === variant.id)
if (!existing) {
existing = { id: variant.id }
model.variants.push(existing)
}
if (variant.settings !== undefined)
existing.settings = Provider.mergeOverlay(existing.settings, withoutCredentials(variant.settings))
if (variant.headers !== undefined)
existing.headers = Provider.mergeHeaders(existing.headers, variant.headers)
if (variant.body !== undefined) existing.body = Provider.mergeOverlay(existing.body, variant.body)
}
if (config.release_date !== undefined) {
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 }
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 ?? Money.USDPerMillionTokens.zero,
write: cost.cache?.write ?? Money.USDPerMillionTokens.zero,
},
}))
model.enabled = !config.disabled
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
})
}
}
Expand Down Expand Up @@ -208,7 +215,7 @@ function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
const token = value.type === "oauth" ? value.access : value.key
return http
.execute(
HttpClientRequest.get(`${server}/api/config`).pipe(
HttpClientRequest.get(`${server}/api/v2/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(token),
HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}),
Expand All @@ -219,14 +226,17 @@ function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
if (response.status === 404) return Effect.undefined
return HttpClientResponse.filterStatusOk(response).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
Effect.map((remote) => remote.config.provider),
Effect.map((remote) => remote.providers),
)
}),
)
}

function withoutCredentials(body: Readonly<Record<string, unknown>> | undefined) {
return Object.fromEntries(Object.entries(body ?? {}).filter(([key]) => key !== "apiKey" && key !== "headers"))
function withoutCredentials<Value>(body: Readonly<Record<string, Value>> | undefined) {
return (
body &&
Object.fromEntries(Object.entries(body).filter(([key]) => !["apiKey", "authToken", "accessToken"].includes(key)))
)
}

function normalizeServer(input: unknown) {
Expand All @@ -242,30 +252,6 @@ function normalizeServer(input: unknown) {
})
}

function remoteCost(input: NonNullable<(typeof ConfigProviderV1.Model.Type)["cost"]>) {
const base = {
input: Money.USDPerMillionTokens.make(input.input),
output: Money.USDPerMillionTokens.make(input.output),
cache: {
read: Money.USDPerMillionTokens.make(input.cache_read ?? 0),
write: Money.USDPerMillionTokens.make(input.cache_write ?? 0),
},
}
if (!input.context_over_200k) return [base]
return [
base,
{
tier: { type: "context" as const, size: 200_000 },
input: Money.USDPerMillionTokens.make(input.context_over_200k.input),
output: Money.USDPerMillionTokens.make(input.context_over_200k.output),
cache: {
read: Money.USDPerMillionTokens.make(input.context_over_200k.cache_read ?? 0),
write: Money.USDPerMillionTokens.make(input.context_over_200k.cache_write ?? 0),
},
},
]
}

function poll(http: HttpClient.HttpClient, server: string, deviceCode: string, interval: Duration.Duration) {
const loop = (wait: Duration.Duration): Effect.Effect<Credential.OAuth, unknown> =>
Effect.gen(function* () {
Expand Down
37 changes: 37 additions & 0 deletions packages/core/test/aisdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,43 @@ it.effect("keys language models by package and flattened overlays", () =>
}),
)

it.effect("uses canonical names and metadata without merging connection cache partitions", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
const loaded: string[] = []
yield* aisdk.hook.sdk((event) => {
loaded.push(`${event.model.providerID}:${event.options.name}`)
event.sdk = createOpenAICompatible({
...event.options,
name: String(event.options.name),
baseURL: String(event.options.baseURL),
})
})
const input = {
...model("@ai-sdk/openai-compatible", { baseURL: "https://proxy.example/v1", reasoningEffort: "high" }),
providerID: Provider.ID.make("work"),
canonical: Provider.ID.openai,
}
const first = yield* aisdk.language(input)
const second = yield* aisdk.language({ ...input, providerID: Provider.ID.make("personal") })
const plain = yield* aisdk.language({ ...input, canonical: undefined })

expect(yield* aisdk.language(input)).toBe(first)
expect(first).not.toBe(second)
expect(first).not.toBe(plain)
expect(first).toMatchObject({ modelId: "api-model", provider: "openai.chat" })
expect(plain.provider).toBe("work.chat")
expect(loaded).toEqual(["work:openai", "personal:openai", "work:work"])

const resolved = yield* aisdk.model(input)
expect(resolved).toMatchObject({ id: "api-model", provider: "openai" })
expect(resolved.route).toMatchObject({ provider: "openai", providerMetadataKey: "openai" })
expect(resolved.route.model({ id: "another-model" })).toMatchObject({ provider: "openai" })
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
expect(prepared.body.providerOptions).toEqual({ openai: { reasoningEffort: "high" } })
}),
)

it.effect("projects request settings, headers, and body overlays", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
Expand Down
Loading
Loading