Compare commits

...
Author SHA1 Message Date
James Long 04c23b8846 feat(tui): expose native OpenCode theme 2026-07-23 16:35:57 +00:00
James LongandGitHub 8f3465c951 refactor(tui): load native V2 themes (#38430) 2026-07-23 12:35:29 -04:00
Shoubhit DashandGitHub 466b75b19d feat(cli): expand acp v1 support (#38325) 2026-07-23 21:13:46 +05:30
James LongandGitHub 833dd2ed7f refactor(tui): simplify turn usage reduction (#38514) 2026-07-23 11:22:00 -04:00
Shoubhit DashandGitHub bbe985b4d0 chore(cli): upgrade acp sdk (#38316) 2026-07-23 20:44:14 +05:30
James LongandGitHub 5b1321a8ca feat(tui): add turn token usage diagnostics (#38398) 2026-07-23 10:00:51 -04:00
Simon KleeandGitHub 52c98a4eeb mini: add replay settings to cli config (#38487) 2026-07-23 12:12:25 +02:00
b6f85c2250 fix(core): default custom model capabilities (#38449)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-22 23:20:16 -05:00
6e8aefcfa0 fix(ai): normalize Bedrock cache usage (#38427)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-22 21:42:53 -05:00
James LongandGitHub f1f0f47ee2 fix(core): migrate named agent colors (#38414) 2026-07-22 22:38:13 -04:00
Kit LangtonandGitHub 203b9f59b7 fix(core): load dynamic models for generation (#38401) 2026-07-22 22:26:08 -04:00
Aiden ClineandGitHub 48bcbd09ef fix(ai): handle incomplete responses without reasons (#38374) 2026-07-22 20:49:08 -05:00
James LongandGitHub d86f732df3 refactor(tui): generate syntax from V2 theme (#38397) 2026-07-22 21:33:25 -04:00
Dax Raad a817fe5e6c fix(schema): loosen agent color response 2026-07-22 19:22:01 -04:00
James LongandGitHub 381f6c47b4 docs(tui): add generated V2 theme reference (#38396) 2026-07-22 18:30:31 -04:00
James LongandGitHub b6e14b5a74 refactor(tui): finish V2 theme migration (#38383) 2026-07-22 17:56:22 -04:00
36979c9641 test(core): consolidate provider factory coverage (#38390)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-22 16:50:33 -05:00
4e067a2014 test(core): remove duplicate patch integration tests (#38389)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-22 16:19:06 -05:00
127 changed files with 2277 additions and 2317 deletions
+5
View File
@@ -97,6 +97,11 @@ jobs:
working-directory: packages/client
run: bun run check:generated
- name: Check generated documentation
if: runner.os == 'Linux'
working-directory: packages/docs
run: bun run check:generated
e2e:
name: e2e (${{ matrix.settings.name }})
if: github.ref_name != 'v2' && github.head_ref != 'v2'
+2 -2
View File
@@ -124,7 +124,7 @@
"opencode2": "./bin/opencode2.cjs",
},
"dependencies": {
"@agentclientprotocol/sdk": "0.21.0",
"@agentclientprotocol/sdk": "1.2.1",
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -1173,7 +1173,7 @@
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.21.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ONj+Q8qOdNQp5XbH5jnMwzT9IKZJsSN0p0lkceS4GtUtNOPVLpNzSS8gqQdGMKfBvA0ESbkL8BTaSN1Rc9miEw=="],
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@1.2.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA=="],
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
+10 -9
View File
@@ -436,21 +436,22 @@ const mapFinishReason = (reason: string): FinishReason => {
return "unknown"
}
// AWS Bedrock Converse reports `inputTokens` (inclusive total) with
// `cacheReadInputTokens` and `cacheWriteInputTokens` as subsets. Pass
// the total through and derive the non-cached breakdown. Bedrock does
// not break reasoning out of `outputTokens` for any current model.
// AWS reports inputTokens separately from cache reads and writes.
// Bedrock does not break reasoning out of outputTokens for current models.
const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => {
if (!usage) return undefined
const cacheTotal = (usage.cacheReadInputTokens ?? 0) + (usage.cacheWriteInputTokens ?? 0)
const nonCached = ProviderShared.subtractTokens(usage.inputTokens, cacheTotal)
const inputTokens = ProviderShared.sumTokens(
usage.inputTokens,
usage.cacheReadInputTokens,
usage.cacheWriteInputTokens,
)
return new Usage({
inputTokens: usage.inputTokens,
inputTokens,
outputTokens: usage.outputTokens,
nonCachedInputTokens: nonCached,
nonCachedInputTokens: usage.inputTokens,
cacheReadInputTokens: usage.cacheReadInputTokens,
cacheWriteInputTokens: usage.cacheWriteInputTokens,
totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens),
totalTokens: ProviderShared.totalTokens(inputTokens, usage.outputTokens, usage.totalTokens),
providerMetadata: { bedrock: usage },
})
}
@@ -253,7 +253,7 @@ const OpenAIResponsesEvent = Schema.Struct({
Schema.Struct({
id: Schema.optional(Schema.String),
service_tier: optionalNull(Schema.String),
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.String })),
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
usage: optionalNull(OpenAIResponsesUsage),
error: optionalNull(OpenAIResponsesErrorPayload),
}),
@@ -602,7 +602,8 @@ const mapUsage = (usage: OpenAIResponsesUsage | null | undefined) => {
const mapFinishReason = (event: OpenAIResponsesEvent, hasFunctionCall: boolean): FinishReason => {
const reason = event.response?.incomplete_details?.reason
if (reason === undefined || reason === null) return hasFunctionCall ? "tool-calls" : "stop"
if (reason === undefined || reason === null)
return hasFunctionCall ? "tool-calls" : event.type === "response.incomplete" ? "unknown" : "stop"
if (reason === "max_output_tokens") return "length"
if (reason === "content_filter") return "content-filter"
return hasFunctionCall ? "tool-calls" : "unknown"
+4 -3
View File
@@ -34,11 +34,12 @@ import { ProviderFailureClassification } from "./errors"
*
* **Semantics by provider**:
*
* - OpenAI Chat / Responses / Gemini / Bedrock: provider reports inclusive
* - OpenAI Chat / Responses / Gemini: provider reports inclusive
* `inputTokens` and an inclusive `outputTokens`; mapper subtracts to
* derive the breakdown.
* - Anthropic: provider reports the breakdown natively (`input_tokens` is
* non-cached only); mapper sums to derive the inclusive `inputTokens`.
* - Anthropic and Bedrock report the input breakdown natively: Anthropic's
* `input_tokens` and Bedrock's `inputTokens` are non-cached only. Their
* mappers sum the breakdown to derive the inclusive `inputTokens`.
* Anthropic does *not* break extended-thinking out of `output_tokens`, so
* `reasoningTokens` is `undefined` and `outputTokens` carries the
* combined total — a documented limitation of the Anthropic API.
File diff suppressed because one or more lines are too long
@@ -13,12 +13,8 @@ const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1"
// call wouldn't deterministically prove cache mapping works. Override with
// BEDROCK_CACHE_MODEL_ID if your account has access elsewhere.
const model = AmazonBedrock.configure({
credentials: {
region: RECORDING_REGION,
accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "fixture",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "fixture",
sessionToken: process.env.AWS_SESSION_TOKEN,
},
apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "fixture",
region: RECORDING_REGION,
}).model(process.env.BEDROCK_CACHE_MODEL_ID ?? "us.anthropic.claude-haiku-4-5-20251001-v1:0")
const cacheRequest = LLM.request({
@@ -36,7 +32,7 @@ const recorded = recordedTests({
prefix: "bedrock-converse-cache",
provider: "amazon-bedrock",
protocol: "bedrock-converse",
requires: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
// Two identical requests in one cassette — replay walks the cassette in
// recording order so the second call replays the cached-hit interaction.
})
@@ -45,10 +41,20 @@ describe("Bedrock Converse cache recorded", () => {
recorded.effect.with("writes then reads cachePoint on identical second call", { tags: ["cache"] }, () =>
Effect.gen(function* () {
const first = yield* LLMClient.generate(cacheRequest)
expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0)
expect(first.usage?.cacheWriteInputTokens ?? 0).toBeGreaterThan(0)
expect(first.usage?.inputTokens).toBe(
(first.usage?.nonCachedInputTokens ?? 0) +
(first.usage?.cacheReadInputTokens ?? 0) +
(first.usage?.cacheWriteInputTokens ?? 0),
)
const second = yield* LLMClient.generate(cacheRequest)
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0)
expect(second.usage?.inputTokens).toBe(
(second.usage?.nonCachedInputTokens ?? 0) +
(second.usage?.cacheReadInputTokens ?? 0) +
(second.usage?.cacheWriteInputTokens ?? 0),
)
}),
)
})
@@ -269,6 +269,39 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("adds cache reads and writes to Bedrock input usage", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "Hello" } }],
["contentBlockStop", { contentBlockIndex: 0 }],
["messageStop", { stopReason: "end_turn" }],
[
"metadata",
{
usage: {
inputTokens: 5,
outputTokens: 2,
totalTokens: 12,
cacheReadInputTokens: 3,
cacheWriteInputTokens: 2,
},
},
],
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.usage).toMatchObject({
inputTokens: 10,
nonCachedInputTokens: 5,
cacheReadInputTokens: 3,
cacheWriteInputTokens: 2,
outputTokens: 2,
totalTokens: 12,
})
}),
)
it.effect("assembles streamed tool call input", () =>
Effect.gen(function* () {
const body = eventStreamBody(
@@ -870,6 +870,32 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("maps incomplete response reasons", () =>
Effect.gen(function* () {
const generate = (incompleteDetails: object) =>
LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
type: "response.incomplete",
response: { id: "resp_incomplete", incomplete_details: incompleteDetails },
}),
),
),
)
const length = yield* generate({ reason: "max_output_tokens" })
const contentFilter = yield* generate({ reason: "content_filter" })
const unknown = yield* generate({})
expect([length.finishReason, contentFilter.finishReason, unknown.finishReason]).toEqual([
"length",
"content-filter",
"unknown",
])
}),
)
// OpenAI's documented stream orders output text within one message item; no
// provider-valid same-kind overlap is evidenced, so done boundaries close it.
it.effect("closes sequential output messages before starting the next", () =>
+1 -1
View File
@@ -22,7 +22,7 @@
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@agentclientprotocol/sdk": "0.21.0",
"@agentclientprotocol/sdk": "1.2.1",
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
+2 -2
View File
@@ -5,6 +5,7 @@ import {
type AuthenticateRequest,
type CancelNotification,
type CloseSessionRequest,
type DeleteSessionRequest,
type ForkSessionRequest,
type InitializeRequest,
type ListSessionsRequest,
@@ -13,7 +14,6 @@ import {
type PromptRequest,
type ResumeSessionRequest,
type SetSessionConfigOptionRequest,
type SetSessionModelRequest,
type SetSessionModeRequest,
} from "@agentclientprotocol/sdk"
import type { OpenCodeClient } from "@opencode-ai/client/promise"
@@ -28,12 +28,12 @@ export function create(client: OpenCodeClient, connection: AgentSideConnection)
newSession: (params: NewSessionRequest) => run(service.newSession(params)),
loadSession: (params: LoadSessionRequest) => run(service.loadSession(params)),
listSessions: (params: ListSessionsRequest) => run(service.listSessions(params)),
deleteSession: (params: DeleteSessionRequest) => run(service.deleteSession(params)),
resumeSession: (params: ResumeSessionRequest) => run(service.resumeSession(params)),
closeSession: (params: CloseSessionRequest) => run(service.closeSession(params)),
unstable_forkSession: (params: ForkSessionRequest) => run(service.forkSession(params)),
setSessionConfigOption: (params: SetSessionConfigOptionRequest) => run(service.setSessionConfigOption(params)),
setSessionMode: (params: SetSessionModeRequest) => run(service.setSessionMode(params)),
unstable_setSessionModel: (params: SetSessionModelRequest) => run(service.setSessionModel(params)),
prompt: (params: PromptRequest) => run(service.prompt(params)),
cancel: (params: CancelNotification) => run(service.cancel(params)),
} satisfies Agent
+4 -5
View File
@@ -47,7 +47,7 @@ export async function streamTurn(input: {
readonly sessionID: string
readonly cwd: string
readonly start: TurnStart
readonly userMessageID?: string | null
readonly writeTextFile: boolean
readonly submit: (signal: AbortSignal) => Promise<unknown>
readonly control: TurnControl
}): Promise<PromptResponse> {
@@ -170,6 +170,7 @@ export async function streamTurn(input: {
tools.delete(event.data.callID)
await syncEditedFiles({
connection: input.connection,
writeTextFile: input.writeTextFile,
sessionID: input.sessionID,
cwd: input.cwd,
toolName: current.name,
@@ -231,7 +232,7 @@ export async function streamTurn(input: {
if (!started) {
streamController.abort()
await completed.catch(() => {})
return response(undefined, undefined, "interrupted", true, undefined, input.userMessageID)
return response(undefined, undefined, "interrupted", true, undefined)
}
}
const terminal = await completed
@@ -246,7 +247,6 @@ export async function streamTurn(input: {
terminal,
control.cancelled,
finish,
input.userMessageID,
)
} catch (error) {
streamController.abort()
@@ -400,7 +400,6 @@ function response(
terminal: "succeeded" | "failed" | "interrupted",
cancelled: boolean,
finish: SessionMessageAssistant["finish"],
messageID: string | null | undefined,
): PromptResponse {
const error = assistant?.error ?? executionError
if (error?.type === "provider.auth") throw new ACPError.AuthRequiredError()
@@ -423,7 +422,7 @@ function response(
}
: undefined
const stopReason = resolveStopReason({ terminal, cancelled, finish, error: error?.type })
return { stopReason, ...(usage ? { usage } : {}), ...(messageID ? { userMessageId: messageID } : {}), _meta: {} }
return { stopReason, ...(usage ? { usage } : {}), _meta: {} }
}
function resolveStopReason(input: {
+2 -1
View File
@@ -53,13 +53,14 @@ export async function replyPermission(input: {
export async function syncEditedFiles(input: {
readonly connection: Partial<Pick<AgentSideConnection, "writeTextFile">>
readonly writeTextFile: boolean
readonly sessionID: string
readonly cwd: string
readonly toolName: string
readonly toolInput: ToolInput
readonly structured: Readonly<Record<string, unknown>>
}) {
if (!input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return
if (!input.writeTextFile || !input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return
const files = Array.isArray(input.structured.files)
? input.structured.files.flatMap((file): string[] => {
if (!file || typeof file !== "object") return []
+18 -13
View File
@@ -16,6 +16,8 @@ import type {
CancelNotification,
CloseSessionRequest,
CloseSessionResponse,
DeleteSessionRequest,
DeleteSessionResponse,
ForkSessionRequest,
ForkSessionResponse,
InitializeRequest,
@@ -33,8 +35,6 @@ import type {
ResumeSessionResponse,
SetSessionConfigOptionRequest,
SetSessionConfigOptionResponse,
SetSessionModelRequest,
SetSessionModelResponse,
SetSessionModeRequest,
SetSessionModeResponse,
} from "@agentclientprotocol/sdk"
@@ -47,7 +47,8 @@ import { ACPError } from "./error"
export const AuthMethodID = "opencode-login"
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission">
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
Partial<Pick<AgentSideConnection, "writeTextFile">>
type Catalog = {
readonly providers: ConfigOptionProvider[]
@@ -83,12 +84,12 @@ export interface Interface {
newSession(input: NewSessionRequest): Promise<NewSessionResponse>
loadSession(input: LoadSessionRequest): Promise<LoadSessionResponse>
listSessions(input: ListSessionsRequest): Promise<ListSessionsResponse>
deleteSession(input: DeleteSessionRequest): Promise<DeleteSessionResponse>
resumeSession(input: ResumeSessionRequest): Promise<ResumeSessionResponse>
closeSession(input: CloseSessionRequest): Promise<CloseSessionResponse>
forkSession(input: ForkSessionRequest): Promise<ForkSessionResponse>
setSessionConfigOption(input: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse>
setSessionMode(input: SetSessionModeRequest): Promise<SetSessionModeResponse>
setSessionModel(input: SetSessionModelRequest): Promise<SetSessionModelResponse>
prompt(input: PromptRequest): Promise<PromptResponse>
cancel(input: CancelNotification): Promise<void>
}
@@ -98,6 +99,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
const catalogs = new Map<string, Promise<Catalog>>()
const registeredMcp = new Map<string, Set<string>>()
const active = new Map<string, TurnControl>()
const capabilities = { writeTextFile: false }
const catalog = (cwd: string) => {
const cached = catalogs.get(cwd)
@@ -157,6 +159,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
return {
initialize: async (params) => {
capabilities.writeTextFile = params.clientCapabilities?.fs?.writeTextFile === true
const authMethod: AuthMethod = {
description: "Run `opencode auth login` in the terminal",
name: "Login with opencode",
@@ -173,7 +176,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
loadSession: true,
mcpCapabilities: { http: true, sse: false },
promptCapabilities: { embeddedContext: true, image: true },
sessionCapabilities: { close: {}, fork: {}, list: {}, resume: {} },
sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} },
},
authMethods: [authMethod],
agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
@@ -216,6 +219,14 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
}
},
deleteSession: async (params) => {
await input.client.session.remove({ sessionID: params.sessionId }).catch((error) => {
if (!isSessionNotFoundError(error)) throw error
})
sessions.delete(params.sessionId)
registeredMcp.delete(params.sessionId)
return {}
},
resumeSession: async (params) => {
const session = await getSession(input.client, params.sessionId)
const state = await attach(session, session.location.directory, params.mcpServers ?? [])
@@ -270,13 +281,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
await selectMode(input.client, await requireSession(params.sessionId), params.modeId)
return {}
},
setSessionModel: async (params) => {
const state = await requireSession(params.sessionId)
const selected = requireModel(state.catalog, params.modelId)
state.model = selected
await input.client.session.switchModel({ sessionID: state.id, model: selected })
return {}
},
prompt: async (params) => {
const state = await requireSession(params.sessionId)
if (active.has(state.id)) {
@@ -295,7 +299,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
sessionID: state.id,
cwd: state.cwd,
start: prepared.start,
userMessageID: params.messageId,
writeTextFile: capabilities.writeTextFile,
control,
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
}).finally(() => {
@@ -479,6 +483,7 @@ async function registerMcpServers(
function mcpConfig(server: McpServer) {
if ("type" in server) {
if (server.type === "acp") throw new Error("MCP-over-ACP is not supported")
return {
type: "remote" as const,
url: server.url,
+3 -3
View File
@@ -140,11 +140,11 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
Flag.withDefault(false),
),
replay: Flag.boolean("replay").pipe(
Flag.withDescription("Replay session history on resume and after resize"),
Flag.withDefault(true),
Flag.withDescription("Restore session history on resume and resize (disable with --no-replay)"),
Flag.optional,
),
replayLimit: Flag.integer("replay-limit").pipe(
Flag.withDescription("Cap visible replay to the newest N messages"),
Flag.withDescription("Limit replay to the newest N messages (default: 200)"),
Flag.optional,
),
model: Flag.string("model").pipe(
+2 -2
View File
@@ -28,8 +28,8 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
model: Option.getOrUndefined(input.model),
agent: Option.getOrUndefined(input.agent),
prompt: Option.getOrUndefined(input.prompt),
replay: input.replay,
replayLimit: Option.getOrUndefined(input.replayLimit),
replay: Option.getOrUndefined(input.replay) ?? resolved.mini?.replay ?? true,
replayLimit: Option.getOrUndefined(input.replayLimit) ?? resolved.mini?.replay_limit,
demo: input.demo,
tuiConfig: resolved,
config: {
+3 -1
View File
@@ -439,6 +439,7 @@ describe("acp event behavior", () => {
sessionID: "ses_cancel",
cwd: "/workspace",
start: { type: "input", id: "input_cancel" },
writeTextFile: false,
control,
submit: async (signal) => {
await fixture.client.session.prompt(
@@ -481,6 +482,7 @@ describe("acp event behavior", () => {
sessionID: "ses_cancel_admission",
cwd: "/workspace",
start: { type: "input", id: "input_cancel_admission" },
writeTextFile: false,
control,
submit: (signal) =>
fixture.client.session.prompt(
@@ -566,7 +568,7 @@ function turn(input: {
sessionID: input.sessionID,
cwd: "/workspace",
start: { type: "input", id: input.inputID },
userMessageID: `client_${input.inputID}`,
writeTextFile: false,
control: { cancelled: false, admission: new AbortController() },
submit: (signal) =>
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),
+2 -3
View File
@@ -85,7 +85,6 @@ test("acp prompt resolves after ordered turn updates", async () => {
try {
const id = "msg_prompt"
const userMessageID = "client-message"
const response = await streamTurn({
client,
connection: {
@@ -97,7 +96,7 @@ test("acp prompt resolves after ordered turn updates", async () => {
sessionID: "ses_test",
cwd: "/workspace",
start: { type: "input", id },
userMessageID,
writeTextFile: false,
control: { cancelled: false, admission: new AbortController() },
submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }),
})
@@ -112,7 +111,7 @@ test("acp prompt resolves after ordered turn updates", async () => {
},
},
])
expect(response).toMatchObject({ stopReason: "end_turn", userMessageId: userMessageID, usage: { totalTokens: 2 } })
expect(response).toMatchObject({ stopReason: "end_turn", usage: { totalTokens: 2 } })
} finally {
events?.close()
await server.stop(true)
@@ -14,6 +14,7 @@ describe("acp initialize/auth subprocess", () => {
expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(false)
expect(initialized.agentCapabilities?.loadSession).toBe(true)
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.delete).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
@@ -1,5 +1,6 @@
import type {
CloseSessionResponse,
DeleteSessionResponse,
ListSessionsResponse,
LoadSessionResponse,
ResumeSessionResponse,
@@ -60,6 +61,20 @@ describe("acp lifecycle subprocess", () => {
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true)
}, 60_000)
test("delete capability and delete request", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
const initialized = await initialize(acp)
expect(initialized.agentCapabilities?.sessionCapabilities?.delete).toEqual({})
const session = await newSession(acp, fixture.home)
expect(
expectOk(await acp.request<DeleteSessionResponse>("session/delete", { sessionId: session.sessionId })),
).toEqual({})
const listed = expectOk(await acp.request<ListSessionsResponse>("session/list", { cwd: fixture.home }))
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(false)
}, 60_000)
test("resume capability advertisement", async () => {
await using fixture = await createAcpFixture()
const initialized = await initialize(fixture.spawn())
@@ -4,6 +4,7 @@ import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { streamTurn } from "../../src/acp/event"
import { syncEditedFiles } from "../../src/acp/permission"
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
@@ -12,6 +13,27 @@ type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission
type Fixture = ReturnType<typeof createSseFixture>
describe("acp permission behavior", () => {
test("does not sync edits when writeTextFile was not advertised", async () => {
const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
await syncEditedFiles({
connection: {
writeTextFile: async (input) => {
writes.push(input)
return {}
},
},
writeTextFile: false,
sessionID: "ses_no_write",
cwd: "/workspace",
toolName: "edit",
toolInput: { filePath: "/workspace/file.ts" },
structured: {},
})
expect(writes).toEqual([])
})
test("forwards allow-once and allow-always selections to the generated client", async () => {
const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({
@@ -465,6 +487,7 @@ function startTurn(fixture: Fixture, connection: Connection, sessionID: string,
sessionID,
cwd,
start: { type: "input", id: inputID },
writeTextFile: true,
control: { cancelled: false, admission: new AbortController() },
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inputID, text: "hello" }, { signal }),
})
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { McpServer, SessionConfigOption } from "@agentclientprotocol/sdk"
import { makeACPFixture, makeSession, secondModel, testModel } from "./service-fixture"
import { makeACPFixture, makeSession, secondModel } from "./service-fixture"
describe("acp service directory behavior", () => {
test("creates sessions from a catalog shared by concurrent callers in the same cwd", async () => {
@@ -134,7 +134,6 @@ describe("acp service directory behavior", () => {
configId: "mode",
value: "plan",
})
await fixture.service.setSessionModel({ sessionId: session.sessionId, modelId: "test/test-model/high" })
await fixture.service.setSessionMode({ sessionId: session.sessionId, modeId: "build" })
expect(currentValue(selectedModel, "model")).toBe("test/second-model")
@@ -148,7 +147,6 @@ describe("acp service directory behavior", () => {
).toEqual([
{ model: { providerID: "test", id: secondModel.id } },
{ model: { providerID: "test", id: secondModel.id, variant: "medium" } },
{ model: { providerID: "test", id: testModel.id, variant: "high" } },
])
expect(
fixture.requests
@@ -225,6 +225,33 @@ describe("acp service lifecycle", () => {
"/api/session/missing/interrupt",
])
})
test("deletes sessions from backing and local storage", async () => {
await using fixture = makeACPFixture({
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_delete") })
}
if (request.method === "DELETE" && request.path === "/api/session/ses_delete") {
return new Response(null, { status: 204 })
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
expect(await fixture.service.deleteSession({ sessionId: session.sessionId })).toEqual({})
expect(fixture.requests).toContainEqual({
method: "DELETE",
path: "/api/session/ses_delete",
query: {},
body: undefined,
})
const missing = await fixture.service
.setSessionConfigOption({ sessionId: session.sessionId, configId: "effort", value: "high" })
.catch((error: unknown) => error)
expect(missing).toMatchObject({ _tag: "ACPSessionNotFoundError", sessionId: session.sessionId })
})
})
function currentValue(result: { readonly configOptions?: readonly SessionConfigOption[] | null }, id: string) {
@@ -45,17 +45,14 @@ describe("acp service prompt routing and usage", () => {
const commandResult = await fixture.service.prompt({
sessionId: session.sessionId,
messageId: "client-command",
prompt: [{ type: "text", text: "/review now" }],
})
const skillResult = await fixture.service.prompt({
sessionId: session.sessionId,
messageId: "client-skill",
prompt: [{ type: "text", text: "/verify" }],
})
const compactResult = await fixture.service.prompt({
sessionId: session.sessionId,
messageId: "client-compact",
prompt: [{ type: "text", text: "/compact" }],
})
@@ -154,13 +151,11 @@ describe("acp service prompt routing and usage", () => {
const response = await fixture.service.prompt({
sessionId: session.sessionId,
messageId: "client-message",
prompt: [{ type: "text", text: "hello" }],
})
expect(response).toEqual({
stopReason: "end_turn",
userMessageId: "client-message",
usage: {
inputTokens: 100,
outputTokens: 40,
+5 -1
View File
@@ -214,11 +214,15 @@ describe("mini command", () => {
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("--server string")
expect(result.stdout).toContain("--prompt string")
expect(result.stdout).toContain("--replay")
expect(result.stdout).toContain("disable with --no-replay")
expect(result.stdout).toContain("--replay-limit integer")
expect(result.stdout).toContain("Limit replay to the newest N messages (default: 200)")
expect(result.stdout).not.toContain("SUBCOMMANDS")
})
test("routes local and explicit-server invocations into mini", async () => {
for (const args of [["mini"], ["mini", "--server", "http://127.0.0.1:1"]]) {
for (const args of [["mini"], ["mini", "--no-replay"], ["mini", "--server", "http://127.0.0.1:1"]]) {
const result = await cli(args)
expect(result.exitCode).toBe(1)
@@ -8,7 +8,7 @@ export type ModelRef = { id: string; providerID: string; variant?: string }
export type ProviderSettings = { [x: string]: JsonValue }
export type AgentColor = string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info"
export type AgentColor = string
export type PermissionV2Effect = "allow" | "deny" | "ask"
+3 -19
View File
@@ -10561,26 +10561,10 @@
"additionalProperties": false
},
"Agent.Color": {
"anyOf": [
"type": "string",
"allOf": [
{
"type": "string",
"allOf": [
{
"pattern": "^#[0-9a-fA-F]{6}$"
}
]
},
{
"type": "string",
"enum": [
"primary",
"secondary",
"accent",
"success",
"warning",
"error",
"info"
]
"pattern": "^#[0-9a-fA-F]{6}$"
}
]
},
+1 -1
View File
@@ -116,7 +116,7 @@ const layer = Layer.effect(
draft.providers.set(providerID, record)
}
const model =
record.models.get(modelID) ?? (ModelV2.Info.empty(providerID, modelID) as ModelV2.MutableInfo)
record.models.get(modelID) ?? (ModelV2.Info.default(providerID, modelID) as ModelV2.MutableInfo)
if (!record.models.has(modelID)) record.models.set(modelID, model)
fn(model)
model.id = modelID
+1 -4
View File
@@ -6,10 +6,7 @@ import { ConfigProvider } from "./provider"
import { ConfigModel } from "./model"
import { PositiveInt } from "../schema"
export const Color = Schema.Union([
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
])
export const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
export class Info extends Schema.Class<Info>("ConfigV2.Agent")({
model: ConfigModel.Selection.pipe(Schema.optional),
+30 -50
View File
@@ -2,12 +2,10 @@ export * as Generate from "./generate"
import { LLM, LLMClient, LLMError } from "@opencode-ai/ai"
import { Context, Effect, Layer, Schema } from "effect"
import { Catalog } from "./catalog"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "./effect/app-node-platform"
import { Integration } from "./integration"
import { ModelResolver } from "./model-resolver"
import { ModelV2 } from "./model"
import { SessionRunnerModel } from "./session/runner/model"
export interface TextInput {
readonly prompt: string
@@ -19,10 +17,10 @@ export class ModelSelectionError extends Schema.TaggedErrorClass<ModelSelectionE
{ message: Schema.String },
) {}
export class UnavailableError extends Schema.TaggedErrorClass<UnavailableError>()(
"Generate.UnavailableError",
{ message: Schema.String, service: Schema.optional(Schema.String) },
) {}
export class UnavailableError extends Schema.TaggedErrorClass<UnavailableError>()("Generate.UnavailableError", {
message: Schema.String,
service: Schema.optional(Schema.String),
}) {}
export type Error = ModelSelectionError | UnavailableError
@@ -35,56 +33,34 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const llm = yield* LLMClient.Service
const selectModel = Effect.fn("Generate.selectModel")(function* (requested?: ModelV2.Ref) {
const selected = requested
? yield* catalog.model.get(requested.providerID, requested.id)
: yield* catalog.model.default().pipe(
Effect.flatMap((model) =>
model && SessionRunnerModel.supported(model)
? Effect.succeed(model)
: Effect.map(catalog.model.available(), (models) => models.find(SessionRunnerModel.supported)),
),
)
if (!selected)
return yield* new ModelSelectionError({
message: requested
? `Model unavailable: ${requested.providerID}/${requested.id}`
: "No model specified and no supported model is available",
})
return yield* SessionRunnerModel.withVariant(selected, requested?.variant).pipe(
Effect.mapError(
() =>
new ModelSelectionError({
message: `Variant unavailable for ${selected.providerID}/${selected.id}: ${requested?.variant}`,
}),
),
)
})
const resolver = yield* ModelResolver.Service
const runText = Effect.fn("Generate.text")(function* (input: TextInput) {
const selected = yield* selectModel(input.model)
const provider = yield* catalog.provider.get(selected.providerID)
const connection = yield* integrations.connection.active(
provider?.integrationID ?? Integration.ID.make(selected.providerID),
const resolved = yield* resolver.resolve(input.model).pipe(
Effect.catchTags({
"SessionRunnerModel.VariantUnavailableError": (error) =>
input.model
? new ModelSelectionError({ message: error.message })
: new UnavailableError({ message: error.message, service: error.providerID }),
"SessionRunnerModel.UnsupportedPackageError": (error) =>
input.model
? new ModelSelectionError({ message: error.message })
: new UnavailableError({ message: error.message, service: error.providerID }),
}),
)
const credential = connection ? yield* integrations.connection.resolve(connection) : undefined
const model = yield* SessionRunnerModel.fromCatalogModel(selected, credential).pipe(
Effect.mapError((error) =>
input.model
? new ModelSelectionError({ message: error.message })
: new UnavailableError({ message: error.message, service: selected.providerID }),
),
)
const response = yield* llm.generate(LLM.request({ model, prompt: input.prompt })).pipe(
if (!resolved)
return yield* new ModelSelectionError({
message: input.model
? `Model unavailable: ${input.model.providerID}/${input.model.id}`
: "No model specified and no supported model is available",
})
const response = yield* llm.generate(LLM.request({ model: resolved.model, prompt: input.prompt })).pipe(
Effect.mapError(
(error: LLMError) =>
new UnavailableError({
message: error.message,
service: selected.providerID,
service: resolved.ref.providerID,
}),
),
)
@@ -106,4 +82,8 @@ export const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Catalog.node, Integration.node, llmClient] })
export const node = makeLocationNode({
service: Service,
layer,
deps: [ModelResolver.node, llmClient],
})
+1 -1
View File
@@ -135,7 +135,7 @@ function build(id: ModelV2.ID, remote: UsableModel, baseURL: string, previous?:
const released = previous?.time.released || Date.parse(version)
return ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.githubCopilot, id),
...ModelV2.Info.default(ProviderV2.ID.githubCopilot, id),
id,
modelID: ModelV2.ID.make(remote.id),
providerID: ProviderV2.ID.githubCopilot,
+2
View File
@@ -20,6 +20,7 @@ import { Integration } from "./integration"
import { Location } from "./location"
import { LocationMutation } from "./location-mutation"
import { LocationServiceMap } from "./location-service-map"
import { ModelResolver } from "./model-resolver"
import { MCP } from "./mcp/index"
import { PermissionV2 } from "./permission"
import { PluginV2 } from "./plugin"
@@ -58,6 +59,7 @@ const locationServiceNodes = [
Reference.node,
Integration.node,
Catalog.node,
ModelResolver.node,
AISDK.node,
PluginV2.node,
PluginSupervisor.node,
+344
View File
@@ -0,0 +1,344 @@
export * as ModelResolver from "./model-resolver"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Model } from "@opencode-ai/ai"
// ast-grep-ignore: no-star-import
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
// ast-grep-ignore: no-star-import
import * as OpenAICompatibleChat from "@opencode-ai/ai/protocols/openai-compatible-chat"
// ast-grep-ignore: no-star-import
import * as OpenAIResponses from "@opencode-ai/ai/protocols/openai-responses"
import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
import { Context, Effect, Layer, Schema } from "effect"
import { produce } from "immer"
import { AISDK } from "./aisdk"
import { Catalog } from "./catalog"
import { Credential } from "./credential"
import { Integration } from "./integration"
import { ModelV2 } from "./model"
import { Npm } from "@opencode-ai/util/npm"
import { OpenAICodex } from "./plugin/provider/openai-codex"
import { ProviderV2 } from "./provider"
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
"SessionRunnerModel.VariantUnavailableError",
{
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
variant: ModelV2.VariantID,
},
) {
override get message() {
return `Variant unavailable for ${this.providerID}/${this.modelID}: ${this.variant}`
}
}
export class UnsupportedPackageError extends Schema.TaggedErrorClass<UnsupportedPackageError>()(
"SessionRunnerModel.UnsupportedPackageError",
{
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
package: Schema.String,
},
) {
override get message() {
return `Unsupported package for ${this.providerID}/${this.modelID}: ${this.package}`
}
}
export type Error = VariantUnavailableError | UnsupportedPackageError | Integration.AuthorizationError
export interface Resolved {
/** Route-level model for provider requests; its id is the provider API model id, which may differ from the catalog id. */
readonly model: Model
/** Selected catalog identity. Durable records and displays must use this, never the API model id. */
readonly ref: ModelV2.Ref
/** Catalog capabilities used to shape requests before provider lowering. */
readonly capabilities: ModelV2.Capabilities
/** Catalog pricing in dollars per million tokens. */
readonly cost: ModelV2.Info["cost"]
}
export interface Interface {
readonly resolve: (requested?: ModelV2.Ref) => Effect.Effect<Resolved | undefined, Error>
readonly resolveModel: (model: ModelV2.Info, variant?: ModelV2.VariantID) => Effect.Effect<Resolved, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ModelResolver") {}
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
if (credential?.type === "key") return Auth.value(credential.key)
if (credential?.type === "oauth") return Auth.value(credential.access)
const value = model.settings?.apiKey
if (typeof value === "string") return Auth.value(value)
return undefined
}
const withDefaults = (model: ModelV2.Info, route: AnyRoute) =>
route.with({
provider: model.providerID,
endpoint: typeof model.settings?.baseURL === "string" ? { baseURL: model.settings.baseURL } : undefined,
headers: providerHeaders(model),
providerOptions: providerOptions(model),
http: model.body === undefined ? undefined : { body: model.body },
limits: { context: model.limit.context, output: model.limit.output },
})
const providerHeaders = (model: ModelV2.Info) => {
const packageName = ProviderV2.packageName(model.package)
const generated = new Map<string, string>()
if (packageName === "@ai-sdk/openai" && typeof model.settings?.organization === "string")
generated.set("OpenAI-Organization", model.settings.organization)
if (packageName === "@ai-sdk/openai" && typeof model.settings?.project === "string")
generated.set("OpenAI-Project", model.settings.project)
if (packageName === "@ai-sdk/anthropic" && typeof model.settings?.authToken === "string")
generated.set("Authorization", `Bearer ${model.settings.authToken}`)
return ProviderV2.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers)
}
const providerOptions = (
model: ModelV2.Info,
): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
if (!ProviderV2.isAISDK(model.package) || model.settings === undefined) return undefined
const { apiKey: _, baseURL: _baseURL, ...settings } = model.settings
if (Object.keys(settings).length === 0) return undefined
const packageName = ProviderV2.packageName(model.package)
if (packageName === "@ai-sdk/openai") return { openai: settings }
if (packageName === "@ai-sdk/anthropic") return { anthropic: settings }
if (packageName === "@ai-sdk/openai-compatible") return { openai: settings }
return undefined
}
export const withVariant = (
model: ModelV2.Info,
variantID: ModelV2.VariantID | undefined,
): Effect.Effect<ModelV2.Info, VariantUnavailableError> => {
const id = variantID === "default" ? undefined : variantID
const variant = model.variants?.find((item) => item.id === id)
if (!variant && variantID !== undefined && variantID !== "default")
return Effect.fail(
new VariantUnavailableError({
providerID: model.providerID,
modelID: model.id,
variant: variantID,
}),
)
return Effect.succeed(
variant
? produce(model, (draft) => {
draft.settings = ProviderV2.mergeOverlay(draft.settings, variant.settings)
draft.headers = ProviderV2.mergeHeaders(draft.headers, variant.headers)
draft.body = ProviderV2.mergeOverlay(draft.body, variant.body)
})
: model,
)
}
export interface Dependencies {
readonly loadPackage?: (specifier: string) => Effect.Effect<ProviderV2.ProviderPackage, ProviderV2.LoadError>
readonly loadAISDK?: (model: ModelV2.Info) => Effect.Effect<Model, AISDK.InitError>
}
export const fromCatalogModel = (
model: ModelV2.Info,
credential?: Credential.Value,
dependencies?: Dependencies,
): Effect.Effect<Model, UnsupportedPackageError> => {
const resolved = produce(model, (draft) => {
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
if (credential?.type === "key" && credential.metadata !== undefined)
draft.body = ProviderV2.mergeOverlay(draft.body, credential.metadata)
})
const packageName = ProviderV2.packageName(resolved.package)
const key = apiKey(resolved, credential)
if (OpenAICodex.isChatGPT(credential) && !ProviderV2.isAISDK(resolved.package) && isNativeOpenAI(resolved.package)) {
return Effect.succeed(codexModel(resolved, credential, key))
}
if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
if (OpenAICodex.isChatGPT(credential)) return Effect.succeed(codexModel(resolved, credential, key))
return Effect.succeed(
withDefaults(resolved, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") {
return Effect.succeed(
withDefaults(resolved, AnthropicMessages.route)
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (
ProviderV2.isAISDK(resolved.package) &&
packageName === "@ai-sdk/openai-compatible" &&
typeof resolved.settings?.baseURL === "string"
) {
return Effect.succeed(
withDefaults(resolved, OpenAICompatibleChat.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (ProviderV2.isAISDK(resolved.package)) {
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
const runtime = produce(resolved, (draft) => {
draft.settings = ProviderV2.mergeOverlay(draft.settings, {
...(credential?.type === "key" ? { apiKey: credential.key } : {}),
...(credential?.type === "oauth" ? { apiKey: credential.access } : {}),
...credential?.metadata,
})
})
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
}
if (!resolved.package) return Effect.fail(unsupported(resolved))
const specifier = resolved.package
return Effect.gen(function* () {
const module = yield* (dependencies?.loadPackage ?? ProviderV2.loadPackage)(specifier).pipe(
Effect.mapError(() => unsupported(resolved)),
)
const configured = { ...resolved.settings, ...credential?.metadata }
const settings = {
...(credential ? withoutNativeAuthSettings(configured) : configured),
...nativeCredentialSettings(specifier, credential),
headers: resolved.headers,
body: resolved.body,
limits: { context: resolved.limit.context, output: resolved.limit.output },
}
return yield* Effect.try({
try: () => {
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
return Model.update(runtime, {
provider: resolved.providerID,
compatibility: resolved.compatibility
? Object.assign({}, runtime.compatibility, resolved.compatibility)
: runtime.compatibility,
})
},
catch: () => unsupported(resolved),
})
})
}
const isNativeOpenAI = (packageName: string | undefined) =>
packageName === "@opencode-ai/ai/providers/openai" ||
packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
if (!credential) return {}
if (credential.type === "key") return { apiKey: credential.key }
if (
specifier === "@opencode-ai/ai/providers/anthropic" ||
specifier === "@opencode-ai/ai/providers/anthropic-compatible"
)
return { authToken: credential.access }
if (
specifier === "@opencode-ai/ai/providers/google-vertex" ||
specifier.startsWith("@opencode-ai/ai/providers/google-vertex/")
)
return { accessToken: credential.access }
return { apiKey: credential.access }
}
const withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
const { accessToken: _accessToken, apiKey: _apiKey, authToken: _authToken, ...rest } = settings
return rest
}
const codexModel = (
model: ModelV2.Info,
credential: Credential.Value | undefined,
key: ReturnType<typeof Auth.value> | undefined,
) => {
const account = OpenAICodex.accountID(credential)
return withDefaults(model, OpenAIResponses.route)
.with({
endpoint: { baseURL: OpenAICodex.baseURL },
auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen(
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
),
})
.model({ id: model.modelID ?? model.id, compatibility: model.compatibility })
}
const unsupported = (model: ModelV2.Info) =>
new UnsupportedPackageError({
providerID: model.providerID,
modelID: model.id,
package: model.package ?? "unknown",
})
export const resolveModel = (
model: ModelV2.Info,
variant: ModelV2.VariantID | undefined,
credential?: Credential.Value,
dependencies?: Dependencies,
) => withVariant(model, variant).pipe(Effect.flatMap((model) => fromCatalogModel(model, credential, dependencies)))
export const supported = (model: ModelV2.Info) => Boolean(model.package)
/** Resolves catalog selections into runtime models for the current Location. */
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const npm = yield* Npm.Service
const aisdk = yield* AISDK.Service
const load = Effect.fn("ModelResolver.resolveModel")(function* (
selected: ModelV2.Info,
variant?: ModelV2.VariantID,
) {
const provider = yield* catalog.provider.get(selected.providerID)
const connection = yield* integrations.connection.active(
provider?.integrationID ?? Integration.ID.make(selected.providerID),
)
const model = yield* resolveModel(
selected,
variant,
connection ? yield* integrations.connection.resolve(connection) : undefined,
{
loadPackage: (specifier) => ProviderV2.loadPackage(specifier, npm),
loadAISDK: (model) => aisdk.model(model),
},
)
return {
model,
ref: ModelV2.Ref.make({
id: selected.id,
providerID: selected.providerID,
...(variant === undefined ? {} : { variant }),
}),
capabilities: selected.capabilities,
cost: selected.cost,
}
})
return Service.of({
resolve: Effect.fn("ModelResolver.resolve")(function* (requested) {
const selected = requested
? yield* catalog.model.get(requested.providerID, requested.id)
: yield* catalog.model
.default()
.pipe(
Effect.flatMap((model) =>
model && supported(model)
? Effect.succeed(model)
: Effect.map(catalog.model.available(), (models) => models.find(supported)),
),
)
if (!selected) return undefined
return yield* load(selected, requested?.variant)
}),
resolveModel: load,
})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Catalog.node, Integration.node, Npm.node, AISDK.node],
})
@@ -1,7 +1,7 @@
export * as OpenAICodex from "./openai-codex"
// TEMPORARY SEAM (#34765): plugins have no hook into LLM route construction, so
// codex routing lives in SessionRunnerModel.fromCatalogModel and catalog filtering
// Codex routing lives in ModelResolver and catalog filtering.
// in OpenAIPlugin, sharing this module. Once the native provider packages land
// (#33689/#33925/#34462) this should collapse into the native OpenAI provider.
// The eligibility rules mirror V1's CodexAuthPlugin allowlist; models.dev has no
+21 -323
View File
@@ -2,30 +2,16 @@ export * as SessionRunnerModel from "./model"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Model } from "@opencode-ai/ai"
// ast-grep-ignore: no-star-import
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
// ast-grep-ignore: no-star-import
import * as OpenAICompatibleChat from "@opencode-ai/ai/protocols/openai-compatible-chat"
// ast-grep-ignore: no-star-import
import * as OpenAIResponses from "@opencode-ai/ai/protocols/openai-responses"
import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
import { Context, Effect, Layer, Schema } from "effect"
import { produce } from "immer"
import { AISDK } from "../../aisdk"
import { Catalog } from "../../catalog"
import { Credential } from "../../credential"
import { Integration } from "../../integration"
import { ModelResolver } from "../../model-resolver"
import { ModelV2 } from "../../model"
import { Npm } from "@opencode-ai/util/npm"
import { OpenAICodex } from "../../plugin/provider/openai-codex"
import { ProviderV2 } from "../../provider"
import { SessionSchema } from "../schema"
export class ModelNotSelectedError extends Schema.TaggedErrorClass<ModelNotSelectedError>()(
"SessionRunnerModel.ModelNotSelectedError",
{
sessionID: SessionSchema.ID,
},
{ sessionID: SessionSchema.ID },
) {
override get message() {
return `No model is available for session ${this.sessionID}`
@@ -34,59 +20,19 @@ export class ModelNotSelectedError extends Schema.TaggedErrorClass<ModelNotSelec
export class ModelUnavailableError extends Schema.TaggedErrorClass<ModelUnavailableError>()(
"SessionRunnerModel.ModelUnavailableError",
{
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
},
{ providerID: ProviderV2.ID, modelID: ModelV2.ID },
) {
override get message() {
return `Model unavailable: ${this.providerID}/${this.modelID}`
}
}
export const VariantUnavailableError = ModelResolver.VariantUnavailableError
export type VariantUnavailableError = ModelResolver.VariantUnavailableError
export const UnsupportedPackageError = ModelResolver.UnsupportedPackageError
export type UnsupportedPackageError = ModelResolver.UnsupportedPackageError
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
"SessionRunnerModel.VariantUnavailableError",
{
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
variant: ModelV2.VariantID,
},
) {
override get message() {
return `Variant unavailable for ${this.providerID}/${this.modelID}: ${this.variant}`
}
}
export class UnsupportedPackageError extends Schema.TaggedErrorClass<UnsupportedPackageError>()(
"SessionRunnerModel.UnsupportedPackageError",
{
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
package: Schema.String,
},
) {
override get message() {
return `Unsupported package for ${this.providerID}/${this.modelID}: ${this.package}`
}
}
export type Error =
| ModelNotSelectedError
| ModelUnavailableError
| VariantUnavailableError
| UnsupportedPackageError
| Integration.AuthorizationError
export interface Resolved {
/** Route-level model for provider requests; its id is the provider API model id, which may differ from the catalog id. */
readonly model: Model
/** Selected catalog identity. Durable records and displays must use this, never the API model id. */
readonly ref: ModelV2.Ref
/** Catalog capabilities used to shape requests before provider lowering. */
readonly capabilities: ModelV2.Capabilities
/** Catalog pricing in dollars per million tokens. */
readonly cost: ModelV2.Info["cost"]
}
export type Error = ModelNotSelectedError | ModelUnavailableError | ModelResolver.Error
export type Resolved = ModelResolver.Resolved
export interface Interface {
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Resolved, Error>
@@ -94,9 +40,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRunnerModel") {}
/** Test or embedding seam for supplying a model resolver directly. */
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
/** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */
export const resolved = (
model: Model,
@@ -116,276 +59,31 @@ export const resolved = (
cost: options.cost,
})
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
if (credential?.type === "key") return Auth.value(credential.key)
if (credential?.type === "oauth") return Auth.value(credential.access)
const value = model.settings?.apiKey
if (typeof value === "string") return Auth.value(value)
}
const withDefaults = (model: ModelV2.Info, route: AnyRoute) =>
route.with({
provider: model.providerID,
endpoint: typeof model.settings?.baseURL === "string" ? { baseURL: model.settings.baseURL } : undefined,
headers: providerHeaders(model),
providerOptions: providerOptions(model),
http: model.body === undefined ? undefined : { body: model.body },
limits: { context: model.limit.context, output: model.limit.output },
})
const providerHeaders = (model: ModelV2.Info) => {
const packageName = ProviderV2.packageName(model.package)
const generated = new Map<string, string>()
if (packageName === "@ai-sdk/openai" && typeof model.settings?.organization === "string")
generated.set("OpenAI-Organization", model.settings.organization)
if (packageName === "@ai-sdk/openai" && typeof model.settings?.project === "string")
generated.set("OpenAI-Project", model.settings.project)
if (packageName === "@ai-sdk/anthropic" && typeof model.settings?.authToken === "string")
generated.set("Authorization", `Bearer ${model.settings.authToken}`)
return ProviderV2.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers)
}
const providerOptions = (
model: ModelV2.Info,
): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
if (!ProviderV2.isAISDK(model.package) || model.settings === undefined) return undefined
const { apiKey: _, baseURL: _baseURL, ...settings } = model.settings
if (Object.keys(settings).length === 0) return undefined
const packageName = ProviderV2.packageName(model.package)
if (packageName === "@ai-sdk/openai") return { openai: settings }
if (packageName === "@ai-sdk/anthropic") return { anthropic: settings }
if (packageName === "@ai-sdk/openai-compatible") return { openai: settings }
}
export const withVariant = (
model: ModelV2.Info,
variantID: ModelV2.VariantID | undefined,
): Effect.Effect<ModelV2.Info, VariantUnavailableError> => {
const id = variantID === "default" ? undefined : variantID
const variant = model.variants?.find((item) => item.id === id)
if (!variant && variantID !== undefined && variantID !== "default")
return Effect.fail(
new VariantUnavailableError({
providerID: model.providerID,
modelID: model.id,
variant: variantID,
}),
)
return Effect.succeed(
variant
? produce(model, (draft) => {
draft.settings = ProviderV2.mergeOverlay(draft.settings, variant.settings)
draft.headers = ProviderV2.mergeHeaders(draft.headers, variant.headers)
draft.body = ProviderV2.mergeOverlay(draft.body, variant.body)
})
: model,
)
}
export interface Dependencies {
readonly loadPackage?: (specifier: string) => Effect.Effect<ProviderV2.ProviderPackage, ProviderV2.LoadError>
readonly loadAISDK?: (model: ModelV2.Info) => Effect.Effect<Model, AISDK.InitError>
}
export const fromCatalogModel = (
model: ModelV2.Info,
credential?: Credential.Value,
dependencies: Dependencies = {},
): Effect.Effect<Model, UnsupportedPackageError> => {
const resolved = produce(model, (draft) => {
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
if (credential?.type === "key" && credential.metadata !== undefined)
draft.body = ProviderV2.mergeOverlay(draft.body, credential.metadata)
})
const packageName = ProviderV2.packageName(resolved.package)
const key = apiKey(resolved, credential)
if (OpenAICodex.isChatGPT(credential) && !ProviderV2.isAISDK(resolved.package) && isNativeOpenAI(resolved.package)) {
return Effect.succeed(codexModel(resolved, credential, key))
}
if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
if (OpenAICodex.isChatGPT(credential)) return Effect.succeed(codexModel(resolved, credential, key))
return Effect.succeed(
withDefaults(resolved, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") {
return Effect.succeed(
withDefaults(resolved, AnthropicMessages.route)
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (
ProviderV2.isAISDK(resolved.package) &&
packageName === "@ai-sdk/openai-compatible" &&
typeof resolved.settings?.baseURL === "string"
) {
return Effect.succeed(
withDefaults(resolved, OpenAICompatibleChat.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (ProviderV2.isAISDK(resolved.package)) {
if (!dependencies.loadAISDK) return Effect.fail(unsupported(resolved))
const runtime = produce(resolved, (draft) => {
draft.settings = ProviderV2.mergeOverlay(draft.settings, {
...(credential?.type === "key" ? { apiKey: credential.key } : {}),
...(credential?.type === "oauth" ? { apiKey: credential.access } : {}),
...credential?.metadata,
})
})
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
}
if (!resolved.package) return Effect.fail(unsupported(resolved))
const specifier = resolved.package
return Effect.gen(function* () {
const module = yield* (dependencies.loadPackage ?? ProviderV2.loadPackage)(specifier).pipe(
Effect.mapError(() => unsupported(resolved)),
)
const configured = { ...resolved.settings, ...credential?.metadata }
const settings = {
...(credential ? withoutNativeAuthSettings(configured) : configured),
...nativeCredentialSettings(specifier, credential),
headers: resolved.headers,
body: resolved.body,
limits: { context: resolved.limit.context, output: resolved.limit.output },
}
return yield* Effect.try({
try: () => {
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
return Model.update(runtime, {
provider: resolved.providerID,
compatibility: resolved.compatibility
? { ...runtime.compatibility, ...resolved.compatibility }
: runtime.compatibility,
})
},
catch: () => unsupported(resolved),
})
})
}
const isNativeOpenAI = (packageName: string | undefined) =>
packageName === "@opencode-ai/ai/providers/openai" ||
packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
if (!credential) return {}
if (credential.type === "key") return { apiKey: credential.key }
if (
specifier === "@opencode-ai/ai/providers/anthropic" ||
specifier === "@opencode-ai/ai/providers/anthropic-compatible"
)
return { authToken: credential.access }
if (
specifier === "@opencode-ai/ai/providers/google-vertex" ||
specifier.startsWith("@opencode-ai/ai/providers/google-vertex/")
)
return { accessToken: credential.access }
return { apiKey: credential.access }
}
const withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
const { accessToken: _accessToken, apiKey: _apiKey, authToken: _authToken, ...rest } = settings
return rest
}
const codexModel = (
model: ModelV2.Info,
credential: Credential.Value | undefined,
key: ReturnType<typeof Auth.value> | undefined,
) => {
const account = OpenAICodex.accountID(credential)
return withDefaults(model, OpenAIResponses.route)
.with({
endpoint: { baseURL: OpenAICodex.baseURL },
auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen(
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
),
})
.model({ id: model.modelID ?? model.id, compatibility: model.compatibility })
}
const unsupported = (model: ModelV2.Info) =>
new UnsupportedPackageError({
providerID: model.providerID,
modelID: model.id,
package: model.package ?? "unknown",
})
export const resolve = (
session: SessionSchema.Info,
model: ModelV2.Info,
credential?: Credential.Value,
dependencies?: Dependencies,
) =>
withVariant(model, session.model?.variant).pipe(
Effect.flatMap((model) => fromCatalogModel(model, credential, dependencies)),
)
export const supported = (model: ModelV2.Info) => Boolean(model.package)
/** Resolves models from the catalog belonging to the current Location runtime. */
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const npm = yield* Npm.Service
const aisdk = yield* AISDK.Service
const resolver = yield* ModelResolver.Service
return Service.of({
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
// Location plugins populate and filter the catalog asynchronously during layer startup.
const defaultModel = session.model ? undefined : yield* catalog.model.default()
const selected = session.model
? (yield* catalog.model.available()).find(
(model) => model.providerID === session.model?.providerID && model.id === session.model.id,
)
: defaultModel && supported(defaultModel)
? defaultModel
: (yield* catalog.model.available()).find(supported)
if (!selected && session.model)
if (!session.model) {
const resolved = yield* resolver.resolve()
if (resolved) return resolved
return yield* new ModelNotSelectedError({ sessionID: session.id })
}
const selected = (yield* catalog.model.available()).find(
(model) => model.providerID === session.model?.providerID && model.id === session.model.id,
)
if (!selected)
return yield* new ModelUnavailableError({
providerID: session.model.providerID,
modelID: session.model.id,
})
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
const provider = yield* catalog.provider.get(selected.providerID)
const connection = yield* integrations.connection.active(
provider?.integrationID ?? Integration.ID.make(selected.providerID),
)
const model = yield* resolve(
session,
selected,
connection ? yield* integrations.connection.resolve(connection) : undefined,
{
loadPackage: (specifier) => ProviderV2.loadPackage(specifier, npm),
loadAISDK: (model) => aisdk.model(model),
},
)
return {
model,
ref: ModelV2.Ref.make({
id: selected.id,
providerID: selected.providerID,
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
}),
capabilities: selected.capabilities,
cost: selected.cost,
}
return yield* resolver.resolveModel(selected, session.model.variant)
}),
})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Catalog.node, Integration.node, Npm.node, AISDK.node],
})
export const node = makeLocationNode({ service: Service, layer, deps: [Catalog.node, ModelResolver.node] })
+1 -1
View File
@@ -161,7 +161,7 @@ export function migrateAgent(info: ConfigAgentV1.Info) {
description: info.description,
mode: info.mode,
hidden: info.hidden,
color: info.color,
color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa",
steps: info.steps,
disabled: info.disable,
permissions: permissions(info.permission),
+1 -1
View File
@@ -12,7 +12,7 @@ const it = testEffect(AISDK.locationLayer)
const model = (packageName: string, settings: Record<string, unknown> = {}) =>
ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("test-provider"), ModelV2.ID.make("catalog-model")),
...ModelV2.Info.default(ProviderV2.ID.make("test-provider"), ModelV2.ID.make("catalog-model")),
modelID: ModelV2.ID.make("api-model"),
package: ProviderV2.aisdk(packageName),
settings,
+7 -3
View File
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect, Schema } from "effect"
@@ -23,6 +23,10 @@ const defaultPermissions = [
{ action: "external_directory", resource: "*", effect: "ask" },
] satisfies PermissionV2.Ruleset
test("rejects named agent color tokens", () => {
expect(() => decode({ agents: { reviewer: { color: "warning" } } })).toThrow()
})
describe("ConfigAgentPlugin.Plugin", () => {
it.effect("matches POSIX paths against home-relative permissions", () =>
Effect.gen(function* () {
@@ -160,7 +164,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
description: "Reviews changes",
mode: "subagent",
hidden: true,
color: "warning",
color: "#ff6b6b",
steps: 12,
request: {
headers: { first: "one", shared: "first" },
@@ -197,7 +201,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
description: "Reviews changes",
mode: "subagent",
hidden: true,
color: "warning",
color: "#ff6b6b",
steps: 12,
model: { providerID: "anthropic", id: "claude-sonnet" },
})
+2 -2
View File
@@ -738,7 +738,7 @@ describe("Config", () => {
system: "Find regressions.",
mode: "subagent",
hidden: false,
color: "warning",
color: "#ff6b6b",
steps: 12,
disabled: false,
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
@@ -824,7 +824,7 @@ describe("Config", () => {
expect(reviewer?.system).toBe("Find regressions.")
expect(reviewer?.mode).toBe("subagent")
expect(reviewer?.hidden).toBe(false)
expect(reviewer?.color).toBe("warning")
expect(reviewer?.color).toBe("#ff6b6b")
expect(reviewer?.steps).toBe(12)
expect(reviewer?.disabled).toBe(false)
expect(reviewer?.permissions).toEqual([{ action: "edit", resource: "*", effect: "deny" }])
@@ -49,6 +49,86 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
const decode = Schema.decodeUnknownSync(Config.Info)
describe("ConfigProviderPlugin.Plugin", () => {
it.effect("defaults custom models to agent capabilities", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("custom")
const modelID = ModelV2.ID.make("chat")
const config = Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({
providers: {
custom: {
package: "aisdk:@ai-sdk/openai-compatible",
models: { chat: {} },
},
},
}),
}),
]),
})
yield* addPlugin(config)
const model = required(yield* catalog.model.get(providerID, modelID))
expect(model.capabilities).toEqual({ tools: true, input: ["text", "image"], output: ["text"] })
}),
)
it.effect("preserves catalog capabilities unless config overrides them", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("custom")
const inheritedID = ModelV2.ID.make("inherited")
const overriddenID = ModelV2.ID.make("overridden")
yield* catalog.transform((draft) => {
draft.model.update(providerID, inheritedID, (model) => {
model.capabilities = { tools: false, input: ["text"], output: ["text"] }
})
draft.model.update(providerID, overriddenID, (model) => {
model.capabilities = { tools: false, input: ["text"], output: ["text"] }
})
})
const config = Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({
providers: {
custom: {
package: "aisdk:@ai-sdk/openai-compatible",
models: {
inherited: { name: "Inherited" },
overridden: {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
},
},
},
},
}),
}),
]),
})
yield* addPlugin(config)
expect((yield* catalog.model.get(providerID, inheritedID))?.capabilities).toEqual({
tools: false,
input: ["text"],
output: ["text"],
})
expect((yield* catalog.model.get(providerID, overriddenID))?.capabilities).toEqual({
tools: true,
input: ["text", "image"],
output: ["text"],
})
}),
)
it.effect("keeps configured model variant bodies unchanged", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
+111
View File
@@ -0,0 +1,111 @@
import { expect } from "bun:test"
import { LLMClient, LLMEvent, LLMResponse, Model } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { AISDK } from "@opencode-ai/core/aisdk"
import { Catalog } from "@opencode-ai/core/catalog"
import { Generate } from "@opencode-ai/core/generate"
import { Integration } from "@opencode-ai/core/integration"
import { ModelResolver } from "@opencode-ai/core/model-resolver"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Npm } from "@opencode-ai/util/npm"
import { Effect, Layer, Stream } from "effect"
import { testEffect } from "./lib/effect"
const selected = ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make("test-provider"), ModelV2.ID.make("gemini")),
package: ProviderV2.aisdk("@ai-sdk/google"),
})
const runtime = Model.make({ id: "gemini", provider: "test-provider", route: OpenAIChat.route })
const catalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.succeed(undefined),
all: () => Effect.die("unused"),
available: () => Effect.die("unused"),
},
model: {
get: () => Effect.succeed(selected),
all: () => Effect.die("unused"),
available: () => Effect.die("unused"),
default: () => Effect.die("unused"),
small: () => Effect.die("unused"),
},
})
const integrations = Layer.mock(Integration.Service, {
connection: {
active: () => Effect.succeed(undefined),
resolve: () => Effect.die("unused"),
key: () => Effect.die("unused"),
update: () => Effect.die("unused"),
remove: () => Effect.die("unused"),
},
oauth: {
connect: () => Effect.die("unused"),
status: () => Effect.die("unused"),
complete: () => Effect.die("unused"),
cancel: () => Effect.die("unused"),
},
command: {
connect: () => Effect.die("unused"),
status: () => Effect.die("unused"),
cancel: () => Effect.die("unused"),
},
})
const npm = Layer.mock(Npm.Service, {
add: () => Effect.die("unused"),
install: () => Effect.die("unused"),
which: () => Effect.die("unused"),
})
const aisdk = Layer.mock(AISDK.Service, {
hook: {
sdk: () => Effect.die("unused"),
language: () => Effect.die("unused"),
},
model: () => Effect.succeed(runtime),
})
const client = Layer.mock(LLMClient.Service)({
prepare: () => Effect.die("unused"),
stream: () => Stream.die("unused"),
generate: () =>
Effect.sync(() => {
const response = LLMResponse.fromEvents([
LLMEvent.textStart({ id: "generate" }),
LLMEvent.textDelta({ id: "generate", text: "OK" }),
LLMEvent.textEnd({ id: "generate" }),
LLMEvent.finish({ reason: "stop" }),
])
if (!response) throw new Error("Incomplete generate response")
return response
}),
})
const resolver = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
const it = testEffect(Generate.layer.pipe(Layer.provide(Layer.merge(resolver, client))))
const resolverIt = testEffect(resolver)
it.effect("loads dynamic AI SDK models", () =>
Effect.gen(function* () {
const generate = yield* Generate.Service
const result = yield* generate.text({
prompt: "Return exactly OK",
model: ModelV2.Ref.make({ providerID: selected.providerID, id: selected.id }),
})
expect(result).toBe("OK")
}),
)
resolverIt.effect("resolves dynamic models with their catalog metadata", () =>
Effect.gen(function* () {
const resolver = yield* ModelResolver.Service
const result = yield* resolver.resolve(ModelV2.Ref.make({ providerID: selected.providerID, id: selected.id }))
expect(result).toEqual({
model: runtime,
ref: ModelV2.Ref.make({ providerID: selected.providerID, id: selected.id }),
capabilities: selected.capabilities,
cost: selected.cost,
})
}),
)
@@ -49,12 +49,12 @@ test("defensively syncs advertised Copilot models", async () => {
try {
const existing = ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.githubCopilot, ModelV2.ID.make("gpt-5")),
...ModelV2.Info.default(ProviderV2.ID.githubCopilot, ModelV2.ID.make("gpt-5")),
modelID: ModelV2.ID.make("gpt-5"),
name: "GPT-5 local",
})
const stale = ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.githubCopilot, ModelV2.ID.make("stale")),
...ModelV2.Info.default(ProviderV2.ID.githubCopilot, ModelV2.ID.make("stale")),
modelID: ModelV2.ID.make("stale"),
})
const models = await CopilotModels.get(server.url.origin, {}, [existing, stale])
@@ -1,17 +1,13 @@
import { describe, expect } from "bun:test"
import { LLM, Model } from "@opencode-ai/ai"
import { LLMClient } from "@opencode-ai/ai/route"
import { DateTime, Effect } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ProjectV2 } from "@opencode-ai/core/project"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionV2 } from "@opencode-ai/core/session"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ModelResolver } from "@opencode-ai/core/model-resolver"
import { it } from "./lib/effect"
interface ModelOptions {
@@ -43,13 +39,13 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
limit: { context: 100, output: 20 },
})
describe("SessionRunnerModel", () => {
describe("ModelResolver", () => {
it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () =>
Effect.gen(function* () {
const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
})
const resolved = yield* SessionRunnerModel.fromCatalogModel(catalog)
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
expect(catalog.id).toBe(ModelV2.ID.make("test-model"))
expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
@@ -68,7 +64,7 @@ describe("SessionRunnerModel", () => {
it.effect("keeps catalog apiKey credentials out of provider JSON", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { apiKey: "secret", baseURL: "https://openai.example/v1" },
}),
@@ -82,7 +78,7 @@ describe("SessionRunnerModel", () => {
it.effect("treats an empty configured API key as omitted", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { apiKey: "", baseURL: "https://openai.example/v1" },
}),
@@ -101,7 +97,7 @@ describe("SessionRunnerModel", () => {
it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai-compatible"), {
compatibility: { reasoningField: "vendor_reasoning" },
settings: {
@@ -130,7 +126,7 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("overlays selected OpenAI Session variant settings and bodies", () =>
it.effect("overlays selected OpenAI variant settings and bodies", () =>
Effect.gen(function* () {
const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
@@ -147,22 +143,7 @@ describe("SessionRunnerModel", () => {
},
],
})
const session = SessionV2.Info.make({
id: SessionV2.ID.make("ses_model_variant"),
projectID: ProjectV2.ID.global,
title: "test",
model: {
id: catalog.id,
providerID: catalog.providerID,
variant: ModelV2.VariantID.make("high"),
},
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
})
const resolved = yield* SessionRunnerModel.resolve(session, catalog)
const resolved = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("high"))
expect(resolved.route.defaults.headers).toMatchObject({ "x-test": "header", "x-variant": "high" })
expect(resolved.route.defaults.http?.body).toEqual({
@@ -177,7 +158,7 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("overlays selected OpenAI-compatible Session variant bodies", () =>
it.effect("overlays selected OpenAI-compatible variant bodies", () =>
Effect.gen(function* () {
const catalog = model(ProviderV2.aisdk("@ai-sdk/openai-compatible"), {
settings: { baseURL: "https://compatible.example/v1" },
@@ -190,18 +171,7 @@ describe("SessionRunnerModel", () => {
},
],
})
const session = SessionV2.Info.make({
id: SessionV2.ID.make("ses_compatible_variant"),
projectID: ProjectV2.ID.global,
title: "test",
model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") },
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
})
const resolved = yield* SessionRunnerModel.resolve(session, catalog)
const resolved = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("high"))
expect(resolved.route.defaults.http?.body).toEqual({
custom_extension: { enabled: true },
@@ -211,27 +181,12 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("rejects an explicit unavailable Session variant during model resolution", () =>
it.effect("rejects an explicit unavailable variant during model resolution", () =>
Effect.gen(function* () {
const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
})
const session = SessionV2.Info.make({
id: SessionV2.ID.make("ses_model_variant_unavailable"),
projectID: ProjectV2.ID.global,
title: "test",
model: {
id: catalog.id,
providerID: catalog.providerID,
variant: ModelV2.VariantID.make("unknown"),
},
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
})
const failure = yield* SessionRunnerModel.resolve(session, catalog).pipe(Effect.flip)
const failure = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("unknown")).pipe(Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.VariantUnavailableError",
@@ -243,7 +198,7 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("overlays selected Anthropic Session variant settings", () =>
it.effect("overlays selected Anthropic variant settings", () =>
Effect.gen(function* () {
const catalog = model(ProviderV2.aisdk("@ai-sdk/anthropic"), {
settings: { baseURL: "https://anthropic.example/v1" },
@@ -256,18 +211,7 @@ describe("SessionRunnerModel", () => {
},
],
})
const session = SessionV2.Info.make({
id: SessionV2.ID.make("ses_anthropic_variant"),
projectID: ProjectV2.ID.global,
title: "test",
model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") },
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
})
const resolved = yield* SessionRunnerModel.resolve(session, catalog)
const resolved = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("high"))
expect(resolved.route.defaults.http?.body).toEqual({
custom_extension: { enabled: true },
@@ -280,7 +224,7 @@ describe("SessionRunnerModel", () => {
it.effect("maps catalog Anthropic AI SDK models into native routes", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/anthropic"), {
settings: { baseURL: "https://anthropic.example/v1" },
}),
@@ -296,7 +240,7 @@ describe("SessionRunnerModel", () => {
it.effect("uses resolved credentials for bearer auth", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
@@ -320,7 +264,7 @@ describe("SessionRunnerModel", () => {
it.effect("prefers stored credentials over configured auth", () =>
Effect.gen(function* () {
const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } })
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { apiKey: "configured-secret", baseURL: "https://openai.example/v1" },
headers: {},
@@ -343,7 +287,7 @@ describe("SessionRunnerModel", () => {
it.effect("does not project OAuth account metadata into the request body", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
@@ -365,7 +309,7 @@ describe("SessionRunnerModel", () => {
it.effect("routes ChatGPT OAuth credentials to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
@@ -400,7 +344,7 @@ describe("SessionRunnerModel", () => {
it.effect("routes native OpenAI provider packages with ChatGPT credentials to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model("@opencode-ai/ai/providers/openai", {
settings: { baseURL: "https://openai.example/v1" },
}),
@@ -429,7 +373,7 @@ describe("SessionRunnerModel", () => {
it.effect("does not route native OpenAI-compatible packages to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model("@opencode-ai/ai/providers/openai-compatible", {
settings: { baseURL: "https://compatible.example/v1" },
}),
@@ -450,7 +394,7 @@ describe("SessionRunnerModel", () => {
it.effect("maps legacy OpenAI organization and project settings to headers", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { organization: "org_123", project: "proj_123" },
}),
@@ -465,7 +409,7 @@ describe("SessionRunnerModel", () => {
it.effect("routes ChatGPT OAuth credentials without an account id to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
@@ -496,7 +440,7 @@ describe("SessionRunnerModel", () => {
it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
@@ -528,12 +472,12 @@ describe("SessionRunnerModel", () => {
it.effect("loads dynamic native provider packages through the injected package loader", () =>
Effect.gen(function* () {
const native = yield* SessionRunnerModel.fromCatalogModel(
const native = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
}),
)
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model("@opencode-ai/ai/providers/custom", {
settings: { region: "test" },
headers: { "x-package": "header" },
@@ -565,7 +509,7 @@ describe("SessionRunnerModel", () => {
it.effect("maps OAuth credentials to native provider auth settings", () =>
Effect.gen(function* () {
const native = yield* SessionRunnerModel.fromCatalogModel(
const native = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
}),
@@ -588,7 +532,7 @@ describe("SessionRunnerModel", () => {
] as const
yield* Effect.forEach(packages, ([specifier, key]) =>
SessionRunnerModel.fromCatalogModel(model(specifier, { settings: { apiKey: "configured-key" } }), credential, {
ModelResolver.fromCatalogModel(model(specifier, { settings: { apiKey: "configured-key" } }), credential, {
loadPackage: () =>
Effect.succeed({
model: (modelID, settings) => {
@@ -604,12 +548,12 @@ describe("SessionRunnerModel", () => {
it.effect("loads arbitrary AISDK packages through the injected AISDK loader", () =>
Effect.gen(function* () {
const native = yield* SessionRunnerModel.fromCatalogModel(
const native = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
}),
)
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/google"), {
modelID: "gemini-api-model",
settings: { project: "test" },
@@ -644,7 +588,7 @@ describe("SessionRunnerModel", () => {
it.effect("rejects AISDK packages without an available loader", () =>
Effect.gen(function* () {
const failure = yield* SessionRunnerModel.fromCatalogModel(
const failure = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/google"), {
settings: { baseURL: "https://google.example/v1" },
}),
@@ -662,12 +606,12 @@ describe("SessionRunnerModel", () => {
it.effect("drops an empty API key before loading an AISDK package", () =>
Effect.gen(function* () {
const native = yield* SessionRunnerModel.fromCatalogModel(
const native = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
}),
)
yield* SessionRunnerModel.fromCatalogModel(
yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/google"), {
settings: { apiKey: "", baseURL: "https://google.example/v1" },
}),
@@ -685,9 +629,9 @@ describe("SessionRunnerModel", () => {
it.effect("reports whether a catalog model declares a provider package", () =>
Effect.sync(() => {
expect(SessionRunnerModel.supported(model(ProviderV2.aisdk("@ai-sdk/openai")))).toBe(true)
expect(SessionRunnerModel.supported(model("@opencode-ai/ai/providers/custom"))).toBe(true)
expect(SessionRunnerModel.supported(model(undefined))).toBe(false)
expect(ModelResolver.supported(model(ProviderV2.aisdk("@ai-sdk/openai")))).toBe(true)
expect(ModelResolver.supported(model("@opencode-ai/ai/providers/custom"))).toBe(true)
expect(ModelResolver.supported(model(undefined))).toBe(false)
}),
)
})
@@ -1,96 +0,0 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect } from "bun:test"
import { createAlibaba } from "@ai-sdk/alibaba"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* AlibabaPlugin.effect(host)
})
describe("AlibabaPlugin", () => {
it.effect("creates an Alibaba SDK for @ai-sdk/alibaba", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")),
modelID: ModelV2.ID.make("qwen"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/alibaba",
options: { name: "alibaba" },
})
expect(result.sdk).toBeDefined()
}),
)
it.effect("ignores non-Alibaba SDK packages", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")),
modelID: ModelV2.ID.make("qwen"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/openai-compatible",
options: { name: "alibaba" },
})
expect(result.sdk).toBeUndefined()
}),
)
it.effect("matches the old bundled Alibaba SDK provider naming", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-alibaba"), ModelV2.ID.make("qwen")),
modelID: ModelV2.ID.make("qwen"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/alibaba",
options: { name: "custom-alibaba", apiKey: "test" },
})
const expected = createAlibaba({ apiKey: "test", ...{ name: "custom-alibaba" } }).languageModel("qwen")
const actual = result.sdk?.languageModel("qwen")
expect(actual?.provider).toBe(expected.provider)
expect(actual?.modelId).toBe(expected.modelId)
}),
)
it.effect("uses the default languageModel(modelID) behavior", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const item = ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("alias")),
modelID: ModelV2.ID.make("qwen-plus"),
package: "aisdk:test-provider",
})
const result = yield* aisdk.runSDK({ model: item, package: "@ai-sdk/alibaba", options: {} })
const language = result.sdk?.languageModel(item.modelID ?? item.id)
expect(language?.modelId).toBe("qwen-plus")
expect(language?.provider).toBe("alibaba.chat")
}),
)
})
@@ -108,7 +108,7 @@ describe("AmazonBedrockPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -134,7 +134,7 @@ describe("AmazonBedrockPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -169,7 +169,7 @@ describe("AmazonBedrockPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -190,7 +190,7 @@ describe("AmazonBedrockPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -210,7 +210,7 @@ describe("AmazonBedrockPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -230,7 +230,7 @@ describe("AmazonBedrockPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -251,7 +251,7 @@ describe("AmazonBedrockPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -281,7 +281,7 @@ describe("AmazonBedrockPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -310,7 +310,7 @@ describe("AmazonBedrockPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")),
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")),
modelID: ModelV2.ID.make("openai.gpt-5.5"),
package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/mantle"),
}),
@@ -338,7 +338,7 @@ describe("AmazonBedrockPlugin", () => {
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")),
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")),
modelID: ModelV2.ID.make("openai.gpt-5.5"),
package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/mantle"),
}),
@@ -347,7 +347,7 @@ describe("AmazonBedrockPlugin", () => {
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-oss-safeguard-120b")),
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-oss-safeguard-120b")),
modelID: ModelV2.ID.make("openai.gpt-oss-safeguard-120b"),
package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/mantle"),
}),
@@ -365,7 +365,7 @@ describe("AmazonBedrockPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/anthropic"),
}),
@@ -393,7 +393,7 @@ describe("AmazonBedrockPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -425,7 +425,7 @@ describe("AmazonBedrockPlugin", () => {
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -434,7 +434,7 @@ describe("AmazonBedrockPlugin", () => {
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -443,7 +443,7 @@ describe("AmazonBedrockPlugin", () => {
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("global.anthropic.claude-sonnet-4-5")),
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("global.anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("global.anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -452,7 +452,7 @@ describe("AmazonBedrockPlugin", () => {
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -461,7 +461,7 @@ describe("AmazonBedrockPlugin", () => {
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -487,7 +487,7 @@ describe("AmazonBedrockPlugin", () => {
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -574,7 +574,7 @@ describe("AmazonBedrockPlugin", () => {
for (const item of cases) {
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make(item.modelID)),
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make(item.modelID)),
modelID: ModelV2.ID.make(item.modelID),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -594,7 +594,7 @@ describe("AmazonBedrockPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -63,7 +63,7 @@ describe("AnthropicPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-anthropic"), ModelV2.ID.make("claude-sonnet-4-5")),
...ModelV2.Info.default(ProviderV2.ID.make("custom-anthropic"), ModelV2.ID.make("claude-sonnet-4-5")),
modelID: ModelV2.ID.make("claude-sonnet-4-5"),
package: ProviderV2.aisdk("@ai-sdk/anthropic"),
}),
@@ -81,7 +81,7 @@ describe("AnthropicPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-5")),
...ModelV2.Info.default(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-5")),
modelID: ModelV2.ID.make("claude-sonnet-4-5"),
package: ProviderV2.aisdk("@ai-sdk/anthropic"),
}),
@@ -121,7 +121,7 @@ describe("AzureCognitiveServicesPlugin", () => {
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")),
...ModelV2.Info.default(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")),
modelID: ModelV2.ID.make("deployment"),
package: "aisdk:test-provider",
}),
@@ -140,7 +140,7 @@ describe("AzureCognitiveServicesPlugin", () => {
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")),
...ModelV2.Info.default(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")),
modelID: ModelV2.ID.make("deployment"),
package: "aisdk:test-provider",
}),
@@ -149,7 +149,7 @@ describe("AzureCognitiveServicesPlugin", () => {
})
const ignored = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")),
...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("deployment")),
modelID: ModelV2.ID.make("deployment"),
package: "aisdk:test-provider",
}),
@@ -170,7 +170,7 @@ describe("AzureCognitiveServicesPlugin", () => {
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("messages-deployment")),
...ModelV2.Info.default(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("messages-deployment")),
modelID: ModelV2.ID.make("messages-deployment"),
package: "aisdk:test-provider",
}),
@@ -179,7 +179,7 @@ describe("AzureCognitiveServicesPlugin", () => {
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("chat-deployment")),
...ModelV2.Info.default(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("chat-deployment")),
modelID: ModelV2.ID.make("chat-deployment"),
package: "aisdk:test-provider",
}),
@@ -188,7 +188,7 @@ describe("AzureCognitiveServicesPlugin", () => {
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("language-deployment")),
...ModelV2.Info.default(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("language-deployment")),
modelID: ModelV2.ID.make("language-deployment"),
package: "aisdk:test-provider",
}),
@@ -148,7 +148,7 @@ describe("AzurePlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
modelID: ModelV2.ID.make("deployment"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -168,7 +168,7 @@ describe("AzurePlugin", () => {
const exit = yield* aisdk
.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
modelID: ModelV2.ID.make("deployment"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -189,7 +189,7 @@ describe("AzurePlugin", () => {
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
modelID: ModelV2.ID.make("deployment"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -208,7 +208,7 @@ describe("AzurePlugin", () => {
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
modelID: ModelV2.ID.make("deployment"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -227,7 +227,7 @@ describe("AzurePlugin", () => {
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
modelID: ModelV2.ID.make("deployment"),
package: ProviderV2.aisdk("test-provider"),
body: { useCompletionUrls: true },
@@ -247,7 +247,7 @@ describe("AzurePlugin", () => {
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
modelID: ModelV2.ID.make("deployment"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -256,7 +256,7 @@ describe("AzurePlugin", () => {
})
const ignored = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")),
...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("deployment")),
modelID: ModelV2.ID.make("deployment"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -280,7 +280,7 @@ describe("AzurePlugin", () => {
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("messages-deployment")),
...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("messages-deployment")),
modelID: ModelV2.ID.make("messages-deployment"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -289,7 +289,7 @@ describe("AzurePlugin", () => {
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("language-deployment")),
...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("language-deployment")),
modelID: ModelV2.ID.make("language-deployment"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -65,7 +65,7 @@ describe("CerebrasPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(
...ModelV2.Info.default(
ProviderV2.ID.make("custom-cerebras"),
ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
),
@@ -88,7 +88,7 @@ describe("CerebrasPlugin", () => {
yield* addPlugin()
yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(
...ModelV2.Info.default(
ProviderV2.ID.make("custom-cerebras"),
ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
),
@@ -110,7 +110,7 @@ describe("CerebrasPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(
...ModelV2.Info.default(
ProviderV2.ID.make("custom-cerebras"),
ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
),
@@ -117,7 +117,7 @@ describe("CloudflareAIGatewayPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
modelID: ModelV2.ID.make("openai/gpt-5"),
package: "aisdk:test-provider",
}),
@@ -139,7 +139,7 @@ describe("CloudflareAIGatewayPlugin", () => {
yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
modelID: ModelV2.ID.make("openai/gpt-5"),
package: "aisdk:test-provider",
}),
@@ -184,7 +184,7 @@ describe("CloudflareAIGatewayPlugin", () => {
yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
modelID: ModelV2.ID.make("openai/gpt-5"),
package: "aisdk:test-provider",
}),
@@ -214,7 +214,7 @@ describe("CloudflareAIGatewayPlugin", () => {
yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
modelID: ModelV2.ID.make("openai/gpt-5"),
package: "aisdk:test-provider",
}),
@@ -252,7 +252,7 @@ describe("CloudflareAIGatewayPlugin", () => {
yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
modelID: ModelV2.ID.make("openai/gpt-5"),
package: "aisdk:test-provider",
}),
@@ -284,7 +284,7 @@ describe("CloudflareAIGatewayPlugin", () => {
yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
modelID: ModelV2.ID.make("openai/gpt-5"),
package: "aisdk:test-provider",
}),
@@ -307,7 +307,7 @@ describe("CloudflareAIGatewayPlugin", () => {
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
modelID: ModelV2.ID.make("openai/gpt-5"),
package: "aisdk:test-provider",
}),
@@ -331,7 +331,7 @@ describe("CloudflareAIGatewayPlugin", () => {
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
modelID: ModelV2.ID.make("openai/gpt-5"),
package: "aisdk:test-provider",
}),
@@ -361,7 +361,7 @@ describe("CloudflareAIGatewayPlugin", () => {
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
modelID: ModelV2.ID.make("openai/gpt-5"),
package: "aisdk:test-provider",
}),
@@ -385,7 +385,7 @@ describe("CloudflareAIGatewayPlugin", () => {
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(
...ModelV2.Info.default(
ProviderV2.ID.make("cloudflare-ai-gateway"),
ModelV2.ID.make("anthropic/claude-sonnet-4-5"),
),
@@ -417,7 +417,7 @@ describe("CloudflareAIGatewayPlugin", () => {
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
modelID: ModelV2.ID.make("openai/gpt-5"),
package: "aisdk:test-provider",
}),
@@ -94,7 +94,7 @@ describe("CloudflareWorkersAIPlugin", () => {
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai")))
const sdk = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
modelID: ModelV2.ID.make("@cf/model"),
package: provider.package,
settings: provider.settings,
@@ -138,7 +138,7 @@ describe("CloudflareWorkersAIPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
modelID: ModelV2.ID.make("@cf/model"),
package: "aisdk:@ai-sdk/openai-compatible",
settings: { baseURL: "https://proxy.example/v1" },
@@ -178,7 +178,7 @@ describe("CloudflareWorkersAIPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
modelID: ModelV2.ID.make("@cf/model"),
package: "aisdk:@ai-sdk/openai-compatible",
settings: { baseURL: "https://proxy.example/v1" },
@@ -207,7 +207,7 @@ describe("CloudflareWorkersAIPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
modelID: ModelV2.ID.make("@cf/model"),
package: "aisdk:@ai-sdk/openai-compatible",
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1" },
@@ -233,7 +233,7 @@ describe("CloudflareWorkersAIPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("alias")),
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("alias")),
modelID: ModelV2.ID.make("@cf/api-model"),
package: "aisdk:test-provider",
}),
@@ -253,7 +253,7 @@ describe("CloudflareWorkersAIPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
modelID: ModelV2.ID.make("@cf/model"),
package: "aisdk:@ai-sdk/anthropic",
settings: { baseURL: "https://proxy.example/v1" },
@@ -1,127 +0,0 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere"
import { ProviderV2 } from "@opencode-ai/core/provider"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const cohereOptions: Record<string, any>[] = []
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* CoherePlugin.effect(host)
})
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
}
return {
responses: make("responses"),
messages: make("messages"),
chat: make("chat"),
languageModel: make("languageModel"),
}
}
void mock.module("@ai-sdk/cohere", () => ({
createCohere: (options: Record<string, any>) => {
cohereOptions.push({ ...options })
return {
languageModel: (modelID: string) => ({
modelID,
provider: `${options.name ?? "cohere"}.chat`,
specificationVersion: "v3",
}),
}
},
}))
describe("CoherePlugin", () => {
it.effect("creates a Cohere SDK only for @ai-sdk/cohere", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const ignored = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")),
modelID: ModelV2.ID.make("command"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/openai-compatible",
options: { name: "cohere" },
})
expect(ignored.sdk).toBeUndefined()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")),
modelID: ModelV2.ID.make("command"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/cohere",
options: { name: "cohere" },
})
expect(result.sdk).toBeDefined()
}),
)
it.effect("uses the model provider ID as the bundled SDK name", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-cohere"), ModelV2.ID.make("command-r-plus")),
modelID: ModelV2.ID.make("command-r-plus"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/cohere",
options: { name: "custom-cohere", apiKey: "test", baseURL: "https://cohere.example" },
})
expect(cohereOptions.at(-1)).toEqual({
name: "custom-cohere",
apiKey: "test",
baseURL: "https://cohere.example",
})
expect(result.sdk?.languageModel("command-r-plus").provider).toBe("custom-cohere.chat")
}),
)
it.effect("leaves language selection to the default languageModel fallback", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
const sdk = fakeSelectorSdk(calls)
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("alias")),
modelID: ModelV2.ID.make("command-r-plus"),
package: "aisdk:test-provider",
}),
sdk,
options: {},
})
expect(result.language).toBeUndefined()
expect(calls).toEqual([])
expect(result.language ?? sdk.languageModel("command-r-plus")).toBeDefined()
expect(calls).toEqual(["languageModel:command-r-plus"])
}),
)
})
@@ -1,161 +0,0 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const deepinfraOptions: Record<string, unknown>[] = []
const deepinfraLanguageModels: string[] = []
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* DeepInfraPlugin.effect(host)
})
void mock.module("@ai-sdk/deepinfra", () => ({
createDeepInfra: (options: Record<string, unknown>) => {
const captured = { ...options }
deepinfraOptions.push(captured)
return {
languageModel: (modelID: string) => {
deepinfraLanguageModels.push(modelID)
return { modelID, provider: `${captured.name ?? "deepinfra"}.chat`, specificationVersion: "v3" }
},
}
},
}))
function resetDeepInfraMock() {
deepinfraOptions.length = 0
deepinfraLanguageModels.length = 0
}
describe("DeepInfraPlugin", () => {
it.effect("creates a DeepInfra SDK for @ai-sdk/deepinfra", () =>
Effect.gen(function* () {
resetDeepInfraMock()
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")),
modelID: ModelV2.ID.make("model"),
package: "aisdk:@ai-sdk/deepinfra",
}),
package: "@ai-sdk/deepinfra",
options: { name: "deepinfra" },
})
expect(result.sdk).toBeDefined()
}),
)
it.effect("passes the model provider ID as the bundled DeepInfra SDK name", () =>
Effect.gen(function* () {
resetDeepInfraMock()
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-deepinfra"), ModelV2.ID.make("model")),
modelID: ModelV2.ID.make("model"),
package: "aisdk:@ai-sdk/deepinfra",
}),
package: "@ai-sdk/deepinfra",
options: { name: "custom-deepinfra", apiKey: "test" },
})
expect(result.sdk.languageModel("model").provider).toBe("custom-deepinfra.chat")
expect(deepinfraOptions).toEqual([{ name: "custom-deepinfra", apiKey: "test" }])
}),
)
it.effect("uses the canonical provider ID as the bundled DeepInfra SDK name", () =>
Effect.gen(function* () {
resetDeepInfraMock()
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")),
modelID: ModelV2.ID.make("model"),
package: "aisdk:@ai-sdk/deepinfra",
}),
package: "@ai-sdk/deepinfra",
options: { name: "deepinfra", apiKey: "test" },
})
expect(result.sdk.languageModel("model").provider).toBe("deepinfra.chat")
expect(deepinfraOptions).toEqual([{ name: "deepinfra", apiKey: "test" }])
}),
)
it.effect("matches only the exact bundled DeepInfra package", () =>
Effect.gen(function* () {
resetDeepInfraMock()
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const packages = [
"unmatched-package",
"@ai-sdk/deepinfra-compatible",
"file:///tmp/@ai-sdk/deepinfra-provider.js",
]
yield* Effect.forEach(packages, (item) =>
Effect.gen(function* () {
const ignored = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")),
modelID: ModelV2.ID.make("model"),
package: "aisdk:@ai-sdk/deepinfra",
}),
package: item,
options: { name: "deepinfra" },
})
expect(ignored.sdk).toBeUndefined()
}),
)
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")),
modelID: ModelV2.ID.make("model"),
package: "aisdk:@ai-sdk/deepinfra",
}),
package: "@ai-sdk/deepinfra",
options: { name: "deepinfra" },
})
expect(result.sdk).toBeDefined()
expect(deepinfraOptions).toEqual([{ name: "deepinfra" }])
}),
)
it.effect("uses the default languageModel selection for DeepInfra models", () =>
Effect.gen(function* () {
resetDeepInfraMock()
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const sdkEvent = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct")),
modelID: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"),
package: "aisdk:@ai-sdk/deepinfra",
}),
package: "@ai-sdk/deepinfra",
options: { name: "deepinfra" },
})
const result = yield* aisdk.runLanguage({ model: sdkEvent.model, sdk: sdkEvent.sdk, options: sdkEvent.options })
const language = result.language ?? result.sdk.languageModel(result.model.modelID ?? result.model.id)
expect(language.provider).toBe("deepinfra.chat")
expect(deepinfraLanguageModels).toEqual(["meta-llama/Llama-3.3-70B-Instruct"])
}),
)
})
@@ -53,7 +53,7 @@ describe("DynamicProviderPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")),
...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")),
modelID: ModelV2.ID.make("test-model"),
package: ProviderV2.aisdk(fixtureProvider),
}),
@@ -72,7 +72,7 @@ describe("DynamicProviderPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")),
...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")),
modelID: ModelV2.ID.make("test-model"),
package: ProviderV2.aisdk(fixtureProvider),
}),
@@ -90,7 +90,7 @@ describe("DynamicProviderPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")),
...ModelV2.Info.default(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")),
modelID: ModelV2.ID.make("test-model"),
package: ProviderV2.aisdk(fixtureProvider),
}),
@@ -107,7 +107,7 @@ describe("DynamicProviderPlugin", () => {
yield* addPlugin(npmEntrypoint(fixtureProviderPath))
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("npm-provider"), ModelV2.ID.make("test-model")),
...ModelV2.Info.default(ProviderV2.ID.make("npm-provider"), ModelV2.ID.make("test-model")),
modelID: ModelV2.ID.make("test-model"),
package: "aisdk:fixture-provider",
}),
@@ -125,7 +125,7 @@ describe("DynamicProviderPlugin", () => {
const exit = yield* aisdk
.language(
ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("missing-entrypoint"), ModelV2.ID.make("alias")),
...ModelV2.Info.default(ProviderV2.ID.make("missing-entrypoint"), ModelV2.ID.make("alias")),
modelID: ModelV2.ID.make("alias"),
package: "aisdk:fixture-provider",
}),
@@ -143,7 +143,7 @@ describe("DynamicProviderPlugin", () => {
const exit = yield* aisdk
.language(
ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("bad-import"), ModelV2.ID.make("alias")),
...ModelV2.Info.default(ProviderV2.ID.make("bad-import"), ModelV2.ID.make("alias")),
modelID: ModelV2.ID.make("alias"),
package: "aisdk:file:///missing/provider-factory.js",
}),
@@ -163,7 +163,7 @@ describe("DynamicProviderPlugin", () => {
const exit = yield* aisdk
.language(
ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("missing-factory"), ModelV2.ID.make("alias")),
...ModelV2.Info.default(ProviderV2.ID.make("missing-factory"), ModelV2.ID.make("alias")),
modelID: ModelV2.ID.make("alias"),
package: "aisdk:fixture-provider",
}),
@@ -181,7 +181,7 @@ describe("DynamicProviderPlugin", () => {
yield* addPlugin()
const language = yield* aisdk.language(
ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("alias")),
...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("alias")),
modelID: ModelV2.ID.make("test-model-api"),
package: ProviderV2.aisdk(fixtureProvider),
}),
@@ -0,0 +1,60 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { AISDK } from "@opencode-ai/core/aisdk"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba"
import { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere"
import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra"
import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway"
import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq"
import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral"
import { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity"
import { TogetherAIPlugin } from "@opencode-ai/core/plugin/provider/togetherai"
import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const modelID = ModelV2.ID.make("test-model")
const options = { name: "custom-provider", apiKey: "test", baseURL: "https://example.test" }
const providers = [
{ id: "alibaba", plugin: AlibabaPlugin, package: "@ai-sdk/alibaba", provider: "alibaba.chat" },
{ id: "cohere", plugin: CoherePlugin, package: "@ai-sdk/cohere", provider: "cohere.chat" },
{ id: "deepinfra", plugin: DeepInfraPlugin, package: "@ai-sdk/deepinfra", provider: "deepinfra.chat" },
{ id: "gateway", plugin: GatewayPlugin, package: "@ai-sdk/gateway", provider: "gateway" },
{ id: "groq", plugin: GroqPlugin, package: "@ai-sdk/groq", provider: "groq.chat" },
{ id: "mistral", plugin: MistralPlugin, package: "@ai-sdk/mistral", provider: "mistral.chat" },
{ id: "perplexity", plugin: PerplexityPlugin, package: "@ai-sdk/perplexity", provider: "perplexity" },
{ id: "togetherai", plugin: TogetherAIPlugin, package: "@ai-sdk/togetherai", provider: "togetherai.chat" },
{ id: "venice", plugin: VenicePlugin, package: "venice-ai-sdk-provider", provider: "custom-provider.chat" },
] as const
const it = testEffect(PluginTestLayer)
providers.forEach((item) =>
it.effect(`${item.id} loads only its exact package`, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* item.plugin.effect(host)
const model = ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.make(item.id), modelID),
modelID,
package: ProviderV2.aisdk(item.package),
})
const matched = yield* aisdk.runSDK({ model, package: item.package, options })
const ignored = yield* aisdk.runSDK({ model, package: `${item.package}/unsupported`, options })
const language = matched.sdk?.languageModel(modelID)
expect({
provider: language?.provider,
modelID: language?.modelId,
version: language?.specificationVersion,
ignored: ignored.sdk === undefined,
}).toEqual({ provider: item.provider, modelID: "test-model", version: "v3", ignored: true })
}),
),
)
@@ -1,115 +0,0 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const gatewayCalls: Record<string, unknown>[] = []
const vercelGatewayModels = ["anthropic/claude-sonnet-4", "openai/gpt-5", "google/gemini-2.5-pro"]
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* GatewayPlugin.effect(host)
})
mock.module("@ai-sdk/gateway", () => ({
createGateway(options: Record<string, unknown>) {
gatewayCalls.push({ ...options })
return {
languageModel(modelID: string) {
return {
modelId: modelID,
provider: options.name,
specificationVersion: "v3",
}
},
}
},
}))
describe("GatewayPlugin", () => {
it.effect("creates a Gateway SDK for @ai-sdk/gateway", () =>
Effect.gen(function* () {
gatewayCalls.length = 0
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("gateway"), ModelV2.ID.make("model")),
modelID: ModelV2.ID.make("model"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/gateway",
options: { name: "gateway" },
})
expect(result.sdk).toBeDefined()
expect(gatewayCalls).toHaveLength(1)
}),
)
it.effect("passes the model providerID as the Gateway SDK name", () =>
Effect.gen(function* () {
gatewayCalls.length = 0
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make("anthropic/claude-sonnet-4")),
modelID: ModelV2.ID.make("anthropic/claude-sonnet-4"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/gateway",
options: { name: "vercel", apiKey: "test-key" },
})
expect(gatewayCalls).toEqual([{ name: "vercel", apiKey: "test-key" }])
expect(result.sdk.languageModel("anthropic/claude-sonnet-4").provider).toBe("vercel")
}),
)
it.effect("matches Vercel AI Gateway models by their @ai-sdk/gateway package", () =>
Effect.gen(function* () {
gatewayCalls.length = 0
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
for (const modelID of vercelGatewayModels) {
const ignored = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)),
modelID: ModelV2.ID.make(modelID),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/vercel",
options: { name: "vercel" },
})
expect(ignored.sdk).toBeUndefined()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)),
modelID: ModelV2.ID.make(modelID),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/gateway",
options: { name: "vercel" },
})
expect(result.sdk).toBeDefined()
}
expect(gatewayCalls).toHaveLength(3)
}),
)
})
@@ -99,7 +99,7 @@ describe("GithubCopilotPlugin", () => {
yield* addPlugin()
const ignored = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
modelID: ModelV2.ID.make("gpt-5"),
package: "aisdk:test-provider",
}),
@@ -108,7 +108,7 @@ describe("GithubCopilotPlugin", () => {
})
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
modelID: ModelV2.ID.make("gpt-5"),
package: "aisdk:test-provider",
}),
@@ -128,7 +128,7 @@ describe("GithubCopilotPlugin", () => {
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("claude-sonnet-4")),
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("claude-sonnet-4")),
modelID: ModelV2.ID.make("claude-sonnet-4"),
package: "aisdk:test-provider",
}),
@@ -147,7 +147,7 @@ describe("GithubCopilotPlugin", () => {
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("alias")),
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("alias")),
modelID: ModelV2.ID.make("claude-sonnet-4"),
package: "aisdk:test-provider",
}),
@@ -166,7 +166,7 @@ describe("GithubCopilotPlugin", () => {
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
modelID: ModelV2.ID.make("gpt-5"),
package: "aisdk:test-provider",
}),
@@ -175,7 +175,7 @@ describe("GithubCopilotPlugin", () => {
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5.1-codex")),
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5.1-codex")),
modelID: ModelV2.ID.make("gpt-5.1-codex"),
package: "aisdk:test-provider",
}),
@@ -184,7 +184,7 @@ describe("GithubCopilotPlugin", () => {
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-4o")),
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-4o")),
modelID: ModelV2.ID.make("gpt-4o"),
package: "aisdk:test-provider",
}),
@@ -193,7 +193,7 @@ describe("GithubCopilotPlugin", () => {
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini")),
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini")),
modelID: ModelV2.ID.make("gpt-5-mini"),
package: "aisdk:test-provider",
}),
@@ -202,7 +202,7 @@ describe("GithubCopilotPlugin", () => {
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini-2025-08-07")),
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini-2025-08-07")),
modelID: ModelV2.ID.make("gpt-5-mini-2025-08-07"),
package: "aisdk:test-provider",
}),
@@ -227,7 +227,7 @@ describe("GithubCopilotPlugin", () => {
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("mai-code-1-flash-picker")),
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("mai-code-1-flash-picker")),
modelID: ModelV2.ID.make("mai-code-1-flash-picker"),
package: "aisdk:test-provider",
settings: { endpoint: "responses" },
@@ -237,7 +237,7 @@ describe("GithubCopilotPlugin", () => {
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
modelID: ModelV2.ID.make("gpt-5"),
package: "aisdk:test-provider",
settings: { endpoint: "chat" },
@@ -257,7 +257,7 @@ describe("GithubCopilotPlugin", () => {
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("default")),
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("default")),
modelID: ModelV2.ID.make("gpt-5"),
package: "aisdk:test-provider",
}),
@@ -266,7 +266,7 @@ describe("GithubCopilotPlugin", () => {
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("small")),
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("small")),
modelID: ModelV2.ID.make("gpt-5-mini"),
package: "aisdk:test-provider",
}),
@@ -275,7 +275,7 @@ describe("GithubCopilotPlugin", () => {
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("sonnet")),
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("sonnet")),
modelID: ModelV2.ID.make("claude-sonnet-4"),
package: "aisdk:test-provider",
}),
@@ -324,7 +324,7 @@ describe("GithubCopilotPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-5")),
...ModelV2.Info.default(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-5")),
modelID: ModelV2.ID.make("gpt-5"),
package: "aisdk:test-provider",
}),
@@ -69,7 +69,7 @@ describe("GitLabPlugin", () => {
yield* addPlugin()
yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
modelID: ModelV2.ID.make("claude"),
package: "aisdk:test-provider",
}),
@@ -107,7 +107,7 @@ describe("GitLabPlugin", () => {
yield* addPlugin()
yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
modelID: ModelV2.ID.make("claude"),
package: "aisdk:test-provider",
}),
@@ -133,7 +133,7 @@ describe("GitLabPlugin", () => {
yield* addPlugin()
yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
modelID: ModelV2.ID.make("claude"),
package: "aisdk:test-provider",
}),
@@ -175,7 +175,7 @@ describe("GitLabPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
modelID: ModelV2.ID.make("claude"),
package: "aisdk:test-provider",
}),
@@ -195,7 +195,7 @@ describe("GitLabPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")),
...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")),
modelID: ModelV2.ID.make("duo-workflow-custom"),
package: "aisdk:test-provider",
headers: {},
@@ -229,7 +229,7 @@ describe("GitLabPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-exact")),
...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-exact")),
modelID: ModelV2.ID.make("duo-workflow-exact"),
package: "aisdk:test-provider",
}),
@@ -257,7 +257,7 @@ describe("GitLabPlugin", () => {
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")),
...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")),
modelID: ModelV2.ID.make("duo-workflow-custom"),
package: "aisdk:test-provider",
headers: {},
@@ -284,7 +284,7 @@ describe("GitLabPlugin", () => {
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
modelID: ModelV2.ID.make("claude"),
package: "aisdk:test-provider",
headers: { h: "v" },
@@ -116,7 +116,7 @@ describe("GoogleVertexAnthropicPlugin", () => {
yield* addPlugin(GoogleVertexAnthropicPlugin)
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(
...ModelV2.Info.default(
ProviderV2.ID.make("google-vertex-anthropic"),
ModelV2.ID.make("claude-sonnet-4-5"),
),
@@ -143,7 +143,7 @@ describe("GoogleVertexAnthropicPlugin", () => {
yield* addPlugin(GoogleVertexAnthropicPlugin)
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(
...ModelV2.Info.default(
ProviderV2.ID.make("google-vertex-anthropic"),
ModelV2.ID.make("claude-sonnet-4-5"),
),
@@ -167,7 +167,7 @@ describe("GoogleVertexAnthropicPlugin", () => {
yield* addPlugin(GoogleVertexAnthropicPlugin)
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")),
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")),
modelID: ModelV2.ID.make("claude-sonnet-4-5"),
package: "aisdk:test-provider",
}),
@@ -187,7 +187,7 @@ describe("GoogleVertexAnthropicPlugin", () => {
yield* addPlugin(GoogleVertexAnthropicPlugin)
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")),
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")),
modelID: ModelV2.ID.make("claude-sonnet-4-5"),
package: "aisdk:test-provider",
}),
@@ -206,7 +206,7 @@ describe("GoogleVertexAnthropicPlugin", () => {
yield* addPlugin(GoogleVertexAnthropicPlugin)
const sdkResult = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")),
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")),
modelID: ModelV2.ID.make(" claude-sonnet-4-5 "),
package: "aisdk:test-provider",
}),
@@ -215,7 +215,7 @@ describe("GoogleVertexAnthropicPlugin", () => {
})
const languageResult = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")),
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")),
modelID: ModelV2.ID.make(" claude-sonnet-4-5 "),
package: "aisdk:test-provider",
}),
@@ -238,7 +238,7 @@ describe("GoogleVertexAnthropicPlugin", () => {
yield* addPlugin(GoogleVertexAnthropicPlugin)
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex-anthropic"), ModelV2.ID.make(" claude-sonnet-4-5 ")),
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex-anthropic"), ModelV2.ID.make(" claude-sonnet-4-5 ")),
modelID: ModelV2.ID.make(" claude-sonnet-4-5 "),
package: "aisdk:test-provider",
}),
@@ -257,7 +257,7 @@ describe("GoogleVertexAnthropicPlugin", () => {
yield* addPlugin(GoogleVertexAnthropicPlugin)
const result = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")),
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")),
modelID: ModelV2.ID.make("claude-sonnet-4-5"),
package: "aisdk:test-provider",
}),
@@ -172,7 +172,7 @@ describe("GoogleVertexPlugin", () => {
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")),
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")),
modelID: ModelV2.ID.make("gemini"),
package: "aisdk:@ai-sdk/google-vertex",
}),
@@ -294,7 +294,7 @@ describe("GoogleVertexPlugin", () => {
yield* addPlugin()
yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")),
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")),
modelID: ModelV2.ID.make("gemini"),
package: "aisdk:@ai-sdk/google-vertex",
}),
@@ -339,7 +339,7 @@ describe("GoogleVertexPlugin", () => {
() =>
aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")),
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")),
modelID: ModelV2.ID.make("gemini"),
package: "aisdk:@ai-sdk/openai-compatible",
}),
@@ -367,7 +367,7 @@ describe("GoogleVertexPlugin", () => {
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" gemini-2.5-pro ")),
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" gemini-2.5-pro ")),
modelID: ModelV2.ID.make(" gemini-2.5-pro "),
package: "aisdk:test-provider",
}),
@@ -26,7 +26,7 @@ describe("GooglePlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("gemini")),
...ModelV2.Info.default(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("gemini")),
modelID: ModelV2.ID.make("gemini"),
package: "aisdk:@ai-sdk/google",
}),
@@ -45,7 +45,7 @@ describe("GooglePlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini")),
...ModelV2.Info.default(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini")),
modelID: ModelV2.ID.make("gemini"),
package: "aisdk:@ai-sdk/google",
}),
@@ -63,7 +63,7 @@ describe("GooglePlugin", () => {
yield* addPlugin()
const sdkEvent = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")),
...ModelV2.Info.default(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")),
modelID: ModelV2.ID.make("gemini-api"),
package: "aisdk:@ai-sdk/google",
}),
@@ -88,7 +88,7 @@ describe("GooglePlugin", () => {
const resolved = yield* aisdk.model(
ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")),
...ModelV2.Info.default(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")),
modelID: ModelV2.ID.make("gemini-api"),
package: "aisdk:@ai-sdk/google",
settings: { apiKey: "test" },
@@ -1,122 +0,0 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect } from "bun:test"
import { createGroq } from "@ai-sdk/groq"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* GroqPlugin.effect(host)
})
describe("GroqPlugin", () => {
it.effect("creates a Groq SDK for @ai-sdk/groq", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")),
modelID: ModelV2.ID.make("llama"),
package: "aisdk:@ai-sdk/groq",
}),
package: "@ai-sdk/groq",
options: { name: "groq" },
})
expect(result.sdk).toBeDefined()
}),
)
it.effect("ignores non-Groq SDK packages", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")),
modelID: ModelV2.ID.make("llama"),
package: "aisdk:@ai-sdk/groq",
}),
package: "@ai-sdk/openai-compatible",
options: { name: "groq" },
})
expect(result.sdk).toBeUndefined()
}),
)
it.effect("only matches the bundled @ai-sdk/groq package exactly", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")),
modelID: ModelV2.ID.make("llama"),
package: "aisdk:@ai-sdk/groq",
}),
package: "@ai-sdk/groq/compat",
options: { name: "groq" },
})
expect(result.sdk).toBeUndefined()
}),
)
it.effect("matches the old bundled Groq SDK provider naming", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-groq"), ModelV2.ID.make("llama")),
modelID: ModelV2.ID.make("llama"),
package: "aisdk:@ai-sdk/groq",
}),
package: "@ai-sdk/groq",
options: { name: "custom-groq", apiKey: "test" },
})
const expected = createGroq({ name: "custom-groq", apiKey: "test" } as Parameters<typeof createGroq>[0] & {
name: string
}).languageModel("llama")
const actual = result.sdk?.languageModel("llama")
expect(actual?.provider).toBe(expected.provider)
expect(actual?.modelId).toBe(expected.modelId)
}),
)
it.effect("uses the default languageModel(modelID) behavior", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const sdk = createGroq({ name: "groq", apiKey: "test" } as Parameters<typeof createGroq>[0] & {
name: string
})
const result = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("alias")),
modelID: ModelV2.ID.make("llama-api"),
package: "aisdk:@ai-sdk/groq",
}),
sdk,
options: { name: "groq", apiKey: "test" },
})
const language = result.language ?? sdk.languageModel(result.model.modelID ?? result.model.id)
expect(language.modelId).toBe("llama-api")
expect(language.provider).toBe("groq.chat")
}),
)
})
@@ -1,134 +0,0 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* MistralPlugin.effect(host)
})
describe("MistralPlugin", () => {
it.effect("creates a Mistral SDK for @ai-sdk/mistral", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")),
modelID: ModelV2.ID.make("mistral-large"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/mistral",
options: { name: "mistral" },
})
expect(result.sdk).toBeDefined()
}),
)
it.effect("ignores non-Mistral SDK packages", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")),
modelID: ModelV2.ID.make("mistral-large"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/openai-compatible",
options: { name: "mistral" },
})
expect(result.sdk).toBeUndefined()
}),
)
it.effect("matches the old bundled Mistral SDK provider name for the bundled provider ID", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const providers: string[] = []
yield* addPlugin()
yield* aisdk.hook.sdk((event) =>
Effect.sync(() => {
providers.push(event.sdk.languageModel("mistral-large").provider)
}),
)
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")),
modelID: ModelV2.ID.make("mistral-large"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/mistral",
options: { name: "mistral" },
})
expect(result.sdk).toBeDefined()
expect(providers).toEqual(["mistral.chat"])
}),
)
it.effect("matches the old bundled Mistral SDK provider name for custom provider IDs", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const providers: string[] = []
yield* addPlugin()
yield* aisdk.hook.sdk((event) =>
Effect.sync(() => {
providers.push(event.sdk.languageModel("mistral-large").provider)
}),
)
yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-mistral"), ModelV2.ID.make("mistral-large")),
modelID: ModelV2.ID.make("mistral-large"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/mistral",
options: { name: "custom-mistral" },
})
expect(providers).toEqual(["mistral.chat"])
}),
)
it.effect("leaves Mistral language selection on the default sdk.languageModel(modelID) path", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
const sdk = {
languageModel: (id: string) => {
calls.push(`languageModel:${id}`)
return { modelId: id, provider: "languageModel", specificationVersion: "v3" } as unknown as LanguageModelV3
},
}
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("alias")),
modelID: ModelV2.ID.make("mistral-large"),
package: "aisdk:test-provider",
}),
sdk,
options: {},
})
const language = result.language ?? sdk.languageModel(result.model.modelID ?? result.model.id)
expect(calls).toEqual(["languageModel:mistral-large"])
expect(language).toBeDefined()
}),
)
})
@@ -26,7 +26,7 @@ describe("OpenAICompatiblePlugin", () => {
yield* addPlugin()
const defaulted = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
modelID: ModelV2.ID.make("model"),
package: "aisdk:test-provider",
}),
@@ -35,7 +35,7 @@ describe("OpenAICompatiblePlugin", () => {
})
const disabled = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
modelID: ModelV2.ID.make("model"),
package: "aisdk:test-provider",
}),
@@ -54,7 +54,7 @@ describe("OpenAICompatiblePlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
modelID: ModelV2.ID.make("model"),
package: "aisdk:test-provider",
}),
@@ -78,7 +78,7 @@ describe("OpenAICompatiblePlugin", () => {
)
yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("model")),
...ModelV2.Info.default(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("model")),
modelID: ModelV2.ID.make("model"),
package: "aisdk:test-provider",
}),
@@ -99,7 +99,7 @@ describe("OpenAICompatiblePlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("model")),
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("model")),
modelID: ModelV2.ID.make("model"),
package: "aisdk:test-provider",
}),
@@ -68,7 +68,7 @@ describe("OpenAIPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5")),
...ModelV2.Info.default(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5")),
modelID: ModelV2.ID.make("gpt-5"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -86,7 +86,7 @@ describe("OpenAIPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5")),
...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5")),
modelID: ModelV2.ID.make("gpt-5"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -105,7 +105,7 @@ describe("OpenAIPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("alias")),
...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("alias")),
modelID: ModelV2.ID.make("gpt-5"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -125,7 +125,7 @@ describe("OpenAIPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("gpt-5")),
...ModelV2.Info.default(ProviderV2.ID.anthropic, ModelV2.ID.make("gpt-5")),
modelID: ModelV2.ID.make("gpt-5"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -293,7 +293,7 @@ describe("OpencodePlugin", () => {
package: ProviderV2.aisdk("test-provider"),
})
const model = ModelV2.Info.make({
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
...ModelV2.Info.default(provider.id, ModelV2.ID.make("paid")),
modelID: ModelV2.ID.make("paid"),
package: ProviderV2.aisdk("test-provider"),
cost: cost(1),
@@ -320,7 +320,7 @@ describe("OpencodePlugin", () => {
package: ProviderV2.aisdk("test-provider"),
})
const model = ModelV2.Info.make({
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("free")),
...ModelV2.Info.default(provider.id, ModelV2.ID.make("free")),
modelID: ModelV2.ID.make("free"),
package: ProviderV2.aisdk("test-provider"),
cost: cost(0),
@@ -347,7 +347,7 @@ describe("OpencodePlugin", () => {
package: ProviderV2.aisdk("test-provider"),
})
const model = ModelV2.Info.make({
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("output-only")),
...ModelV2.Info.default(provider.id, ModelV2.ID.make("output-only")),
modelID: ModelV2.ID.make("output-only"),
package: ProviderV2.aisdk("test-provider"),
cost: cost(0, 1),
@@ -376,7 +376,7 @@ describe("OpencodePlugin", () => {
package: ProviderV2.aisdk("test-provider"),
})
const model = ModelV2.Info.make({
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
...ModelV2.Info.default(provider.id, ModelV2.ID.make("paid")),
modelID: ModelV2.ID.make("paid"),
package: ProviderV2.aisdk("test-provider"),
cost: cost(1),
@@ -410,7 +410,7 @@ describe("OpencodePlugin", () => {
package: ProviderV2.aisdk("test-provider"),
})
const model = ModelV2.Info.make({
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
...ModelV2.Info.default(provider.id, ModelV2.ID.make("paid")),
modelID: ModelV2.ID.make("paid"),
package: ProviderV2.aisdk("test-provider"),
cost: cost(1),
@@ -438,7 +438,7 @@ describe("OpencodePlugin", () => {
settings: { apiKey: "configured" },
})
const model = ModelV2.Info.make({
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
...ModelV2.Info.default(provider.id, ModelV2.ID.make("paid")),
modelID: ModelV2.ID.make("paid"),
package: ProviderV2.aisdk("test-provider"),
cost: cost(1),
@@ -468,7 +468,7 @@ describe("OpencodePlugin", () => {
package: ProviderV2.aisdk("test-provider"),
})
const model = ModelV2.Info.make({
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
...ModelV2.Info.default(provider.id, ModelV2.ID.make("paid")),
modelID: ModelV2.ID.make("paid"),
package: ProviderV2.aisdk("test-provider"),
cost: cost(1),
@@ -54,7 +54,7 @@ describe("OpenRouterPlugin", () => {
const ignored = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")),
...ModelV2.Info.default(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")),
modelID: ModelV2.ID.make("openai/gpt-5"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -65,7 +65,7 @@ describe("OpenRouterPlugin", () => {
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("openai/gpt-5")),
...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("openai/gpt-5")),
modelID: ModelV2.ID.make("openai/gpt-5"),
package: ProviderV2.aisdk("test-provider"),
}),
@@ -1,127 +0,0 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* PerplexityPlugin.effect(host)
})
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
}
return {
responses: make("responses"),
messages: make("messages"),
chat: make("chat"),
languageModel: make("languageModel"),
}
}
describe("PerplexityPlugin", () => {
it.effect("creates a Perplexity SDK for the exact @ai-sdk/perplexity package", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")),
modelID: ModelV2.ID.make("sonar"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/perplexity",
options: { name: "perplexity" },
})
expect(result.sdk).toBeDefined()
}),
)
it.effect("ignores packages that are not the bundled Perplexity package", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")),
modelID: ModelV2.ID.make("sonar"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/perplexity-compatible",
options: { name: "perplexity" },
})
expect(result.sdk).toBeUndefined()
}),
)
it.effect("uses the Perplexity provider ID as the SDK name for the bundled provider", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")),
modelID: ModelV2.ID.make("sonar"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/perplexity",
options: { name: "perplexity" },
})
expect(result.sdk.languageModel("sonar").provider).toBe("perplexity")
}),
)
it.effect("creates bundled Perplexity SDKs for custom provider IDs", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-perplexity"), ModelV2.ID.make("sonar")),
modelID: ModelV2.ID.make("sonar"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/perplexity",
options: { name: "custom-perplexity" },
})
expect(result.sdk.languageModel("sonar").provider).toBe("perplexity")
}),
)
it.effect("leaves Perplexity language selection to the default languageModel fallback", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("alias")),
modelID: ModelV2.ID.make("sonar"),
package: "aisdk:test-provider",
}),
sdk: fakeSelectorSdk(calls),
options: {},
})
expect(calls).toEqual([])
expect(result.language).toBeUndefined()
}),
)
})
@@ -48,7 +48,7 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
function model(providerID: string) {
return ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make(providerID), ModelV2.ID.make("sap-model")),
...ModelV2.Info.default(ProviderV2.ID.make(providerID), ModelV2.ID.make("sap-model")),
modelID: ModelV2.ID.make("sap-model"),
package: ProviderV2.aisdk(fixtureProvider),
})
@@ -58,7 +58,7 @@ describe("SnowflakeCortexPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-4")),
...ModelV2.Info.default(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-4")),
modelID: ModelV2.ID.make("gpt-4"),
package: "aisdk:test-provider",
}),
@@ -77,7 +77,7 @@ describe("SnowflakeCortexPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
...ModelV2.Info.default(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
modelID: ModelV2.ID.make("claude-sonnet-4-6"),
package: "aisdk:test-provider",
}),
@@ -97,7 +97,7 @@ describe("SnowflakeCortexPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
...ModelV2.Info.default(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
modelID: ModelV2.ID.make("claude-sonnet-4-6"),
package: "aisdk:test-provider",
}),
@@ -121,7 +121,7 @@ describe("SnowflakeCortexPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
...ModelV2.Info.default(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
modelID: ModelV2.ID.make("claude-sonnet-4-6"),
package: "aisdk:test-provider",
}),
@@ -141,7 +141,7 @@ describe("SnowflakeCortexPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
...ModelV2.Info.default(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
modelID: ModelV2.ID.make("claude-sonnet-4-6"),
package: "aisdk:test-provider",
}),
@@ -165,7 +165,7 @@ describe("SnowflakeCortexPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
...ModelV2.Info.default(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
modelID: ModelV2.ID.make("claude-sonnet-4-6"),
package: "aisdk:test-provider",
}),
@@ -1,132 +0,0 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { TogetherAIPlugin } from "@opencode-ai/core/plugin/provider/togetherai"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* TogetherAIPlugin.effect(host)
})
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
}
return {
responses: make("responses"),
messages: make("messages"),
chat: make("chat"),
languageModel: make("languageModel"),
}
}
describe("TogetherAIPlugin", () => {
it.effect("creates a TogetherAI SDK for @ai-sdk/togetherai", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")),
modelID: ModelV2.ID.make("model"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/togetherai",
options: { name: "togetherai" },
})
expect(result.sdk).toBeDefined()
}),
)
it.effect("matches the old bundled provider package exactly", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const ignored = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")),
modelID: ModelV2.ID.make("model"),
package: "aisdk:test-provider",
}),
package: "file:///tmp/@ai-sdk/togetherai-provider.js",
options: { name: "togetherai" },
})
expect(ignored.sdk).toBeUndefined()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")),
modelID: ModelV2.ID.make("model"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/togetherai",
options: { name: "togetherai" },
})
expect(result.sdk).toBeDefined()
}),
)
it.effect("creates bundled TogetherAI SDKs for custom provider IDs", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-togetherai"), ModelV2.ID.make("model")),
modelID: ModelV2.ID.make("model"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/togetherai",
options: { name: "custom-togetherai" },
})
expect(result.sdk.languageModel("model").provider).toBe("togetherai.chat")
}),
)
it.effect("defaults language selection to sdk.languageModel with the model API ID", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(
ProviderV2.ID.make("togetherai"),
ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"),
),
modelID: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"),
package: "aisdk:test-provider",
}),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {},
})
expect(result.language).toBeUndefined()
expect(calls).toEqual([])
expect(
result.language ?? fakeSelectorSdk(calls).languageModel(result.model.modelID ?? result.model.id),
).toBeDefined()
expect(calls).toEqual(["languageModel:meta-llama/Llama-3.3-70B-Instruct-Turbo"])
}),
)
})
@@ -1,120 +0,0 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* VenicePlugin.effect(host)
})
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
}
return {
responses: make("responses"),
messages: make("messages"),
chat: make("chat"),
languageModel: make("languageModel"),
}
}
describe("VenicePlugin", () => {
it.effect("creates a Venice SDK for venice-ai-sdk-provider", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")),
modelID: ModelV2.ID.make("model"),
package: "aisdk:test-provider",
}),
package: "venice-ai-sdk-provider",
options: { name: "venice" },
})
expect(result.sdk).toBeDefined()
}),
)
it.effect("uses the model provider ID as the bundled Venice SDK name", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-venice"), ModelV2.ID.make("model")),
modelID: ModelV2.ID.make("model"),
package: "aisdk:test-provider",
}),
package: "venice-ai-sdk-provider",
options: { name: "custom-venice", apiKey: "test" },
})
expect(result.sdk).toBeDefined()
expect(result.sdk.languageModel("model").provider).toBe("custom-venice.chat")
}),
)
it.effect("only handles the bundled venice-ai-sdk-provider package", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* addPlugin()
const similar = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")),
modelID: ModelV2.ID.make("model"),
package: "aisdk:test-provider",
}),
package: "file:///tmp/venice-ai-sdk-provider.js",
options: { name: "venice" },
})
const other = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")),
modelID: ModelV2.ID.make("model"),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/openai-compatible",
options: { name: "venice" },
})
expect(similar.sdk).toBeUndefined()
expect(other.sdk).toBeUndefined()
}),
)
it.effect("leaves Venice language selection to the default languageModel fallback", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("alias")),
modelID: ModelV2.ID.make("alias"),
package: "aisdk:test-provider",
}),
sdk: fakeSelectorSdk(calls),
options: {},
})
expect(calls).toEqual([])
expect(result.language).toBeUndefined()
}),
)
})
@@ -59,7 +59,7 @@ describe("VercelPlugin", () => {
yield* addPlugin()
const event = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-vercel"), ModelV2.ID.make("v0-1.0-md")),
...ModelV2.Info.default(ProviderV2.ID.make("custom-vercel"), ModelV2.ID.make("v0-1.0-md")),
modelID: ModelV2.ID.make("v0-1.0-md"),
package: "aisdk:@ai-sdk/vercel",
}),
@@ -62,7 +62,7 @@ describe("XAIPlugin", () => {
const ignored = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")),
...ModelV2.Info.default(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")),
modelID: ModelV2.ID.make("grok-4"),
package: "aisdk:@ai-sdk/xai",
}),
@@ -72,7 +72,7 @@ describe("XAIPlugin", () => {
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")),
...ModelV2.Info.default(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")),
modelID: ModelV2.ID.make("grok-4"),
package: "aisdk:@ai-sdk/xai",
}),
@@ -92,7 +92,7 @@ describe("XAIPlugin", () => {
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("custom-xai"), ModelV2.ID.make("grok-4")),
...ModelV2.Info.default(ProviderV2.ID.make("custom-xai"), ModelV2.ID.make("grok-4")),
modelID: ModelV2.ID.make("grok-4"),
package: "aisdk:@ai-sdk/xai",
}),
@@ -112,7 +112,7 @@ describe("XAIPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("alias")),
...ModelV2.Info.default(ProviderV2.ID.make("xai"), ModelV2.ID.make("alias")),
modelID: ModelV2.ID.make("grok-4"),
package: "aisdk:@ai-sdk/xai",
}),
@@ -133,7 +133,7 @@ describe("XAIPlugin", () => {
yield* addPlugin()
const result = yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("grok-4")),
...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("grok-4")),
modelID: ModelV2.ID.make("grok-4"),
package: "aisdk:@ai-sdk/xai",
}),
+9 -8
View File
@@ -49,14 +49,15 @@ const client = Layer.mock(LLMClient.Service)({
generate: () => Effect.die("unused"),
})
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = SessionRunnerModel.layerWith(() =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
)
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
})
const locations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
+9 -8
View File
@@ -66,14 +66,15 @@ const client = Layer.mock(LLMClient.Service)({
return response
}),
})
const models = SessionRunnerModel.layerWith(() =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
)
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
})
const builtins = Layer.mock(InstructionBuiltIns.Service, {
load: () =>
Effect.succeed(
@@ -73,14 +73,15 @@ const model = OpenAIChat.route
generation: { maxTokens: 20, temperature: 0 },
})
.model({ id: "gpt-4o-mini" })
const models = SessionRunnerModel.layerWith(() =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
)
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
})
const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) })
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
const skillInstructions = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
+11 -10
View File
@@ -280,17 +280,18 @@ const echo = Layer.effectDiscard(
const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] })
let modelResolveHook = Effect.void
let currentModel = model
const models = SessionRunnerModel.layerWith((session) =>
modelResolveHook.pipe(
Effect.as(
SessionRunnerModel.resolved(session.model?.id === "replacement" ? replacementModel : currentModel, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
variant: session.model?.variant,
}),
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: (session) =>
modelResolveHook.pipe(
Effect.as(
SessionRunnerModel.resolved(session.model?.id === "replacement" ? replacementModel : currentModel, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
variant: session.model?.variant,
}),
),
),
),
)
})
const systemContextKey = Instructions.Key.make("test/context")
let systemBaseline = "Initial context"
let systemRemoved = false
+2 -2
View File
@@ -170,8 +170,8 @@ test("Core reuses the canonical shared schemas", async () => {
for (const [core, shared] of schemas) expect(core).toBe(shared)
expect(Agent.Info.empty(Agent.ID.make("test"))).toEqual(AgentV2.Info.empty(AgentV2.ID.make("test")))
expect(Model.Info.empty(Provider.ID.make("test"), Model.ID.make("model"))).toEqual(
ModelV2.Info.empty(ProviderV2.ID.make("test"), ModelV2.ID.make("model")),
expect(Model.Info.default(Provider.ID.make("test"), Model.ID.make("model"))).toEqual(
ModelV2.Info.default(ProviderV2.ID.make("test"), ModelV2.ID.make("model")),
)
expect(Provider.Info.empty(Provider.ID.make("test"))).toEqual(ProviderV2.Info.empty(ProviderV2.ID.make("test")))
expect(Skill.Source.key(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make("/tmp") }))).toBe(
-163
View File
@@ -387,17 +387,6 @@ describe("PatchTool", () => {
),
)
it.live("updates an empty file", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "empty.txt")
yield* Effect.promise(() => fs.writeFile(target, ""))
yield* executeTool(registry, call("*** Begin Patch\n*** Update File: empty.txt\n@@\n+First line\n*** End Patch"))
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("First line\n")
}),
),
)
it.live("rejects deleting a directory", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
@@ -410,40 +399,6 @@ describe("PatchTool", () => {
),
)
it.live("supports an end-of-file anchor", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "tail.txt")
yield* Effect.promise(() => fs.writeFile(target, "first\nsecond"))
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: tail.txt\n@@\n first\n-second\n+second updated\n*** End of File\n*** End Patch",
),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("first\nsecond updated\n")
}),
),
)
it.live("applies an end-of-file chunk to the final duplicate", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "duplicates.txt")
yield* Effect.promise(() => fs.writeFile(target, "marker\nend\nmiddle\nmarker\nend\n"))
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: duplicates.txt\n@@\n-marker\n-end\n+marker changed\n+end\n*** End of File\n*** End Patch",
),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe(
"marker\nend\nmiddle\nmarker changed\nend\n",
)
}),
),
)
it.live("rejects a missing second chunk context", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
@@ -513,20 +468,6 @@ describe("PatchTool", () => {
),
)
it.live("applies multiple hunks to one file", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "multi.txt")
yield* Effect.promise(() => fs.writeFile(target, "a\nb\nc\nd\n"))
yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: multi.txt\n@@\n-b\n+B\n@@\n-d\n+D\n*** End Patch"),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("a\nB\nc\nD\n")
}),
),
)
it.live("applies successive update operations to one file", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
@@ -566,110 +507,6 @@ describe("PatchTool", () => {
),
)
it.live("appends a trailing newline on update", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "no-newline.txt")
yield* Effect.promise(() => fs.writeFile(target, "no newline at end"))
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: no-newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch",
),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("first line\nsecond line\n")
}),
),
)
it.live("disambiguates change context with an @@ header", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "context.txt")
yield* Effect.promise(() => fs.writeFile(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n"))
yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: context.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch"),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe(
"fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n",
)
}),
),
)
it.live("parses a heredoc-wrapped patch", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
yield* executeTool(
registry,
call("cat <<'EOF'\n*** Begin Patch\n*** Add File: heredoc.txt\n+with cat\n*** End Patch\nEOF"),
)
expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "heredoc.txt"), "utf8"))).toBe(
"with cat\n",
)
}),
),
)
it.live("parses a heredoc-wrapped patch without cat", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
yield* executeTool(
registry,
call("<<EOF\n*** Begin Patch\n*** Add File: heredoc.txt\n+without cat\n*** End Patch\nEOF"),
)
expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "heredoc.txt"), "utf8"))).toBe(
"without cat\n",
)
}),
),
)
it.live("matches with trailing whitespace differences", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "trailing.txt")
yield* Effect.promise(() => fs.writeFile(target, "line1 \nline2\nline3 \n"))
yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: trailing.txt\n@@\n-line2\n+changed\n*** End Patch"),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("line1 \nchanged\nline3 \n")
}),
),
)
it.live("matches with leading whitespace differences", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "leading.txt")
yield* Effect.promise(() => fs.writeFile(target, " line1\nline2\n line3\n"))
yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: leading.txt\n@@\n-line2\n+changed\n*** End Patch"),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe(" line1\nchanged\n line3\n")
}),
),
)
it.live("matches with Unicode punctuation differences", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "unicode.txt")
yield* Effect.promise(() => fs.writeFile(target, "He said “hello”\nsome—dash\nend\n"))
yield* executeTool(
registry,
call(
'*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch',
),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe('He said "hi"\nsome—dash\nend\n')
}),
),
)
it.live("rejects an update with missing context", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
+2 -2
View File
@@ -88,8 +88,8 @@ describe("search tools", () => {
expect(glob.output?.structured).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(grep.output?.structured).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(glob.output?.content).toEqual([{ type: "text", text: glob.result.value }])
expect(grep.output?.content).toEqual([{ type: "text", text: grep.result.value }])
expect(glob.output?.content).toEqual([{ type: "text", text: String(glob.result.value) }])
expect(grep.output?.content).toEqual([{ type: "text", text: String(grep.result.value) }])
expect(String(glob.result.value).split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT)
expect(grep.result.value).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`)
}),
+11
View File
@@ -19,4 +19,15 @@ bun validate
bun broken-links
```
The V2 theme token reference is generated from
`packages/tui/src/theme/v2/schema.ts`. Regenerate it after schema changes:
```bash
bun run generate
```
`bun validate` checks that the committed snippet is current. The repository's
generation workflow also refreshes it on pushes to `dev`, so Mintlify always
receives the generated MDX as part of the published docs tree.
The hosted preview is available at [opencode.mintlify.site](https://opencode.mintlify.site).
+3 -4
View File
@@ -89,7 +89,7 @@ becomes `system`:
description: Reviews changes without modifying files
mode: subagent
model: anthropic/claude-sonnet-4-5#high
color: warning
color: "#ff6b6b"
steps: 8
permissions:
- action: edit
@@ -118,7 +118,7 @@ Use the `agents` field in any [OpenCode configuration file](/config):
"mode": "all",
"model": "anthropic/claude-sonnet-4-5#high",
"system": "Review the current changes. Report findings before any summary.",
"color": "warning",
"color": "#ff6b6b",
"steps": 8,
"permissions": [
{ "action": "edit", "resource": "*", "effect": "deny" },
@@ -250,8 +250,7 @@ security boundary.
### `color`
Sets the agent's UI color. Use a six-digit hex color such as `#ff6b6b`, or one
of `primary`, `secondary`, `accent`, `success`, `warning`, `error`, or `info`.
Sets the agent's UI color. Use a six-digit hex color such as `#ff6b6b`.
### `disabled`
+1
View File
@@ -38,6 +38,7 @@
"attachments",
"compaction",
"warming",
"themes",
"formatters",
"lsp",
"references"
+1 -1
View File
@@ -158,6 +158,6 @@ limitations and safety details.
## Customize
Make OpenCode your own by [picking a theme](https://opencode.ai/docs/themes), [customizing
Make OpenCode your own by [picking a theme](/themes), [customizing
keybinds](https://opencode.ai/docs/keybinds), [configuring formatters](/formatters), [creating commands](/commands), or
editing the [OpenCode config](/config).
+7 -5
View File
@@ -94,9 +94,10 @@ You can also map a friendly catalog ID to a different API model ID with `modelID
}
```
Here `openai/coding-default` is the selectable catalog reference, while `gpt-5.2` is sent to the provider. When adding a
model that is not already in the catalog, set accurate `capabilities` and `limit` values so OpenCode can expose tools and
enforce the correct context limits. Set `disabled: true` on a model entry to hide it from the available catalog.
Here `openai/coding-default` is the selectable catalog reference, while `gpt-5.2` is sent to the provider. A model that is
not already in the catalog defaults to tool support, text and image input, and text output. Set accurate `capabilities`
and `limit` values when those defaults do not match the model or OpenCode needs to enforce its context limits. Set
`disabled: true` on a model entry to hide it from the available catalog.
OpenAI-compatible models that stream reasoning through a custom assistant-message field can set
`compatibility.reasoningField`:
@@ -193,8 +194,9 @@ For an OpenAI-compatible server, define a provider package, endpoint, and at lea
}
```
Use the server's real model name, limits, modalities, and tool support. OpenCode cannot infer these for a model you add
manually. If the endpoint requires a key, add `apiKey` to provider `settings` using an environment substitution such as
Use the server's real model name, limits, modalities, and tool support. OpenCode applies the custom-model capability
defaults described above but cannot infer the server's actual limits or whether those defaults are accurate. If the
endpoint requires a key, add `apiKey` to provider `settings` using an environment substitution such as
`"apiKey": "{env:LOCAL_API_KEY}"`; do not commit secrets.
### Model references
+3 -19
View File
@@ -10561,26 +10561,10 @@
"additionalProperties": false
},
"Agent.Color": {
"anyOf": [
"type": "string",
"allOf": [
{
"type": "string",
"allOf": [
{
"pattern": "^#[0-9a-fA-F]{6}$"
}
]
},
{
"type": "string",
"enum": [
"primary",
"secondary",
"accent",
"success",
"warning",
"error",
"info"
]
"pattern": "^#[0-9a-fA-F]{6}$"
}
]
},
+7 -3
View File
@@ -3,11 +3,15 @@
"name": "@opencode-ai/docs",
"private": true,
"scripts": {
"dev": "bun --bun mint dev --no-open --port 3333",
"validate": "bun --bun mint validate",
"dev": "bun run generate && bun --bun mint dev --no-open --port 3333",
"generate": "bun script/generate-theme-tokens.ts",
"check:generated": "bun script/generate-theme-tokens.ts --check",
"validate": "bun run check:generated && bun --bun mint validate",
"broken-links": "bun --bun mint broken-links"
},
"devDependencies": {
"mint": "4.2.666"
"effect": "catalog:",
"mint": "4.2.666",
"prettier": "3.6.2"
}
}
@@ -0,0 +1,136 @@
#!/usr/bin/env bun
import { Schema, SchemaAST } from "effect"
import { format } from "prettier"
import { ThemeDefinition, ThemeDocument } from "../../tui/src/theme/v2/schema"
const target = import.meta.dir + "/../snippets/generated/theme-tokens.mdx"
const root = requireObject(ThemeDefinition.ast)
const hue = requireObject(requireField(root, "hue").type)
const hueNames = hue.propertySignatures.map((field) => String(field.name))
const hueSteps = requireObject(requireField(hue, hueNames[0]).type).propertySignatures.map((field) =>
String(field.name),
)
const contexts = root.propertySignatures
.map((field) => String(field.name))
.filter((name) => name.startsWith("@context:"))
const tokens = root.propertySignatures
.filter((field) => {
const name = String(field.name)
return name !== "hue" && name !== "categorical" && !name.startsWith("@context:")
})
.flatMap((field) => tokenPaths(field.type, String(field.name)))
const groups = Map.groupBy(tokens, (token) =>
token
.split(".")
.slice(0, token.split(".").length > 2 ? 2 : 1)
.join("."),
)
const table = [...groups]
.map(([group, values]) => `| \`${group}\` | ${values.map((value) => `\`${value}\``).join("<br />")} |`)
.join("\n")
const example = {
version: 2,
light: {
hue: {
accent: "$hue.purple",
interactive: "$hue.purple",
},
text: {
default: "$hue.neutral.900",
},
background: {
default: "#fafafa",
},
},
dark: {
mergeMode: true,
text: {
default: "$hue.neutral.100",
},
background: {
default: "#101014",
},
},
} satisfies ThemeDocument
Schema.decodeUnknownSync(ThemeDocument)(example)
const output = await format(
`{/* Generated by packages/docs/script/generate-theme-tokens.ts. Do not edit. */}
\`\`\`json title="my-theme.json"
${JSON.stringify(example, null, 2)}
\`\`\`
## Token reference
This reference is generated from the Effect schema in
\`packages/tui/src/theme/v2/schema.ts\`. Changes to the runtime schema update
this section through \`bun run generate\`.
### Hue tokens
Every hue is a ${hueSteps.length}-step scale. Define a scale with all of these
steps, or alias it to another hue with a value such as \`$hue.blue\`.
| | Values |
| --- | --- |
| Hues | ${hueNames.map((name) => `\`${name}\``).join(", ")} |
| Steps | ${hueSteps.map((step) => `\`${step}\``).join(", ")} |
Reference a hue color as \`$hue.<name>.<step>\`, for example
\`$hue.interactive.500\`.
### Semantic tokens
Semantic values can reference another token by prefixing its path with \`$\`,
for example \`$text.default\`. Stateful tokens inherit their \`default\`
value when a state is omitted.
| Group | Tokens |
| --- | --- |
${table}
### Contexts
${contexts.map((context) => `\`${context}\``).join(" and ")} accept partial
overrides of the semantic tokens above. Components apply these contexts to
surfaces that need different contrast without changing the base theme.
`,
{ parser: "mdx", printWidth: 120, semi: false },
)
if (process.argv.includes("--check")) {
const current = await Bun.file(target).text()
if (current === output) process.exit(0)
console.error("Generated theme token documentation is stale. Run `bun run generate` from packages/docs.")
process.exit(1)
}
await Bun.write(target, output)
function requireObject(ast: SchemaAST.AST): SchemaAST.Objects {
if (SchemaAST.isObjects(ast)) return ast
if (SchemaAST.isUnion(ast)) {
const object = ast.types.map(findObject).find((value) => value !== undefined)
if (object) return object
}
throw new Error(`Expected an object schema, received ${ast._tag}`)
}
function findObject(ast: SchemaAST.AST): SchemaAST.Objects | undefined {
if (SchemaAST.isObjects(ast)) return ast
if (SchemaAST.isUnion(ast)) return ast.types.map(findObject).find((value) => value !== undefined)
if (SchemaAST.isSuspend(ast)) return findObject(ast.thunk())
}
function requireField(ast: SchemaAST.Objects, name: string) {
const field = ast.propertySignatures.find((field) => String(field.name) === name)
if (field) return field
throw new Error(`Theme schema field not found: ${name}`)
}
function tokenPaths(ast: SchemaAST.AST, prefix: string): string[] {
const object = findObject(ast)
if (!object || object.propertySignatures.length === 0) return [prefix]
return object.propertySignatures.flatMap((field) => tokenPaths(field.type, `${prefix}.${String(field.name)}`))
}
@@ -0,0 +1,79 @@
{/* Generated by packages/docs/script/generate-theme-tokens.ts. Do not edit. */}
```json title="my-theme.json"
{
"version": 2,
"light": {
"hue": {
"accent": "$hue.purple",
"interactive": "$hue.purple"
},
"text": {
"default": "$hue.neutral.900"
},
"background": {
"default": "#fafafa"
}
},
"dark": {
"mergeMode": true,
"text": {
"default": "$hue.neutral.100"
},
"background": {
"default": "#101014"
}
}
}
```
## Token reference
This reference is generated from the Effect schema in
`packages/tui/src/theme/v2/schema.ts`. Changes to the runtime schema update
this section through `bun run generate`.
### Hue tokens
Every hue is a 9-step scale. Define a scale with all of these
steps, or alias it to another hue with a value such as `$hue.blue`.
| | Values |
| ----- | -------------------------------------------------------------------------------------------------------- |
| Hues | `gray`, `red`, `orange`, `yellow`, `green`, `cyan`, `blue`, `purple`, `accent`, `interactive`, `neutral` |
| Steps | `100`, `200`, `300`, `400`, `500`, `600`, `700`, `800`, `900` |
Reference a hue color as `$hue.<name>.<step>`, for example
`$hue.interactive.500`.
### Semantic tokens
Semantic values can reference another token by prefixing its path with `$`,
for example `$text.default`. Stateful tokens inherit their `default`
value when a state is omitted.
| Group | Tokens |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `text` | `text.default`<br />`text.subdued` |
| `text.action` | `text.action.primary.default`<br />`text.action.primary.$hovered`<br />`text.action.primary.$focused`<br />`text.action.primary.$pressed`<br />`text.action.primary.$selected`<br />`text.action.primary.$disabled`<br />`text.action.destructive.default`<br />`text.action.destructive.$hovered`<br />`text.action.destructive.$focused`<br />`text.action.destructive.$pressed`<br />`text.action.destructive.$selected`<br />`text.action.destructive.$disabled` |
| `text.formfield` | `text.formfield.default`<br />`text.formfield.$hovered`<br />`text.formfield.$focused`<br />`text.formfield.$pressed`<br />`text.formfield.$selected`<br />`text.formfield.$disabled` |
| `text.feedback` | `text.feedback.error.default`<br />`text.feedback.error.subdued`<br />`text.feedback.warning.default`<br />`text.feedback.warning.subdued`<br />`text.feedback.success.default`<br />`text.feedback.success.subdued`<br />`text.feedback.info.default`<br />`text.feedback.info.subdued` |
| `background` | `background.default` |
| `background.surface` | `background.surface.offset`<br />`background.surface.overlay` |
| `background.action` | `background.action.primary.default`<br />`background.action.primary.$hovered`<br />`background.action.primary.$focused`<br />`background.action.primary.$pressed`<br />`background.action.primary.$selected`<br />`background.action.primary.$disabled`<br />`background.action.destructive.default`<br />`background.action.destructive.$hovered`<br />`background.action.destructive.$focused`<br />`background.action.destructive.$pressed`<br />`background.action.destructive.$selected`<br />`background.action.destructive.$disabled` |
| `background.formfield` | `background.formfield.default`<br />`background.formfield.$hovered`<br />`background.formfield.$focused`<br />`background.formfield.$pressed`<br />`background.formfield.$selected`<br />`background.formfield.$disabled` |
| `background.feedback` | `background.feedback.error.default`<br />`background.feedback.warning.default`<br />`background.feedback.success.default`<br />`background.feedback.info.default` |
| `border` | `border.default` |
| `scrollbar` | `scrollbar.default` |
| `diff.text` | `diff.text.added`<br />`diff.text.removed`<br />`diff.text.context`<br />`diff.text.hunkHeader` |
| `diff.background` | `diff.background.added`<br />`diff.background.removed`<br />`diff.background.context` |
| `diff.highlight` | `diff.highlight.added`<br />`diff.highlight.removed` |
| `diff.lineNumber` | `diff.lineNumber.text`<br />`diff.lineNumber.background.added`<br />`diff.lineNumber.background.removed` |
| `syntax` | `syntax.comment`<br />`syntax.keyword`<br />`syntax.function`<br />`syntax.variable`<br />`syntax.string`<br />`syntax.number`<br />`syntax.type`<br />`syntax.operator`<br />`syntax.punctuation` |
| `markdown` | `markdown.text`<br />`markdown.heading`<br />`markdown.link`<br />`markdown.linkText`<br />`markdown.code`<br />`markdown.blockQuote`<br />`markdown.emphasis`<br />`markdown.strong`<br />`markdown.horizontalRule`<br />`markdown.listItem`<br />`markdown.listEnumeration`<br />`markdown.image`<br />`markdown.imageText`<br />`markdown.codeBlock` |
### Contexts
`@context:elevated` and `@context:overlay` accept partial
overrides of the semantic tokens above. Components apply these contexts to
surfaces that need different contrast without changing the base theme.
+129
View File
@@ -0,0 +1,129 @@
---
title: "Themes"
description: "Choose a built-in TUI theme or create a custom color scheme."
---
import ThemeTokens from "/snippets/generated/theme-tokens.mdx"
OpenCode includes built-in light and dark themes and can load custom themes
from your global configuration or a project directory. The default theme is
`opencode`.
## Choose a theme
In the full-screen TUI, run:
```text
/themes
```
You can also open the picker with `ctrl+x`, then `t`, using the
default keybindings.
Use `/settings` to change both the theme and its color mode. OpenCode supports
three modes:
| Mode | Behavior |
| -------- | -------------------------------------------------------- |
| `system` | Follow the terminal's detected light or dark appearance. |
| `dark` | Always use the theme's dark colors. |
| `light` | Always use the theme's light colors. |
Your selection is stored in `~/.config/opencode/cli.json`, or the equivalent
path under `$XDG_CONFIG_HOME`:
```json title="cli.json"
{
"theme": {
"name": "tokyonight",
"mode": "system"
}
}
```
<Note>
Theme selection applies to the full-screen TUI. Direct interactive runs use colors derived from the terminal palette
and honor only the color mode.
</Note>
## Built-in themes
OpenCode currently includes:
| | | |
| ------------ | ------------------- | ---------------------- |
| `aura` | `ayu` | `carbonfox` |
| `catppuccin` | `catppuccin-frappe` | `catppuccin-macchiato` |
| `cobalt2` | `cursor` | `dracula` |
| `everforest` | `flexoki` | `github` |
| `gruvbox` | `kanagawa` | `lucent-orng` |
| `material` | `matrix` | `mercury` |
| `monokai` | `nightowl` | `nord` |
| `one-dark` | `opencode` | `orng` |
| `osaka-jade` | `palenight` | `rosepine` |
| `solarized` | `synthwave84` | `tokyonight` |
| `vercel` | `vesper` | `zenburn` |
When OpenCode can read your terminal palette, the picker also includes
`system`. The `system` theme generates its colors from your terminal's
foreground, background, and ANSI palette.
## Custom themes
Create a JSON file in either of these locations:
```text
~/.config/opencode/themes/my-theme.json
.opencode/themes/my-theme.json
```
OpenCode checks the global theme directory first, followed by every
`.opencode/themes` directory from the filesystem root down to the current
directory. A more local file with the same filename overrides an earlier one.
The filename becomes the theme name, so `my-theme.json` appears as `my-theme`.
Custom theme files must be strict JSON. Comments and trailing commas are not
supported.
### Format
V2 themes organize colors into hue scales and semantic tokens. Set `version`
to `2` and define at least one of `light` or `dark`:
<Warning>
Native V2 custom theme files are not loaded directly by the current beta. Existing custom files use the V1 format and
are migrated to these tokens at runtime. This reference tracks the native V2 schema while direct file loading is
completed.
</Warning>
By default, a theme inherits OpenCode's complete theme, so you only need to
define overrides. Set `mergeMode` to `true` to inherit one mode from the other
before applying that mode's overrides. Set `standalone` to `true` only when you
intend to supply a complete independent theme.
Each token accepts:
- A hex color such as `"#5c9cf5"`
- `"transparent"` to use the terminal default
- A hue reference such as `"$hue.blue.500"`
- Another semantic token reference such as `"$text.default"`
Syntax and markdown tokens accept hex colors and hue references. Other
semantic tokens can reference any semantic token.
<ThemeTokens />
If you add or edit a custom theme while OpenCode is running, restart the TUI to
reload it.
## Terminal colors
Themes display most accurately in a terminal with truecolor support. Check
your terminal with:
```bash
echo $COLORTERM
```
Most modern terminals report `truecolor` or `24bit`. Without truecolor,
OpenCode approximates theme colors using the available terminal palette.
+1 -4
View File
@@ -16,10 +16,7 @@ export type ID = typeof ID.Type
export const Name = Schema.String.pipe(Schema.brand("Agent.Name"))
export type Name = typeof Name.Type
export const Color = Schema.Union([
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
]).annotate({ identifier: "Agent.Color" })
export const Color = Schema.String.annotate({ identifier: "Agent.Color" })
export type Color = typeof Color.Type
export interface Info extends Schema.Schema.Type<typeof Info> {}
+2 -2
View File
@@ -106,13 +106,13 @@ export const Info = Schema.Struct({
.annotate({ identifier: "Model.Info" })
.pipe(
statics(() => ({
empty: (providerID: Provider.ID, id: ID) =>
default: (providerID: Provider.ID, id: ID) =>
({
id,
modelID: id,
providerID,
name: id,
capabilities: { tools: false, input: [], output: [] },
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
variants: [],
time: { released: 0 },
cost: [],
+10
View File
@@ -0,0 +1,10 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { Agent } from "../src/agent.js"
test("Agent.Color preserves configured colors at the public boundary", () => {
const encode = Schema.encodeSync(Agent.Color)
expect(encode("info")).toBe("info")
expect(encode("custom-color")).toBe("custom-color")
})
@@ -20,6 +20,12 @@ import { PersistedRevert } from "../src/session-revert.js"
import { optional } from "../src/schema.js"
describe("contract hygiene", () => {
test("restricts agent colors to six-digit hex values", () => {
const decode = Schema.decodeUnknownSync(Agent.Color)
expect(decode("#ff6b6b")).toBe("#ff6b6b")
expect(() => decode("warning")).toThrow()
})
test("keeps absolute costs distinct from model rates", () => {
const usd = Money.USD.make(1)
const rate = Money.USDPerMillionTokens.make(1)
@@ -77,7 +83,7 @@ describe("contract hygiene", () => {
test("model defaults and provider overlays preserve public invariants", () => {
const id = Model.ID.make("model")
expect(Model.Info.empty(Provider.ID.make("provider"), id)).toMatchObject({ modelID: id, variants: [] })
expect(Model.Info.default(Provider.ID.make("provider"), id)).toMatchObject({ modelID: id, variants: [] })
expect(() =>
Schema.decodeUnknownSync(Provider.Info)({
id: "provider",
+2
View File
@@ -65,6 +65,7 @@ import { DialogThemeList } from "./component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "./component/dialog-agent"
import { DialogSessionList } from "./component/dialog-session-list"
import { ThemeErrorToast } from "./component/theme-error-toast"
import { ThemeProvider, useTheme } from "./context/theme"
import { Home } from "./routes/home"
import { Session } from "./routes/session"
@@ -337,6 +338,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<DataProvider>
<LocationProvider>
<ThemeProvider mode={mode}>
<ThemeErrorToast />
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
+4 -4
View File
@@ -70,7 +70,7 @@ declare module "@opentui/solid" {
extend({ go_upsell_art: GoUpsellArtRenderable })
export function BgPulse() {
const { theme } = useTheme()
const { themeV2, mode } = useTheme().contextual("elevated")
const renderer = useRenderer()
let targetFps = renderer.targetFps
let maxFps = renderer.maxFps
@@ -91,9 +91,9 @@ export function BgPulse() {
<go_upsell_art
width="100%"
height="100%"
backgroundPanel={theme.backgroundPanel}
primary={theme.primary}
logoBase={tint(theme.background, theme.text, 0.62)}
backgroundPanel={themeV2.background.default}
primary={themeV2.hue.interactive[mode() === "light" ? 800 : 200]}
logoBase={tint(themeV2.background.default, themeV2.text.default, 0.62)}
live
/>
)
+12 -1
View File
@@ -59,6 +59,7 @@ export function DevToolsBar() {
const canSwitchMode = () => supports(nextMode())
const runtime = createMemo(() => runtimeStatus(frontendSamples()))
const timing = () => config.data.debug?.timing ?? false
const turnTokens = () => config.data.debug?.turn_tokens ?? false
const offEscape = keymap.intercept(
"key",
@@ -352,6 +353,16 @@ export function DevToolsBar() {
>
{timing() ? "[x]" : "[ ]"} Time to first draw
</Action>
<Action
onClick={() =>
void config.update((draft) => {
draft.debug = { ...draft.debug, turn_tokens: !turnTokens() }
})
}
hoverBackground
>
{turnTokens() ? "[x]" : "[ ]"} Turn token usage
</Action>
</box>
<For each={groups()}>
{(group) => (
@@ -403,7 +414,7 @@ function PanelBox(props: ParentProps) {
position="absolute"
zIndex={2600}
bottom={1}
left={0}
left={-1}
width={42}
paddingLeft={2}
paddingRight={2}
@@ -222,14 +222,6 @@ const settings: Setting[] = [
values: [false, true],
labels: ["off", "on"],
},
{
title: "DevTools: Timing",
category: "Debug",
path: ["debug", "timing"],
default: true,
values: [false, true],
labels: ["off", "on"],
},
]
export function DialogConfig() {
@@ -0,0 +1,20 @@
import { onCleanup } from "solid-js"
import { useTheme } from "../context/theme"
import { useToast } from "../ui/toast"
export function ThemeErrorToast() {
const theme = useTheme()
const toast = useToast()
onCleanup(
theme.onError(({ name, error }) =>
toast.show({
variant: "error",
title: `Failed to load theme: ${name}`,
message: error.message,
}),
),
)
return null
}

Some files were not shown because too many files have changed in this diff Show More