mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-29 13:06:13 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b525ee0dfa |
@@ -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"
|
||||
@@ -1801,6 +1802,7 @@ export type ModelInfo = {
|
||||
id: string
|
||||
modelID: string
|
||||
providerID: string
|
||||
canonical?: string
|
||||
family?: string
|
||||
name: string
|
||||
compatibility?: ModelCompatibility
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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" }),
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -44,8 +44,17 @@ 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)
|
||||
@@ -53,7 +62,14 @@ export const Plugin = define({
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
@@ -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")
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
@@ -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) {
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -425,6 +425,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
providers: {
|
||||
custom: {
|
||||
name: "Configured",
|
||||
canonical: "anthropic",
|
||||
env: ["CUSTOM_API_KEY"],
|
||||
package: "native",
|
||||
headers: { first: "first", shared: "first" },
|
||||
@@ -461,6 +462,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
providers: {
|
||||
custom: {
|
||||
package: "aisdk:custom-sdk",
|
||||
canonical: "anthropic",
|
||||
settings: { baseURL: "https://example.test" },
|
||||
headers: { last: "last", shared: "last" },
|
||||
models: {
|
||||
@@ -498,12 +500,23 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
}),
|
||||
]
|
||||
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(Provider.ID.anthropic, (provider) => {
|
||||
provider.package = "aisdk:@ai-sdk/anthropic"
|
||||
})
|
||||
draft.model.update(Provider.ID.anthropic, modelID, (model) => {
|
||||
model.variants = [{ id: Model.VariantID.make("fast"), settings: { effort: "high" } }]
|
||||
})
|
||||
})
|
||||
yield* addPlugin(entries)
|
||||
|
||||
const provider = required(yield* catalog.provider.get(providerID))
|
||||
const model = required(yield* catalog.model.get(providerID, modelID))
|
||||
expect((yield* catalog.model.default())?.id).toBe(Model.ID.make("default"))
|
||||
expect(provider.name).toBe("Renamed")
|
||||
expect(provider.canonical).toBe(Provider.ID.anthropic)
|
||||
expect(model.canonical).toBe(Provider.ID.anthropic)
|
||||
expect(model.providerID).toBe(providerID)
|
||||
expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual({
|
||||
type: "env",
|
||||
names: ["CUSTOM_API_KEY"],
|
||||
@@ -511,6 +524,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed")
|
||||
expect(provider.activation).toBe("enabled")
|
||||
expect(provider.package).toBe("aisdk:custom-sdk")
|
||||
expect(model.package).toBe("aisdk:custom-sdk")
|
||||
expect(provider.settings).toEqual({ baseURL: "https://example.test" })
|
||||
expect(provider.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.id).toBe(modelID)
|
||||
@@ -542,6 +556,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
Model.VariantID.make("slow"),
|
||||
])
|
||||
expect(model.variants?.[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.variants?.[0]?.settings).toEqual({ effort: "high" })
|
||||
expect(model.variants?.[1]?.headers).toEqual({ slow: "slow" })
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -16,6 +16,7 @@ import { it } from "./lib/effect"
|
||||
|
||||
interface ModelOptions {
|
||||
readonly providerID?: Provider.ID
|
||||
readonly canonical?: Provider.ID
|
||||
readonly modelID?: string
|
||||
readonly compatibility?: Compatibility
|
||||
readonly settings?: Info["settings"]
|
||||
@@ -30,6 +31,7 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
|
||||
id: ID.make("test-model"),
|
||||
modelID: ID.make(options.modelID ?? "api-test-model"),
|
||||
providerID: options.providerID ?? Provider.ID.make("test-provider"),
|
||||
canonical: options.canonical,
|
||||
name: "Test model",
|
||||
compatibility: options.compatibility,
|
||||
package: packageName,
|
||||
@@ -283,6 +285,7 @@ describe("ModelResolver", () => {
|
||||
it.effect("uses no native API-key auth for an explicitly enabled provider without credentials", () => {
|
||||
const selected = model(Provider.aisdk("@ai-sdk/google"), {
|
||||
providerID: Provider.ID.make("gateway"),
|
||||
canonical: Provider.ID.google,
|
||||
settings: { baseURL: "https://gateway.example.com/v1" },
|
||||
headers: { "cf-access-token": "access-token" },
|
||||
})
|
||||
@@ -309,7 +312,10 @@ describe("ModelResolver", () => {
|
||||
})
|
||||
const integrations = Layer.mock(Integration.Service, {
|
||||
connection: {
|
||||
active: () => Effect.undefined,
|
||||
active: (id) => {
|
||||
expect(id).toBe(Integration.ID.make("gateway"))
|
||||
return Effect.undefined
|
||||
},
|
||||
resolve: () => Effect.die("unused"),
|
||||
key: () => Effect.die("unused"),
|
||||
activate: () => Effect.die("unused"),
|
||||
@@ -347,6 +353,8 @@ describe("ModelResolver", () => {
|
||||
const resolved = yield* resolver.resolveModel(selected)
|
||||
|
||||
expect(resolved.limit).toEqual(selected.limit)
|
||||
expect(resolved.ref.providerID).toBe(selected.providerID)
|
||||
expect(String(resolved.model.provider)).toBe("google")
|
||||
const headers = yield* resolved.model.route.auth.apply({
|
||||
request: LLM.request({ model: resolved.model, prompt: "Hello" }),
|
||||
method: "POST",
|
||||
@@ -389,6 +397,7 @@ describe("ModelResolver", () => {
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/openai-compatible"), {
|
||||
canonical: Provider.ID.make("deepseek"),
|
||||
compatibility: {
|
||||
reasoningField: "vendor_reasoning",
|
||||
requireReasoning: true,
|
||||
@@ -417,6 +426,8 @@ describe("ModelResolver", () => {
|
||||
|
||||
expect(headers.authorization).toBe("Bearer settings-secret")
|
||||
expect(resolved.route.id).toBe("openai-compatible-chat")
|
||||
expect(String(resolved.provider)).toBe("deepseek")
|
||||
expect(resolved.route.providerMetadataKey).toBe("deepseek")
|
||||
expect(resolved.compatibility?.reasoningField).toBe("vendor_reasoning")
|
||||
expect(resolved.compatibility?.requireReasoning).toBe(true)
|
||||
expect(resolved.compatibility?.maxTokensField).toBe("max_completion_tokens")
|
||||
|
||||
@@ -210,49 +210,68 @@ describe("OpencodePlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("loads providers and models from the connected OpenCode server", () =>
|
||||
it.live("loads native V2 providers and models from the connected OpenCode server", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const authorization: Array<string | null> = []
|
||||
const requests: string[] = []
|
||||
return {
|
||||
authorization,
|
||||
requests,
|
||||
server: Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
authorization.push(request.headers.get("authorization"))
|
||||
requests.push(`${request.method} ${new URL(request.url).pathname}`)
|
||||
const origin = new URL(request.url).origin
|
||||
return Response.json({
|
||||
config: {
|
||||
enterprise: { url: origin },
|
||||
provider: {
|
||||
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: {
|
||||
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 },
|
||||
providers: {
|
||||
remote: {
|
||||
canonical: "openai",
|
||||
name: "Remote",
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
env: ["REMOTE_API_KEY"],
|
||||
settings: {
|
||||
baseURL: `${origin}/v1`,
|
||||
apiKey: "{env:REMOTE_API_KEY}",
|
||||
authToken: "provider-auth",
|
||||
accessToken: "provider-access",
|
||||
custom: "value",
|
||||
},
|
||||
headers: { "x-org-id": "org" },
|
||||
models: {
|
||||
model: {
|
||||
modelID: "api-model",
|
||||
name: "Remote Model",
|
||||
family: "remote",
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
settings: {
|
||||
apiKey: "model-secret",
|
||||
authToken: "model-auth",
|
||||
accessToken: "model-access",
|
||||
temperature: 0.5,
|
||||
},
|
||||
override: {
|
||||
name: "Override",
|
||||
provider: { npm: "@ai-sdk/anthropic", api: `${origin}/anthropic` },
|
||||
},
|
||||
disabled: { name: "Disabled", status: "deprecated" },
|
||||
variants: [
|
||||
{
|
||||
id: "high",
|
||||
settings: {
|
||||
apiKey: "variant-secret",
|
||||
authToken: "variant-auth",
|
||||
accessToken: "variant-access",
|
||||
temperature: 0.2,
|
||||
},
|
||||
headers: { "x-variant": "high" },
|
||||
},
|
||||
],
|
||||
cost: { input: 1, output: 2, cache: { read: 0.1 } },
|
||||
limit: { context: 1000, output: 100 },
|
||||
},
|
||||
override: {
|
||||
name: "Override",
|
||||
package: "aisdk:@ai-sdk/anthropic",
|
||||
settings: { baseURL: `${origin}/anthropic` },
|
||||
},
|
||||
disabled: { name: "Disabled", disabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -261,13 +280,19 @@ describe("OpencodePlugin", () => {
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ authorization, server }) =>
|
||||
({ authorization, requests, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(Provider.ID.make("remote"), () => {})
|
||||
draft.model.update(Provider.ID.make("remote"), Model.ID.make("model"), (model) => {
|
||||
draft.provider.update(Provider.ID.openai, (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/openai")
|
||||
provider.integrationID = Integration.ID.make("openai")
|
||||
})
|
||||
draft.model.update(Provider.ID.openai, Model.ID.make("api-model"), (model) => {
|
||||
model.package = Provider.aisdk("@ai-sdk/openai")
|
||||
model.settings = { baseURL: "https://upstream.example/v1" }
|
||||
model.variants = [
|
||||
{
|
||||
id: Model.VariantID.make("custom"),
|
||||
@@ -290,19 +315,27 @@ describe("OpencodePlugin", () => {
|
||||
|
||||
yield* addPlugin()
|
||||
expect(authorization).toEqual(["Bearer secret"])
|
||||
expect(requests).toEqual(["GET /api/v2/config"])
|
||||
|
||||
const provider = required(yield* catalog.provider.get(Provider.ID.make("remote")))
|
||||
expect(provider).toMatchObject({
|
||||
id: "remote",
|
||||
canonical: "openai",
|
||||
name: "Remote",
|
||||
integrationID: "opencode",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: { baseURL: `${server.url.origin}/v1`, custom: "value" },
|
||||
headers: { "x-org-id": "org" },
|
||||
})
|
||||
expect(yield* (yield* Integration.Service).get(Integration.ID.make("remote"))).toBeUndefined()
|
||||
expect(provider.settings).toEqual({ baseURL: `${server.url.origin}/v1`, custom: "value" })
|
||||
expect(yield* integrations.get(Integration.ID.make("remote"))).toBeUndefined()
|
||||
|
||||
const model = required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("model")))
|
||||
expect(model).toMatchObject({
|
||||
id: "model",
|
||||
modelID: "api-model",
|
||||
providerID: "remote",
|
||||
canonical: "openai",
|
||||
name: "Remote Model",
|
||||
family: "remote",
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
@@ -312,6 +345,7 @@ describe("OpencodePlugin", () => {
|
||||
settings: { baseURL: `${server.url.origin}/v1`, custom: "value", temperature: 0.5 },
|
||||
headers: { "x-org-id": "org" },
|
||||
})
|
||||
expect(model.settings).toEqual({ baseURL: `${server.url.origin}/v1`, custom: "value", temperature: 0.5 })
|
||||
const override = required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("override")))
|
||||
expect(override.package).toBe(Provider.aisdk("@ai-sdk/anthropic"))
|
||||
expect(override.settings?.baseURL).toBe(`${server.url.origin}/anthropic`)
|
||||
@@ -325,13 +359,16 @@ describe("OpencodePlugin", () => {
|
||||
{
|
||||
id: Model.VariantID.make("high"),
|
||||
settings: { temperature: 0.2 },
|
||||
headers: {},
|
||||
headers: { "x-variant": "high" },
|
||||
},
|
||||
])
|
||||
expect(
|
||||
required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("disabled"))).enabled,
|
||||
).toBe(false)
|
||||
expect(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("stale"))).toBeDefined()
|
||||
expect((yield* catalog.model.get(Provider.ID.openai, Model.ID.make("api-model")))?.settings?.baseURL).toBe(
|
||||
"https://upstream.example/v1",
|
||||
)
|
||||
|
||||
yield* credentials.update(initial.id, { label: "Renamed" })
|
||||
yield* Effect.yieldNow
|
||||
@@ -350,6 +387,7 @@ describe("OpencodePlugin", () => {
|
||||
(count) => count === 2,
|
||||
)
|
||||
expect(authorization).toEqual(["Bearer secret", "Bearer replacement"])
|
||||
expect(requests).toEqual(["GET /api/v2/config", "GET /api/v2/config"])
|
||||
|
||||
yield* credentials.remove(initial.id)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
@@ -3,6 +3,7 @@ export * as ConfigProvider from "./provider.js"
|
||||
import { Schema } from "effect"
|
||||
import { Money } from "../money.js"
|
||||
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js"
|
||||
import { Provider } from "../provider.js"
|
||||
import { optional } from "../schema.js"
|
||||
|
||||
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
|
||||
@@ -57,6 +58,7 @@ class Model extends Schema.Class<Model>("Config.Model")({
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Provider")({
|
||||
canonical: Provider.ID.pipe(optional),
|
||||
name: Schema.String.pipe(optional),
|
||||
env: Schema.String.pipe(Schema.Array, optional),
|
||||
package: Schema.String.pipe(optional),
|
||||
|
||||
@@ -101,6 +101,7 @@ export const Info = Schema.Struct({
|
||||
id: ID,
|
||||
modelID: ID,
|
||||
providerID: Provider.ID,
|
||||
canonical: Provider.ID.pipe(optional),
|
||||
family: Family.pipe(optional),
|
||||
name: Schema.String,
|
||||
compatibility: Compatibility.pipe(optional),
|
||||
|
||||
@@ -47,6 +47,7 @@ export const Request = Schema.Struct({
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
export const Info = Schema.Struct({
|
||||
id: ID,
|
||||
canonical: ID.pipe(optional),
|
||||
integrationID: Integration.ID.pipe(optional),
|
||||
name: Schema.String,
|
||||
activation: Activation,
|
||||
|
||||
@@ -5,10 +5,19 @@ import { ConfigAgent } from "../src/config/agent.js"
|
||||
import { ConfigMCP } from "../src/config/mcp.js"
|
||||
import { ConfigProvider } from "../src/config/provider.js"
|
||||
import { Mcp } from "../src/mcp.js"
|
||||
import { Provider } from "../src/provider.js"
|
||||
import { AbsolutePath } from "../src/schema.js"
|
||||
import { WebSearch } from "../src/websearch.js"
|
||||
|
||||
describe("Config.Entry", () => {
|
||||
test("round-trips canonical provider IDs without changing config keys", () => {
|
||||
const input = { providers: { "console-anthropic": { canonical: "anthropic" } } }
|
||||
const decoded = Schema.decodeUnknownSync(Config.Info)(input)
|
||||
expect(decoded.providers?.["console-anthropic"]?.canonical).toBe(Provider.ID.anthropic)
|
||||
expect(Schema.encodeSync(Config.Info)(decoded)).toEqual(input)
|
||||
expect(() => Schema.decodeUnknownSync(Config.Info)({ providers: { custom: { canonical: 1 } } })).toThrow()
|
||||
})
|
||||
|
||||
test("accepts disabled, fixed, and random web search selection", () => {
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
|
||||
@@ -70,7 +79,7 @@ describe("Config.Entry", () => {
|
||||
}),
|
||||
},
|
||||
}),
|
||||
providers: { custom: new ConfigProvider.Info({ headers: undefined }) },
|
||||
providers: { custom: new ConfigProvider.Info({ canonical: undefined, headers: undefined }) },
|
||||
}),
|
||||
})
|
||||
const encoded = Schema.encodeSync(Config.Entry)(entry)
|
||||
@@ -85,5 +94,6 @@ describe("Config.Entry", () => {
|
||||
expect(docs).not.toHaveProperty("headers")
|
||||
expect(docs.oauth).not.toHaveProperty("client_id")
|
||||
expect(encoded.info.providers?.custom).not.toHaveProperty("headers")
|
||||
expect(encoded.info.providers?.custom).not.toHaveProperty("canonical")
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user