diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index ea3939b750de..859594134cb6 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -16,6 +16,7 @@ import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "./provider/goog import { GroqPlugin } from "./provider/groq" import { KiloPlugin } from "./provider/kilo" import { LLMGatewayPlugin } from "./provider/llmgateway" +import { ManifestPlugin } from "./provider/manifest" import { MistralPlugin } from "./provider/mistral" import { NvidiaPlugin } from "./provider/nvidia" import { OpenAIPlugin } from "./provider/openai" @@ -51,6 +52,7 @@ export const ProviderPlugins = [ GroqPlugin, KiloPlugin, LLMGatewayPlugin, + ManifestPlugin, MistralPlugin, NvidiaPlugin, OpencodePlugin, diff --git a/packages/core/src/plugin/provider/manifest.ts b/packages/core/src/plugin/provider/manifest.ts new file mode 100644 index 000000000000..0dcd488aa7ae --- /dev/null +++ b/packages/core/src/plugin/provider/manifest.ts @@ -0,0 +1,20 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" + +export const ManifestPlugin = PluginV2.define({ + id: PluginV2.ID.make("manifest"), + effect: Effect.gen(function* () { + return { + "catalog.transform": Effect.fn(function* (evt) { + for (const item of evt.provider.list()) { + if (item.provider.id !== "manifest") continue + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue + evt.provider.update(item.provider.id, (provider) => { + provider.request.headers["X-Source"] = "opencode" + }) + } + }), + } + }), +}) diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 3f5424a47f36..b06bef1bac21 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -16,6 +16,7 @@ export const ID = Schema.String.pipe( amazonBedrock: schema.make("amazon-bedrock"), azure: schema.make("azure"), openrouter: schema.make("openrouter"), + manifest: schema.make("manifest"), mistral: schema.make("mistral"), gitlab: schema.make("gitlab"), })), diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 0f71b39a9d5e..8e0646efdc1d 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -19,6 +19,7 @@ import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cl import { AzureAuthPlugin } from "./azure" import { DigitalOceanAuthPlugin } from "./digitalocean" import { XaiAuthPlugin } from "./xai" +import { ManifestAuthPlugin } from "./manifest" import { SnowflakeCortexAuthPlugin } from "./snowflake-cortex" import { Effect, Layer, Context } from "effect" import { EffectBridge } from "@/effect/bridge" @@ -76,6 +77,7 @@ function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] { CloudflareAIGatewayAuthPlugin, AzureAuthPlugin, DigitalOceanAuthPlugin, + ManifestAuthPlugin, SnowflakeCortexAuthPlugin, XaiAuthPlugin, ] diff --git a/packages/opencode/src/plugin/manifest.ts b/packages/opencode/src/plugin/manifest.ts new file mode 100644 index 000000000000..097160bcd127 --- /dev/null +++ b/packages/opencode/src/plugin/manifest.ts @@ -0,0 +1,15 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" + +export async function ManifestAuthPlugin(_input: PluginInput): Promise { + return { + auth: { + provider: "manifest", + methods: [ + { + type: "api", + label: "Manifest API Key", + }, + ], + }, + } +} diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 4352f8a9b519..d1452928f79a 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -451,6 +451,65 @@ function custom(dep: CustomDep): Record { }, }, }), + manifest: Effect.fnUntraced(function* (provider: Info) { + const config = yield* dep.config() + const baseURL = provider.options?.baseURL ?? config.provider?.["manifest"]?.options?.baseURL + + return { + autoload: false, + options: { + headers: { + "X-Source": "opencode", + }, + }, + async discoverModels() { + if (!baseURL) return {} + + const modelsURL = `${baseURL.replace(/\/+$/, "")}/models` + const apiKey = provider.options?.apiKey ?? config.provider?.["manifest"]?.options?.apiKey + const headers: Record = { Accept: "application/json" } + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}` + + try { + const res = await fetch(modelsURL, { headers, signal: AbortSignal.timeout(5_000) }) + if (!res.ok) return {} + const body = (await res.json()) as { data?: Array<{ id: string; display_name?: string }> } + if (!body.data || !Array.isArray(body.data)) return {} + + const models: Record = {} + for (const item of body.data) { + if (!item.id || typeof item.id !== "string") continue + models[item.id] = { + id: ModelV2.ID.make(item.id), + providerID: ProviderV2.ID.make("manifest"), + name: item.display_name ?? item.id, + family: "manifest", + api: { id: item.id, url: baseURL, npm: "@ai-sdk/openai-compatible" }, + status: "active", + headers: {}, + options: {}, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 1_000_000, output: 32_768 }, + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + release_date: "", + variants: {}, + } + } + return models + } catch { + return {} + } + }, + } + }), openrouter: () => Effect.succeed({ autoload: false, @@ -1557,6 +1616,20 @@ export const layer = Layer.effect( }) } + const manifest = ProviderV2.ID.make("manifest") + if (discoveryLoaders[manifest] && providers[manifest] && isProviderAllowed(manifest)) { + yield* Effect.promise(async () => { + try { + const discovered = await discoveryLoaders[manifest]() + for (const [modelID, model] of Object.entries(discovered)) { + if (!providers[manifest].models[modelID]) { + providers[manifest].models[modelID] = model + } + } + } catch (e) {} + }) + } + for (const [id, provider] of Object.entries(providers)) { const providerID = ProviderV2.ID.make(id) if (!isProviderAllowed(providerID)) { diff --git a/packages/opencode/test/provider/manifest.test.ts b/packages/opencode/test/provider/manifest.test.ts new file mode 100644 index 000000000000..a94039ed130a --- /dev/null +++ b/packages/opencode/test/provider/manifest.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, mock, beforeAll, afterAll } from "bun:test" +import { Provider } from "../../src/provider/provider" +import { Effect } from "effect" +import { testEffect } from "../lib/effect" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { createServer, type Server as HTTPServer } from "http" + +const MANIFEST = ProviderV2.ID.make("manifest") +const it = testEffect(Provider.defaultLayer) + +const withEnv = (values: Record, effect: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]] as const)) + Object.assign(process.env, values) + return previous + }), + () => effect, + (previous) => + Effect.sync(() => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + }), + ) + +describe("manifest provider", () => { + let server: HTTPServer + let port: number + + beforeAll(async () => { + server = createServer((req, res) => { + if (req.url === "/v1/models") { + res.writeHead(200, { "Content-Type": "application/json" }) + res.end( + JSON.stringify({ + object: "list", + data: [ + { + id: "auto", + object: "model", + type: "model", + display_name: "Manifest Auto", + }, + ], + has_more: false, + first_id: "auto", + last_id: "auto", + }), + ) + return + } + res.writeHead(404) + res.end() + }) + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address() + if (addr && typeof addr === "object") { + port = addr.port + } + resolve() + }) + }) + }) + + afterAll(() => { + server?.close() + }) + + it.instance( + "manifest provider loads from config with model discovery", + () => + withEnv( + {}, + Effect.gen(function* () { + const provider = yield* Provider.Service + const providers = yield* provider.list() + expect(providers[MANIFEST]).toBeDefined() + expect(providers[MANIFEST].source).toBe("config") + expect(providers[MANIFEST].models["auto"]).toBeDefined() + expect(providers[MANIFEST].models["auto"].name).toBe("Manifest Auto") + expect(providers[MANIFEST].models["auto"].api.npm).toBe("@ai-sdk/openai-compatible") + }), + ), + { + config: { + provider: { + manifest: { + npm: "@ai-sdk/openai-compatible", + name: "Manifest", + options: { + get baseURL() { + return `http://127.0.0.1:${port}/v1` + }, + apiKey: "mnfst_test_key", + }, + models: { + auto: { + name: "Manifest Auto", + }, + }, + }, + }, + }, + }, + ) + + it.instance( + "manifest provider not loaded without config", + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + const providers = yield* provider.list() + expect(providers[MANIFEST]).toBeUndefined() + }), + { config: {} }, + ) +}) diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index b31ebb67792c..4e73441bafd4 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -1796,6 +1796,61 @@ OpenCode Zen is a list of tested and verified models provided by the OpenCode te --- +### Manifest + +[Manifest](https://manifest.build) is a model router for AI agents. It sits between your agent and LLM providers, scores each request by complexity, and routes it to the best model that can handle it. Manifest tracks costs, tokens, and messages across all your agents. + +1. Get your Manifest API key from the Manifest dashboard. Keys use the `mnfst_` prefix. + +2. Add Manifest to your `opencode.json` configuration: + + ```json title="opencode.json" + { + "$schema": "https://opencode.ai/config.json", + "provider": { + "manifest": { + "npm": "@ai-sdk/openai-compatible", + "name": "Manifest", + "options": { + "baseURL": "http://localhost:3001/v1", + "apiKey": "mnfst_YOUR_KEY" + }, + "models": { + "auto": { + "name": "Manifest Auto" + } + } + } + }, + "model": "manifest/auto" + } + ``` + + Replace `http://localhost:3001/v1` with your Manifest instance URL and `mnfst_YOUR_KEY` with your API key. + +3. Run the `/connect` command and search for Manifest. + + ```txt + /connect + ``` + +4. Enter your API key. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +5. The `auto` model routes requests through Manifest's scoring engine. Run `/models` to confirm it's available. + + ```txt + /models + ``` + +--- + ### SAP AI Core SAP AI Core provides access to 40+ models from OpenAI, Anthropic, Google, Amazon, Meta, Mistral, and AI21 through a unified platform.