core: expose v2 model listing API (#25821)

This commit is contained in:
Dax 2026-05-13 10:43:08 -04:00 committed by GitHub
commit 8345152319
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
138 changed files with 8188 additions and 302 deletions

View file

@ -0,0 +1,523 @@
import { convertToOpenAICompatibleChatMessages as convertToCopilotMessages } from "@opencode-ai/core/github-copilot/chat/convert-to-openai-compatible-chat-messages"
import { describe, test, expect } from "bun:test"
describe("system messages", () => {
test("should convert system message content to string", () => {
const result = convertToCopilotMessages([
{
role: "system",
content: "You are a helpful assistant with AGENTS.md instructions.",
},
])
expect(result).toEqual([
{
role: "system",
content: "You are a helpful assistant with AGENTS.md instructions.",
},
])
})
})
describe("user messages", () => {
test("should convert messages with only a text part to a string content", () => {
const result = convertToCopilotMessages([
{
role: "user",
content: [{ type: "text", text: "Hello" }],
},
])
expect(result).toEqual([{ role: "user", content: "Hello" }])
})
test("should convert messages with image parts", () => {
const result = convertToCopilotMessages([
{
role: "user",
content: [
{ type: "text", text: "Hello" },
{
type: "file",
data: Buffer.from([0, 1, 2, 3]).toString("base64"),
mediaType: "image/png",
},
],
},
])
expect(result).toEqual([
{
role: "user",
content: [
{ type: "text", text: "Hello" },
{
type: "image_url",
image_url: { url: "data:image/png;base64,AAECAw==" },
},
],
},
])
})
test("should convert messages with image parts from Uint8Array", () => {
const result = convertToCopilotMessages([
{
role: "user",
content: [
{ type: "text", text: "Hi" },
{
type: "file",
data: new Uint8Array([0, 1, 2, 3]),
mediaType: "image/png",
},
],
},
])
expect(result).toEqual([
{
role: "user",
content: [
{ type: "text", text: "Hi" },
{
type: "image_url",
image_url: { url: "data:image/png;base64,AAECAw==" },
},
],
},
])
})
test("should handle URL-based images", () => {
const result = convertToCopilotMessages([
{
role: "user",
content: [
{
type: "file",
data: new URL("https://example.com/image.jpg"),
mediaType: "image/*",
},
],
},
])
expect(result).toEqual([
{
role: "user",
content: [
{
type: "image_url",
image_url: { url: "https://example.com/image.jpg" },
},
],
},
])
})
test("should handle multiple text parts without flattening", () => {
const result = convertToCopilotMessages([
{
role: "user",
content: [
{ type: "text", text: "Part 1" },
{ type: "text", text: "Part 2" },
],
},
])
expect(result).toEqual([
{
role: "user",
content: [
{ type: "text", text: "Part 1" },
{ type: "text", text: "Part 2" },
],
},
])
})
})
describe("assistant messages", () => {
test("should convert assistant text messages", () => {
const result = convertToCopilotMessages([
{
role: "assistant",
content: [{ type: "text", text: "Hello back!" }],
},
])
expect(result).toEqual([
{
role: "assistant",
content: "Hello back!",
tool_calls: undefined,
reasoning_text: undefined,
reasoning_opaque: undefined,
},
])
})
test("should handle assistant message with null content when only tool calls", () => {
const result = convertToCopilotMessages([
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call1",
toolName: "calculator",
input: { a: 1, b: 2 },
},
],
},
])
expect(result).toEqual([
{
role: "assistant",
content: null,
tool_calls: [
{
id: "call1",
type: "function",
function: {
name: "calculator",
arguments: JSON.stringify({ a: 1, b: 2 }),
},
},
],
reasoning_text: undefined,
reasoning_opaque: undefined,
},
])
})
test("should concatenate multiple text parts", () => {
const result = convertToCopilotMessages([
{
role: "assistant",
content: [
{ type: "text", text: "First part. " },
{ type: "text", text: "Second part." },
],
},
])
expect(result[0].content).toBe("First part. Second part.")
})
})
describe("tool calls", () => {
test("should stringify arguments to tool calls", () => {
const result = convertToCopilotMessages([
{
role: "assistant",
content: [
{
type: "tool-call",
input: { foo: "bar123" },
toolCallId: "quux",
toolName: "thwomp",
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "quux",
toolName: "thwomp",
output: { type: "json", value: { oof: "321rab" } },
},
],
},
])
expect(result).toEqual([
{
role: "assistant",
content: null,
tool_calls: [
{
id: "quux",
type: "function",
function: {
name: "thwomp",
arguments: JSON.stringify({ foo: "bar123" }),
},
},
],
reasoning_text: undefined,
reasoning_opaque: undefined,
},
{
role: "tool",
tool_call_id: "quux",
content: JSON.stringify({ oof: "321rab" }),
},
])
})
test("should handle text output type in tool results", () => {
const result = convertToCopilotMessages([
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-1",
toolName: "getWeather",
output: { type: "text", value: "It is sunny today" },
},
],
},
])
expect(result).toEqual([
{
role: "tool",
tool_call_id: "call-1",
content: "It is sunny today",
},
])
})
test("should handle multiple tool results as separate messages", () => {
const result = convertToCopilotMessages([
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call1",
toolName: "api1",
output: { type: "text", value: "Result 1" },
},
{
type: "tool-result",
toolCallId: "call2",
toolName: "api2",
output: { type: "text", value: "Result 2" },
},
],
},
])
expect(result).toHaveLength(2)
expect(result[0]).toEqual({
role: "tool",
tool_call_id: "call1",
content: "Result 1",
})
expect(result[1]).toEqual({
role: "tool",
tool_call_id: "call2",
content: "Result 2",
})
})
test("should handle text plus multiple tool calls", () => {
const result = convertToCopilotMessages([
{
role: "assistant",
content: [
{ type: "text", text: "Checking... " },
{
type: "tool-call",
toolCallId: "call1",
toolName: "searchTool",
input: { query: "Weather" },
},
{ type: "text", text: "Almost there..." },
{
type: "tool-call",
toolCallId: "call2",
toolName: "mapsTool",
input: { location: "Paris" },
},
],
},
])
expect(result).toEqual([
{
role: "assistant",
content: "Checking... Almost there...",
tool_calls: [
{
id: "call1",
type: "function",
function: {
name: "searchTool",
arguments: JSON.stringify({ query: "Weather" }),
},
},
{
id: "call2",
type: "function",
function: {
name: "mapsTool",
arguments: JSON.stringify({ location: "Paris" }),
},
},
],
reasoning_text: undefined,
reasoning_opaque: undefined,
},
])
})
})
describe("reasoning (copilot-specific)", () => {
test("should omit reasoning_text without reasoning_opaque", () => {
const result = convertToCopilotMessages([
{
role: "assistant",
content: [
{ type: "reasoning", text: "Let me think about this..." },
{ type: "text", text: "The answer is 42." },
],
},
])
expect(result).toEqual([
{
role: "assistant",
content: "The answer is 42.",
tool_calls: undefined,
reasoning_text: undefined,
reasoning_opaque: undefined,
},
])
})
test("should include reasoning_opaque from providerOptions", () => {
const result = convertToCopilotMessages([
{
role: "assistant",
content: [
{
type: "reasoning",
text: "Thinking...",
providerOptions: {
copilot: { reasoningOpaque: "opaque-signature-123" },
},
},
{ type: "text", text: "Done!" },
],
},
])
expect(result).toEqual([
{
role: "assistant",
content: "Done!",
tool_calls: undefined,
reasoning_text: "Thinking...",
reasoning_opaque: "opaque-signature-123",
},
])
})
test("should include reasoning_opaque from text part providerOptions", () => {
const result = convertToCopilotMessages([
{
role: "assistant",
content: [
{
type: "text",
text: "Done!",
providerOptions: {
copilot: { reasoningOpaque: "opaque-text-456" },
},
},
],
},
])
expect(result).toEqual([
{
role: "assistant",
content: "Done!",
tool_calls: undefined,
reasoning_text: undefined,
reasoning_opaque: "opaque-text-456",
},
])
})
test("should handle reasoning-only assistant message", () => {
const result = convertToCopilotMessages([
{
role: "assistant",
content: [
{
type: "reasoning",
text: "Just thinking, no response yet",
providerOptions: {
copilot: { reasoningOpaque: "sig-abc" },
},
},
],
},
])
expect(result).toEqual([
{
role: "assistant",
content: null,
tool_calls: undefined,
reasoning_text: "Just thinking, no response yet",
reasoning_opaque: "sig-abc",
},
])
})
})
describe("full conversation", () => {
test("should convert a multi-turn conversation with reasoning", () => {
const result = convertToCopilotMessages([
{
role: "system",
content: "You are a helpful assistant.",
},
{
role: "user",
content: [{ type: "text", text: "What is 2+2?" }],
},
{
role: "assistant",
content: [
{
type: "reasoning",
text: "Let me calculate 2+2...",
providerOptions: {
copilot: { reasoningOpaque: "sig-abc" },
},
},
{ type: "text", text: "2+2 equals 4." },
],
},
{
role: "user",
content: [{ type: "text", text: "What about 3+3?" }],
},
])
expect(result).toHaveLength(4)
const systemMsg = result[0]
expect(systemMsg.role).toBe("system")
// Assistant message should have reasoning fields
const assistantMsg = result[2] as {
reasoning_text?: string
reasoning_opaque?: string
}
expect(assistantMsg.reasoning_text).toBe("Let me calculate 2+2...")
expect(assistantMsg.reasoning_opaque).toBe("sig-abc")
})
})

View file

@ -0,0 +1,592 @@
import { OpenAICompatibleChatLanguageModel } from "@opencode-ai/core/github-copilot/chat/openai-compatible-chat-language-model"
import { describe, test, expect, mock } from "bun:test"
import type { LanguageModelV3Prompt } from "@ai-sdk/provider"
async function convertReadableStreamToArray<T>(stream: ReadableStream<T>): Promise<T[]> {
const reader = stream.getReader()
const result: T[] = []
while (true) {
const { done, value } = await reader.read()
if (done) break
result.push(value)
}
return result
}
const TEST_PROMPT: LanguageModelV3Prompt = [{ role: "user", content: [{ type: "text", text: "Hello" }] }]
// Fixtures from copilot_test.exs
const FIXTURES = {
basicText: [
`data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gemini-2.0-flash-001","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}`,
`data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gemini-2.0-flash-001","choices":[{"index":0,"delta":{"content":" world"},"finish_reason":null}]}`,
`data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gemini-2.0-flash-001","choices":[{"index":0,"delta":{"content":"!"},"finish_reason":"stop"}]}`,
`data: [DONE]`,
],
reasoningWithToolCalls: [
`data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Understanding Dayzee's Purpose**\\n\\nI'm starting to get a better handle on \`dayzee\`.\\n\\n"}}],"created":1764940861,"id":"OdwyabKMI9yel7oPlbzgwQM","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}`,
`data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Assessing Dayzee's Functionality**\\n\\nI've reviewed the files.\\n\\n"}}],"created":1764940862,"id":"OdwyabKMI9yel7oPlbzgwQM","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}`,
`data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{\\"filePath\\":\\"/README.md\\"}","name":"read_file"},"id":"call_abc123","index":0,"type":"function"}],"reasoning_opaque":"4CUQ6696CwSXOdQ5rtvDimqA91tBzfmga4ieRbmZ5P67T2NLW3"}}],"created":1764940862,"id":"OdwyabKMI9yel7oPlbzgwQM","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}`,
`data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{\\"filePath\\":\\"/mix.exs\\"}","name":"read_file"},"id":"call_def456","index":1,"type":"function"}]}}],"created":1764940862,"id":"OdwyabKMI9yel7oPlbzgwQM","usage":{"completion_tokens":53,"prompt_tokens":19581,"prompt_tokens_details":{"cached_tokens":17068},"total_tokens":19768,"reasoning_tokens":134},"model":"gemini-3-pro-preview"}`,
`data: [DONE]`,
],
reasoningWithOpaqueAtEnd: [
`data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Analyzing the Inquiry's Nature**\\n\\nI'm currently parsing the user's question.\\n\\n"}}],"created":1765201729,"id":"Ptc2afqsCIHqlOoP653UiAI","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}`,
`data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Reconciling User's Input**\\n\\nI'm grappling with the context.\\n\\n"}}],"created":1765201730,"id":"Ptc2afqsCIHqlOoP653UiAI","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}`,
`data: {"choices":[{"index":0,"delta":{"content":"I am Tidewave, a highly skilled AI coding agent.\\n\\n","role":"assistant"}}],"created":1765201730,"id":"Ptc2afqsCIHqlOoP653UiAI","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}`,
`data: {"choices":[{"finish_reason":"stop","index":0,"delta":{"content":"How can I help you?","role":"assistant","reasoning_opaque":"/PMlTqxqSJZnUBDHgnnJKLVI4eZQ"}}],"created":1765201730,"id":"Ptc2afqsCIHqlOoP653UiAI","usage":{"completion_tokens":59,"prompt_tokens":5778,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":5932,"reasoning_tokens":95},"model":"gemini-3-pro-preview"}`,
`data: [DONE]`,
],
// Case where reasoning_opaque and content come in the SAME chunk
reasoningWithOpaqueAndContentSameChunk: [
`data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Understanding the Query's Nature**\\n\\nI'm currently grappling with the user's philosophical query.\\n\\n"}}],"created":1766062103,"id":"FPhDacixL9zrlOoPqLSuyQ4","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-2.5-pro"}`,
`data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Framing the Response's Core**\\n\\nNow, I'm structuring my response.\\n\\n"}}],"created":1766062103,"id":"FPhDacixL9zrlOoPqLSuyQ4","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-2.5-pro"}`,
`data: {"choices":[{"index":0,"delta":{"content":"Of course. I'm thinking right now.","role":"assistant","reasoning_opaque":"ExXaGwW7jBo39OXRe9EPoFGN1rOtLJBx"}}],"created":1766062103,"id":"FPhDacixL9zrlOoPqLSuyQ4","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-2.5-pro"}`,
`data: {"choices":[{"finish_reason":"stop","index":0,"delta":{"content":" What's on your mind?","role":"assistant"}}],"created":1766062103,"id":"FPhDacixL9zrlOoPqLSuyQ4","usage":{"completion_tokens":78,"prompt_tokens":3767,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":3915,"reasoning_tokens":70},"model":"gemini-2.5-pro"}`,
`data: [DONE]`,
],
// Case where reasoning_opaque and content come in same chunk, followed by tool calls
reasoningWithOpaqueContentAndToolCalls: [
`data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Analyzing the Structure**\\n\\nI'm currently trying to get a handle on the project's layout. My initial focus is on the file structure itself, specifically the directory organization. I'm hoping this will illuminate how different components interact. I'll need to identify the key modules and their dependencies.\\n\\n\\n"}}],"created":1766066995,"id":"MQtEafqbFYTZsbwPwuCVoAg","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-2.5-pro"}`,
`data: {"choices":[{"index":0,"delta":{"content":"Okay, I need to check out the project's file structure.","role":"assistant","reasoning_opaque":"WHOd3dYFnxEBOsKUXjbX6c2rJa0fS214FHbsj+A3Q+i63SFo7H/92RsownAzyo0h2qEy3cOcrvAatsMx51eCKiMSqt4dYWZhd5YVSgF0CehkpDbWBP/SoRqLU1dhCmUJV/6b5uYFBOzKLBGNadyhI7T1gWFlXntwc6SNjH6DujnFPeVr+L8DdOoUJGJrw2aOfm9NtkXA6wZh9t7dt+831yIIImjD9MHczuXoXj8K7tyLpIJ9KlVXMhnO4IKSYNdKRtoHlGTmudAp5MgH/vLWb6oSsL+ZJl/OdF3WBOeanGhYNoByCRDSvR7anAR/9m5zf9yUax+u/nFg+gzmhFacnzZGtSmcvJ4/4HWKNtUkRASTKeN94DXB8j1ptB/i6ldaMAz2ZyU+sbjPWI8aI4fKJ2MuO01u3uE87xVwpWiM+0rahIzJsllI5edwOaOFtF4tnlCTQafbxHwCZR62uON2E+IjGzW80MzyfYrbLBJKS5zTeHCgPYQSNaKzPfpzkQvdwo3JUnJYcEHgGeKzkq5sbvS5qitCYI7Xue0V98S6/KnUSPnDQBjNnas2i6BqJV2vuCEU/Y3ucrlKVbuRIFCZXCyLzrsGeRLRKlrf5S/HDAQ04IOPQVQhBPvhX0nDjhZB"}}],"created":1766066995,"id":"MQtEafqbFYTZsbwPwuCVoAg","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-2.5-pro"}`,
`data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{}","name":"list_project_files"},"id":"call_MHxqRDd5WVo3NU8wUXRaMmc0MFE","index":0,"type":"function"}]}}],"created":1766066995,"id":"MQtEafqbFYTZsbwPwuCVoAg","usage":{"completion_tokens":19,"prompt_tokens":3767,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":3797,"reasoning_tokens":11},"model":"gemini-2.5-pro"}`,
`data: [DONE]`,
],
// Case where reasoning goes directly to tool_calls with NO content
// reasoning_opaque and tool_calls come in the same chunk
reasoningDirectlyToToolCalls: [
`data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Executing and Analyzing HTML**\\n\\nI've successfully captured the HTML snapshot using the \`browser_eval\` tool, giving me a solid understanding of the page structure. Now, I'm shifting focus to Elixir code execution with \`project_eval\` to assess my ability to work within the project's environment.\\n\\n\\n"}}],"created":1766068643,"id":"oBFEaafzD9DVlOoPkY3l4Qs","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}`,
`data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Testing Project Contexts**\\n\\nI've got the HTML body snapshot from \`browser_eval\`, which is a helpful reference. Next, I'm testing my ability to run Elixir code in the project with \`project_eval\`. I'm starting with a simple sum: \`1 + 1\`. This will confirm I'm set up to interact with the project's codebase.\\n\\n\\n"}}],"created":1766068644,"id":"oBFEaafzD9DVlOoPkY3l4Qs","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}`,
`data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{\\"code\\":\\"1 + 1\\"}","name":"project_eval"},"id":"call_MHw3RDhmT1J5Z3B6WlhpVjlveTc","index":0,"type":"function"}],"reasoning_opaque":"ytGNWFf2doK38peANDvm7whkLPKrd+Fv6/k34zEPBF6Qwitj4bTZT0FBXleydLb6"}}],"created":1766068644,"id":"oBFEaafzD9DVlOoPkY3l4Qs","usage":{"completion_tokens":12,"prompt_tokens":8677,"prompt_tokens_details":{"cached_tokens":3692},"total_tokens":8768,"reasoning_tokens":79},"model":"gemini-3-pro-preview"}`,
`data: [DONE]`,
],
reasoningOpaqueWithToolCallsNoReasoningText: [
`data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{}","name":"read_file"},"id":"call_reasoning_only","index":0,"type":"function"}],"reasoning_opaque":"opaque-xyz"}}],"created":1769917420,"id":"opaque-only","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-flash-preview"}`,
`data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{}","name":"read_file"},"id":"call_reasoning_only_2","index":1,"type":"function"}]}}],"created":1769917420,"id":"opaque-only","usage":{"completion_tokens":12,"prompt_tokens":123,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":135,"reasoning_tokens":0},"model":"gemini-3-flash-preview"}`,
`data: [DONE]`,
],
}
function createMockFetch(chunks: string[]) {
return mock(async () => {
const body = new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(new TextEncoder().encode(chunk + "\n\n"))
}
controller.close()
},
})
return new Response(body, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
})
})
}
function createModel(fetchFn: ReturnType<typeof mock>) {
return new OpenAICompatibleChatLanguageModel("test-model", {
provider: "copilot.chat",
url: () => "https://api.test.com/chat/completions",
headers: () => ({ Authorization: "Bearer test-token" }),
fetch: fetchFn as any,
})
}
describe("doStream", () => {
test("should stream text deltas", async () => {
const mockFetch = createMockFetch(FIXTURES.basicText)
const model = createModel(mockFetch)
const { stream } = await model.doStream({
prompt: TEST_PROMPT,
includeRawChunks: false,
})
const parts = await convertReadableStreamToArray(stream)
// Filter to just the key events
const textParts = parts.filter(
(p) => p.type === "text-start" || p.type === "text-delta" || p.type === "text-end" || p.type === "finish",
)
expect(textParts).toMatchObject([
{ type: "text-start", id: "txt-0" },
{ type: "text-delta", id: "txt-0", delta: "Hello" },
{ type: "text-delta", id: "txt-0", delta: " world" },
{ type: "text-delta", id: "txt-0", delta: "!" },
{ type: "text-end", id: "txt-0" },
{ type: "finish", finishReason: { unified: "stop" } },
])
})
test("should stream reasoning with tool calls and capture reasoning_opaque", async () => {
const mockFetch = createMockFetch(FIXTURES.reasoningWithToolCalls)
const model = createModel(mockFetch)
const { stream } = await model.doStream({
prompt: TEST_PROMPT,
includeRawChunks: false,
})
const parts = await convertReadableStreamToArray(stream)
// Check reasoning parts
const reasoningParts = parts.filter(
(p) => p.type === "reasoning-start" || p.type === "reasoning-delta" || p.type === "reasoning-end",
)
expect(reasoningParts[0]).toEqual({
type: "reasoning-start",
id: "reasoning-0",
})
expect(reasoningParts[1]).toMatchObject({
type: "reasoning-delta",
id: "reasoning-0",
})
expect((reasoningParts[1] as { delta: string }).delta).toContain("**Understanding Dayzee's Purpose**")
expect(reasoningParts[2]).toMatchObject({
type: "reasoning-delta",
id: "reasoning-0",
})
expect((reasoningParts[2] as { delta: string }).delta).toContain("**Assessing Dayzee's Functionality**")
// reasoning_opaque should be in reasoning-end providerMetadata
const reasoningEnd = reasoningParts.find((p) => p.type === "reasoning-end")
expect(reasoningEnd).toMatchObject({
type: "reasoning-end",
id: "reasoning-0",
providerMetadata: {
copilot: {
reasoningOpaque: "4CUQ6696CwSXOdQ5rtvDimqA91tBzfmga4ieRbmZ5P67T2NLW3",
},
},
})
// Check tool calls
const toolParts = parts.filter(
(p) => p.type === "tool-input-start" || p.type === "tool-call" || p.type === "tool-input-end",
)
expect(toolParts).toContainEqual({
type: "tool-input-start",
id: "call_abc123",
toolName: "read_file",
})
expect(toolParts).toContainEqual(
expect.objectContaining({
type: "tool-call",
toolCallId: "call_abc123",
toolName: "read_file",
}),
)
expect(toolParts).toContainEqual({
type: "tool-input-start",
id: "call_def456",
toolName: "read_file",
})
// Check finish
const finish = parts.find((p) => p.type === "finish")
expect(finish).toMatchObject({
type: "finish",
finishReason: { unified: "tool-calls" },
usage: {
inputTokens: { total: 19581 },
outputTokens: { total: 53 },
},
})
})
test("should handle reasoning_opaque that comes at end with text in between", async () => {
const mockFetch = createMockFetch(FIXTURES.reasoningWithOpaqueAtEnd)
const model = createModel(mockFetch)
const { stream } = await model.doStream({
prompt: TEST_PROMPT,
includeRawChunks: false,
})
const parts = await convertReadableStreamToArray(stream)
// Check that reasoning comes first
const reasoningStart = parts.findIndex((p) => p.type === "reasoning-start")
const textStart = parts.findIndex((p) => p.type === "text-start")
expect(reasoningStart).toBeLessThan(textStart)
// Check reasoning deltas
const reasoningDeltas = parts.filter((p) => p.type === "reasoning-delta")
expect(reasoningDeltas).toHaveLength(2)
expect((reasoningDeltas[0] as { delta: string }).delta).toContain("**Analyzing the Inquiry's Nature**")
expect((reasoningDeltas[1] as { delta: string }).delta).toContain("**Reconciling User's Input**")
// Check text deltas
const textDeltas = parts.filter((p) => p.type === "text-delta")
expect(textDeltas).toHaveLength(2)
expect((textDeltas[0] as { delta: string }).delta).toContain("I am Tidewave")
expect((textDeltas[1] as { delta: string }).delta).toContain("How can I help you?")
// reasoning-end should be emitted before text-start
const reasoningEndIndex = parts.findIndex((p) => p.type === "reasoning-end")
const textStartIndex = parts.findIndex((p) => p.type === "text-start")
expect(reasoningEndIndex).toBeGreaterThan(-1)
expect(reasoningEndIndex).toBeLessThan(textStartIndex)
// In this fixture, reasoning_opaque comes AFTER content has started (in chunk 4)
// So it arrives too late to be attached to reasoning-end. But it should still
// be captured and included in the finish event's providerMetadata.
const reasoningEnd = parts.find((p) => p.type === "reasoning-end")
expect(reasoningEnd).toMatchObject({
type: "reasoning-end",
id: "reasoning-0",
})
// reasoning_opaque should be in the finish event's providerMetadata
const finish = parts.find((p) => p.type === "finish")
expect(finish).toMatchObject({
type: "finish",
finishReason: { unified: "stop" },
usage: {
inputTokens: { total: 5778 },
outputTokens: { total: 59 },
},
providerMetadata: {
copilot: {
reasoningOpaque: "/PMlTqxqSJZnUBDHgnnJKLVI4eZQ",
},
},
})
})
test("should handle reasoning_opaque and content in the same chunk", async () => {
const mockFetch = createMockFetch(FIXTURES.reasoningWithOpaqueAndContentSameChunk)
const model = createModel(mockFetch)
const { stream } = await model.doStream({
prompt: TEST_PROMPT,
includeRawChunks: false,
})
const parts = await convertReadableStreamToArray(stream)
// The critical test: reasoning-end should come BEFORE text-start
const reasoningEndIndex = parts.findIndex((p) => p.type === "reasoning-end")
const textStartIndex = parts.findIndex((p) => p.type === "text-start")
expect(reasoningEndIndex).toBeGreaterThan(-1)
expect(textStartIndex).toBeGreaterThan(-1)
expect(reasoningEndIndex).toBeLessThan(textStartIndex)
// Check reasoning deltas
const reasoningDeltas = parts.filter((p) => p.type === "reasoning-delta")
expect(reasoningDeltas).toHaveLength(2)
expect((reasoningDeltas[0] as { delta: string }).delta).toContain("**Understanding the Query's Nature**")
expect((reasoningDeltas[1] as { delta: string }).delta).toContain("**Framing the Response's Core**")
// reasoning_opaque should be in reasoning-end even though it came with content
const reasoningEnd = parts.find((p) => p.type === "reasoning-end")
expect(reasoningEnd).toMatchObject({
type: "reasoning-end",
id: "reasoning-0",
providerMetadata: {
copilot: {
reasoningOpaque: "ExXaGwW7jBo39OXRe9EPoFGN1rOtLJBx",
},
},
})
// Check text deltas
const textDeltas = parts.filter((p) => p.type === "text-delta")
expect(textDeltas).toHaveLength(2)
expect((textDeltas[0] as { delta: string }).delta).toContain("Of course. I'm thinking right now.")
expect((textDeltas[1] as { delta: string }).delta).toContain("What's on your mind?")
// Check finish
const finish = parts.find((p) => p.type === "finish")
expect(finish).toMatchObject({
type: "finish",
finishReason: { unified: "stop" },
})
})
test("should handle reasoning_opaque and content followed by tool calls", async () => {
const mockFetch = createMockFetch(FIXTURES.reasoningWithOpaqueContentAndToolCalls)
const model = createModel(mockFetch)
const { stream } = await model.doStream({
prompt: TEST_PROMPT,
includeRawChunks: false,
})
const parts = await convertReadableStreamToArray(stream)
// Check that reasoning comes first, then text, then tool calls
const reasoningEndIndex = parts.findIndex((p) => p.type === "reasoning-end")
const textStartIndex = parts.findIndex((p) => p.type === "text-start")
const toolStartIndex = parts.findIndex((p) => p.type === "tool-input-start")
expect(reasoningEndIndex).toBeGreaterThan(-1)
expect(textStartIndex).toBeGreaterThan(-1)
expect(toolStartIndex).toBeGreaterThan(-1)
expect(reasoningEndIndex).toBeLessThan(textStartIndex)
expect(textStartIndex).toBeLessThan(toolStartIndex)
// Check reasoning content
const reasoningDeltas = parts.filter((p) => p.type === "reasoning-delta")
expect(reasoningDeltas).toHaveLength(1)
expect((reasoningDeltas[0] as { delta: string }).delta).toContain("**Analyzing the Structure**")
// reasoning_opaque should be in reasoning-end (comes with content in same chunk)
const reasoningEnd = parts.find((p) => p.type === "reasoning-end")
expect(reasoningEnd).toMatchObject({
type: "reasoning-end",
id: "reasoning-0",
providerMetadata: {
copilot: {
reasoningOpaque: expect.stringContaining("WHOd3dYFnxEBOsKUXjbX6c2rJa0fS214"),
},
},
})
// Check text content
const textDeltas = parts.filter((p) => p.type === "text-delta")
expect(textDeltas).toHaveLength(1)
expect((textDeltas[0] as { delta: string }).delta).toContain(
"Okay, I need to check out the project's file structure.",
)
// Check tool call
const toolParts = parts.filter(
(p) => p.type === "tool-input-start" || p.type === "tool-call" || p.type === "tool-input-end",
)
expect(toolParts).toContainEqual({
type: "tool-input-start",
id: "call_MHxqRDd5WVo3NU8wUXRaMmc0MFE",
toolName: "list_project_files",
})
expect(toolParts).toContainEqual(
expect.objectContaining({
type: "tool-call",
toolCallId: "call_MHxqRDd5WVo3NU8wUXRaMmc0MFE",
toolName: "list_project_files",
}),
)
// Check finish
const finish = parts.find((p) => p.type === "finish")
expect(finish).toMatchObject({
type: "finish",
finishReason: { unified: "tool-calls" },
usage: {
inputTokens: { total: 3767 },
outputTokens: { total: 19 },
},
})
})
test("should emit reasoning-end before tool-input-start when reasoning goes directly to tool calls", async () => {
const mockFetch = createMockFetch(FIXTURES.reasoningDirectlyToToolCalls)
const model = createModel(mockFetch)
const { stream } = await model.doStream({
prompt: TEST_PROMPT,
includeRawChunks: false,
})
const parts = await convertReadableStreamToArray(stream)
// Critical check: reasoning-end MUST come before tool-input-start
const reasoningEndIndex = parts.findIndex((p) => p.type === "reasoning-end")
const toolStartIndex = parts.findIndex((p) => p.type === "tool-input-start")
expect(reasoningEndIndex).toBeGreaterThan(-1)
expect(toolStartIndex).toBeGreaterThan(-1)
expect(reasoningEndIndex).toBeLessThan(toolStartIndex)
// Check reasoning parts
const reasoningDeltas = parts.filter((p) => p.type === "reasoning-delta")
expect(reasoningDeltas).toHaveLength(2)
expect((reasoningDeltas[0] as { delta: string }).delta).toContain("**Executing and Analyzing HTML**")
expect((reasoningDeltas[1] as { delta: string }).delta).toContain("**Testing Project Contexts**")
// reasoning_opaque should be in reasoning-end providerMetadata
const reasoningEnd = parts.find((p) => p.type === "reasoning-end")
expect(reasoningEnd).toMatchObject({
type: "reasoning-end",
id: "reasoning-0",
providerMetadata: {
copilot: {
reasoningOpaque: "ytGNWFf2doK38peANDvm7whkLPKrd+Fv6/k34zEPBF6Qwitj4bTZT0FBXleydLb6",
},
},
})
// No text parts should exist
const textParts = parts.filter((p) => p.type === "text-start" || p.type === "text-delta" || p.type === "text-end")
expect(textParts).toHaveLength(0)
// Check tool call
const toolCall = parts.find((p) => p.type === "tool-call")
expect(toolCall).toMatchObject({
type: "tool-call",
toolCallId: "call_MHw3RDhmT1J5Z3B6WlhpVjlveTc",
toolName: "project_eval",
})
// Check finish
const finish = parts.find((p) => p.type === "finish")
expect(finish).toMatchObject({
type: "finish",
finishReason: { unified: "tool-calls" },
})
})
test("should attach reasoning_opaque to tool calls without reasoning_text", async () => {
const mockFetch = createMockFetch(FIXTURES.reasoningOpaqueWithToolCallsNoReasoningText)
const model = createModel(mockFetch)
const { stream } = await model.doStream({
prompt: TEST_PROMPT,
includeRawChunks: false,
})
const parts = await convertReadableStreamToArray(stream)
const reasoningParts = parts.filter(
(p) => p.type === "reasoning-start" || p.type === "reasoning-delta" || p.type === "reasoning-end",
)
expect(reasoningParts).toHaveLength(0)
const toolCall = parts.find((p) => p.type === "tool-call" && p.toolCallId === "call_reasoning_only")
expect(toolCall).toMatchObject({
type: "tool-call",
toolCallId: "call_reasoning_only",
toolName: "read_file",
providerMetadata: {
copilot: {
reasoningOpaque: "opaque-xyz",
},
},
})
})
test("should include response metadata from first chunk", async () => {
const mockFetch = createMockFetch(FIXTURES.basicText)
const model = createModel(mockFetch)
const { stream } = await model.doStream({
prompt: TEST_PROMPT,
includeRawChunks: false,
})
const parts = await convertReadableStreamToArray(stream)
const metadata = parts.find((p) => p.type === "response-metadata")
expect(metadata).toMatchObject({
type: "response-metadata",
id: "chatcmpl-123",
modelId: "gemini-2.0-flash-001",
})
})
test("should emit stream-start with warnings", async () => {
const mockFetch = createMockFetch(FIXTURES.basicText)
const model = createModel(mockFetch)
const { stream } = await model.doStream({
prompt: TEST_PROMPT,
includeRawChunks: false,
})
const parts = await convertReadableStreamToArray(stream)
const streamStart = parts.find((p) => p.type === "stream-start")
expect(streamStart).toEqual({
type: "stream-start",
warnings: [],
})
})
test("should include raw chunks when requested", async () => {
const mockFetch = createMockFetch(FIXTURES.basicText)
const model = createModel(mockFetch)
const { stream } = await model.doStream({
prompt: TEST_PROMPT,
includeRawChunks: true,
})
const parts = await convertReadableStreamToArray(stream)
const rawChunks = parts.filter((p) => p.type === "raw")
expect(rawChunks.length).toBeGreaterThan(0)
})
})
describe("request body", () => {
test("should send tools in OpenAI format", async () => {
let capturedBody: unknown
const mockFetch = mock(async (_url: string, init?: RequestInit) => {
capturedBody = JSON.parse(init?.body as string)
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(`data: [DONE]\n\n`))
controller.close()
},
}),
{ status: 200, headers: { "Content-Type": "text/event-stream" } },
)
})
const model = createModel(mockFetch)
await model.doStream({
prompt: TEST_PROMPT,
tools: [
{
type: "function",
name: "get_weather",
description: "Get the weather for a location",
inputSchema: {
type: "object",
properties: {
location: { type: "string" },
},
required: ["location"],
},
},
],
includeRawChunks: false,
})
expect((capturedBody as { tools: unknown[] }).tools).toEqual([
{
type: "function",
function: {
name: "get_weather",
description: "Get the weather for a location",
parameters: {
type: "object",
properties: {
location: { type: "string" },
},
required: ["location"],
},
},
},
])
})
})

View file

@ -0,0 +1,188 @@
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 { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
import { fakeSelectorSdk, it, model } from "../v2/plugin/provider-helper"
describe("GithubCopilotPlugin", () => {
it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(GithubCopilotPlugin)
const ignored = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("github-copilot", "gpt-5"),
package: "@ai-sdk/openai-compatible",
options: { name: "github-copilot" },
},
{},
)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("github-copilot", "gpt-5"),
package: "@ai-sdk/github-copilot",
options: { name: "github-copilot" },
},
{},
)
expect(ignored.sdk).toBeUndefined()
expect(result.sdk).toBeDefined()
}),
)
it.effect("selects languageModel when responses and chat are absent", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* plugin.add(GithubCopilotPlugin)
yield* plugin.trigger(
"aisdk.language",
{
model: model("github-copilot", "claude-sonnet-4"),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {},
},
{},
)
expect(calls).toEqual(["languageModel:claude-sonnet-4"])
}),
)
it.effect("selects languageModel with the API model ID when responses and chat are absent", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* plugin.add(GithubCopilotPlugin)
yield* plugin.trigger(
"aisdk.language",
{
model: model("github-copilot", "alias", { apiID: ModelV2.ID.make("claude-sonnet-4") }),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {},
},
{},
)
expect(calls).toEqual(["languageModel:claude-sonnet-4"])
}),
)
it.effect("uses responses for gpt-5 models except gpt-5-mini", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* plugin.add(GithubCopilotPlugin)
yield* plugin.trigger(
"aisdk.language",
{ model: model("github-copilot", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} },
{},
)
yield* plugin.trigger(
"aisdk.language",
{ model: model("github-copilot", "gpt-5.1-codex"), sdk: fakeSelectorSdk(calls), options: {} },
{},
)
yield* plugin.trigger(
"aisdk.language",
{ model: model("github-copilot", "gpt-4o"), sdk: fakeSelectorSdk(calls), options: {} },
{},
)
yield* plugin.trigger(
"aisdk.language",
{ model: model("github-copilot", "gpt-5-mini"), sdk: fakeSelectorSdk(calls), options: {} },
{},
)
yield* plugin.trigger(
"aisdk.language",
{ model: model("github-copilot", "gpt-5-mini-2025-08-07"), sdk: fakeSelectorSdk(calls), options: {} },
{},
)
expect(calls).toEqual([
"responses:gpt-5",
"responses:gpt-5.1-codex",
"chat:gpt-4o",
"chat:gpt-5-mini",
"chat:gpt-5-mini-2025-08-07",
])
}),
)
it.effect("uses the API model ID when selecting responses or chat", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* plugin.add(GithubCopilotPlugin)
yield* plugin.trigger(
"aisdk.language",
{
model: model("github-copilot", "default", { apiID: ModelV2.ID.make("gpt-5") }),
sdk: fakeSelectorSdk(calls),
options: {},
},
{},
)
yield* plugin.trigger(
"aisdk.language",
{
model: model("github-copilot", "small", { apiID: ModelV2.ID.make("gpt-5-mini") }),
sdk: fakeSelectorSdk(calls),
options: {},
},
{},
)
yield* plugin.trigger(
"aisdk.language",
{
model: model("github-copilot", "sonnet", { apiID: ModelV2.ID.make("claude-sonnet-4") }),
sdk: fakeSelectorSdk(calls),
options: {},
},
{},
)
expect(calls).toEqual(["responses:gpt-5", "chat:gpt-5-mini", "chat:claude-sonnet-4"])
}),
)
it.effect("filters gpt-5-chat-latest before Copilot language selection", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(GithubCopilotPlugin)
const result = yield* plugin.trigger(
"model.update",
{},
{ model: model("github-copilot", "gpt-5-chat-latest"), cancel: false },
)
expect(result.cancel).toBe(true)
}),
)
it.effect("does not filter gpt-5-chat-latest for non-Copilot providers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(GithubCopilotPlugin)
const result = yield* plugin.trigger(
"model.update",
{},
{ model: model("custom-copilot", "gpt-5-chat-latest"), cancel: false },
)
expect(result.cancel).toBe(false)
}),
)
it.effect("ignores non-Copilot providers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* plugin.add(GithubCopilotPlugin)
const result = yield* plugin.trigger(
"aisdk.language",
{ model: model("openai", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} },
{},
)
expect(calls).toEqual([])
expect(result.language).toBeUndefined()
}),
)
})

View file

@ -0,0 +1,199 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
const it = testEffect(Catalog.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer)))
describe("CatalogV2", () => {
it.effect("normalizes provider baseURL into endpoint url", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
yield* catalog.provider.update(providerID, (provider) => {
provider.endpoint = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://default.example.com",
}
provider.options.aisdk.provider.baseURL = "https://override.example.com"
})
const provider = yield* catalog.provider.get(providerID)
expect(provider.endpoint).toEqual({
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://override.example.com",
})
expect(provider.options.aisdk.provider.baseURL).toBeUndefined()
}),
)
it.effect("normalizes model baseURL into endpoint url", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
yield* catalog.provider.update(providerID, (provider) => {
provider.endpoint = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://provider.example.com",
}
})
yield* catalog.model.update(providerID, modelID, (model) => {
model.endpoint = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://model.example.com",
}
model.options.aisdk.provider.baseURL = "https://override.example.com"
})
const model = yield* catalog.model.get(providerID, modelID)
expect(model.endpoint).toEqual({
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://override.example.com",
})
expect(model.options.aisdk.provider.baseURL).toBeUndefined()
}),
)
it.effect("resolves unknown model endpoint from provider endpoint", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
yield* catalog.provider.update(providerID, (provider) => {
provider.endpoint = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://provider.example.com",
}
})
yield* catalog.model.update(providerID, modelID, () => {})
const model = yield* catalog.model.get(providerID, modelID)
expect(model.endpoint).toEqual({
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://provider.example.com",
})
}),
)
it.effect("runs provider hooks after baseURL is normalized", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.make("test")
const seen: unknown[] = []
yield* plugin.add({
id: PluginV2.ID.make("test"),
effect: Effect.succeed({
"provider.update": (evt) =>
Effect.sync(() => {
seen.push(evt.provider.endpoint.type)
if (evt.provider.endpoint.type === "aisdk") seen.push(evt.provider.endpoint.url)
seen.push(evt.provider.options.aisdk.provider.baseURL)
}),
}),
})
yield* catalog.provider.update(providerID, (provider) => {
provider.endpoint = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
}
provider.options.aisdk.provider.baseURL = "https://provider.example.com"
})
expect(seen).toEqual(["aisdk", "https://provider.example.com", undefined])
}),
)
it.effect("resolves provider and model option merges", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
yield* catalog.provider.update(providerID, (provider) => {
provider.options.headers.provider = "provider"
provider.options.headers.shared = "provider"
provider.options.body.provider = true
provider.options.aisdk.provider.provider = true
})
yield* catalog.model.update(providerID, modelID, (model) => {
model.options.headers.model = "model"
model.options.headers.shared = "model"
model.options.body.model = true
model.options.aisdk.provider.model = true
model.options.aisdk.request.request = true
})
const model = yield* catalog.model.get(providerID, modelID)
expect(model.options.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
expect(model.options.body).toEqual({ provider: true, model: true })
expect(model.options.aisdk.provider).toEqual({ provider: true, model: true })
expect(model.options.aisdk.request).toEqual({ request: true })
}),
)
it.effect("falls back to newest available model when no default is configured", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
yield* catalog.provider.update(providerID, (provider) => {
provider.enabled = { via: "custom", data: {} }
})
yield* catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => {
model.time.released = DateTime.makeUnsafe(1000)
})
yield* catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => {
model.time.released = DateTime.makeUnsafe(2000)
})
const model = yield* catalog.model.default()
expect(Option.getOrUndefined(model)?.id).toMatch("new")
}),
)
it.effect("small model prefers small keyword candidates before cost scoring", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
yield* catalog.provider.update(providerID, () => {})
yield* catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
model.capabilities.input = ["text"]
model.capabilities.output = ["text"]
model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }]
model.time.released = DateTime.makeUnsafe(Date.now())
})
yield* catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => {
model.capabilities.input = ["text"]
model.capabilities.output = ["text"]
model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }]
model.time.released = DateTime.makeUnsafe(Date.now())
})
const model = yield* catalog.model.small(providerID)
expect(Option.getOrUndefined(model)?.id).toMatch("expensive-mini")
}),
)
})

View file

@ -0,0 +1,9 @@
export function createFixtureProvider(options: Record<string, unknown>) {
const captured = Object.fromEntries(Object.entries(options))
return Object.assign((modelID: string) => ({ modelID, options: captured }), {
options: captured,
languageModel(modelID: string) {
return { modelID, options: captured }
},
})
}

View file

@ -0,0 +1,67 @@
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 { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba"
import { it, model } from "./provider-helper"
describe("AlibabaPlugin", () => {
it.effect("creates an Alibaba SDK for @ai-sdk/alibaba", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(AlibabaPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("alibaba", "qwen"), 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
yield* plugin.add(AlibabaPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("alibaba", "qwen"), 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
yield* plugin.add(AlibabaPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-alibaba", "qwen"),
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 old default languageModel(apiID) behavior", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(AlibabaPlugin)
const item = model("alibaba", "alias", { apiID: ModelV2.ID.make("qwen-plus") })
const result = yield* plugin.trigger("aisdk.sdk", { model: item, package: "@ai-sdk/alibaba", options: {} }, {})
const language = result.sdk?.languageModel(item.apiID)
expect(language?.modelId).toBe("qwen-plus")
expect(language?.provider).toBe("alibaba.chat")
}),
)
})

View file

@ -0,0 +1,464 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock"
import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper"
function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") {
const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID)
return (language as { config: { baseUrl: () => string } }).config.baseUrl()
}
function bedrockFetch(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") {
const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID)
return (language as { config: { fetch: (input: Parameters<typeof fetch>[0], init?: RequestInit) => Promise<Response> } }).config
.fetch
}
describe("AmazonBedrockPlugin", () => {
it.effect("moves endpoint option to endpoint URL", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(AmazonBedrockPlugin)
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("amazon-bedrock", {
options: {
headers: {},
body: {},
aisdk: { provider: { endpoint: "https://bedrock.example" }, request: {} },
},
}),
cancel: false,
},
)
expect(result.provider.endpoint).toEqual({
type: "aisdk",
package: "test-provider",
url: "https://bedrock.example",
})
expect(result.provider.options.aisdk.provider.endpoint).toBeUndefined()
}),
)
it.effect("prefers endpoint over baseURL for SDK base URL", () =>
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(AmazonBedrockPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
package: "@ai-sdk/amazon-bedrock",
options: {
name: "amazon-bedrock",
bearerToken: "token",
baseURL: "https://base.example",
endpoint: "https://endpoint.example",
region: "us-east-1",
},
},
{},
)
expect(bedrockBaseURL(result.sdk)).toBe("https://endpoint.example")
}),
),
)
it.effect("uses baseURL as SDK base URL", () =>
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(AmazonBedrockPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
package: "@ai-sdk/amazon-bedrock",
options: {
name: "amazon-bedrock",
bearerToken: "token",
baseURL: "https://base.example",
region: "us-east-1",
},
},
{},
)
expect(bedrockBaseURL(result.sdk)).toBe("https://base.example")
}),
),
)
it.effect("creates SDK without explicit credential env so the default AWS chain can resolve credentials", () =>
withEnv(
{
AWS_ACCESS_KEY_ID: undefined,
AWS_BEARER_TOKEN_BEDROCK: undefined,
AWS_CONTAINER_CREDENTIALS_FULL_URI: undefined,
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: undefined,
AWS_PROFILE: undefined,
AWS_REGION: undefined,
AWS_WEB_IDENTITY_TOKEN_FILE: undefined,
},
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(AmazonBedrockPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
package: "@ai-sdk/amazon-bedrock",
options: { name: "amazon-bedrock" },
},
{},
)
expect(result.sdk).toBeDefined()
expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.us-east-1.amazonaws.com")
}),
),
)
it.effect("uses config region over AWS_REGION for SDK base URL", () =>
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "us-east-1" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(AmazonBedrockPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
package: "@ai-sdk/amazon-bedrock",
options: { name: "amazon-bedrock", region: "eu-west-1" },
},
{},
)
expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.eu-west-1.amazonaws.com")
}),
),
)
it.effect("uses AWS_REGION for SDK base URL when config region is absent", () =>
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "eu-west-1" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(AmazonBedrockPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
package: "@ai-sdk/amazon-bedrock",
options: { name: "amazon-bedrock" },
},
{},
)
expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.eu-west-1.amazonaws.com")
}),
),
)
it.effect("defaults SDK region to us-east-1", () =>
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(AmazonBedrockPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
package: "@ai-sdk/amazon-bedrock",
options: { name: "amazon-bedrock" },
},
{},
)
expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.us-east-1.amazonaws.com")
}),
),
)
it.effect("loads bearer token option into env and uses bearer auth", () =>
withEnv({ AWS_ACCESS_KEY_ID: undefined, AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const headers: Array<string | null> = []
yield* plugin.add(AmazonBedrockPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
package: "@ai-sdk/amazon-bedrock",
options: {
name: "amazon-bedrock",
bearerToken: "option-token",
fetch: async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
headers.push(new Headers(init?.headers).get("Authorization"))
return new Response("{}")
},
},
},
{},
)
yield* Effect.promise(() => bedrockFetch(result.sdk)("https://bedrock.example", { method: "POST" }))
expect(process.env.AWS_BEARER_TOKEN_BEDROCK).toBe("option-token")
expect(headers).toEqual(["Bearer option-token"])
}),
),
)
it.effect("prefers bearer token env over bearer token option", () =>
withEnv({ AWS_BEARER_TOKEN_BEDROCK: "env-token" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const headers: Array<string | null> = []
yield* plugin.add(AmazonBedrockPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
package: "@ai-sdk/amazon-bedrock",
options: {
name: "amazon-bedrock",
bearerToken: "option-token",
fetch: async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
headers.push(new Headers(init?.headers).get("Authorization"))
return new Response("{}")
},
},
},
{},
)
yield* Effect.promise(() => bedrockFetch(result.sdk)("https://bedrock.example", { method: "POST" }))
expect(process.env.AWS_BEARER_TOKEN_BEDROCK).toBe("env-token")
expect(headers).toEqual(["Bearer env-token"])
}),
),
)
it.effect("uses SigV4 credential env when bearer token is absent", () =>
withEnv(
{
AWS_ACCESS_KEY_ID: "test-access-key",
AWS_BEARER_TOKEN_BEDROCK: undefined,
AWS_REGION: "us-east-1",
AWS_SECRET_ACCESS_KEY: "test-secret-key",
AWS_SESSION_TOKEN: "test-session-token",
},
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const headers: Array<string | null> = []
yield* plugin.add(AmazonBedrockPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
package: "@ai-sdk/amazon-bedrock",
options: {
name: "amazon-bedrock",
fetch: async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
headers.push(new Headers(init?.headers).get("Authorization"))
return new Response("{}")
},
},
},
{},
)
yield* Effect.promise(() =>
bedrockFetch(result.sdk)("https://bedrock-runtime.us-east-1.amazonaws.com/model/test/invoke", {
body: "{}",
method: "POST",
}),
)
expect(headers[0]?.startsWith("AWS4-HMAC-SHA256 ")).toBe(true)
}),
),
)
it.effect("applies legacy cross-region inference prefixes", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* plugin.add(AmazonBedrockPlugin)
yield* plugin.trigger(
"aisdk.language",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {},
},
{},
)
yield* plugin.trigger(
"aisdk.language",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: "eu-west-1" },
},
{},
)
yield* plugin.trigger(
"aisdk.language",
{
model: model("amazon-bedrock", "global.anthropic.claude-sonnet-4-5"),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: "eu-west-1" },
},
{},
)
yield* plugin.trigger(
"aisdk.language",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: "ap-northeast-1" },
},
{},
)
yield* plugin.trigger(
"aisdk.language",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: "ap-southeast-2" },
},
{},
)
expect(calls).toEqual([
"languageModel:us.anthropic.claude-sonnet-4-5",
"languageModel:eu.anthropic.claude-sonnet-4-5",
"languageModel:global.anthropic.claude-sonnet-4-5",
"languageModel:jp.anthropic.claude-sonnet-4-5",
"languageModel:au.anthropic.claude-sonnet-4-5",
])
}),
)
it.effect("uses AWS_REGION for language prefixes when region option is absent", () =>
withEnv({ AWS_REGION: "eu-west-1" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* plugin.add(AmazonBedrockPlugin)
yield* plugin.trigger(
"aisdk.language",
{
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {},
},
{},
)
expect(calls).toEqual(["languageModel:eu.anthropic.claude-sonnet-4-5"])
}),
),
)
it.effect("applies the full legacy cross-region prefix matrix", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
const cases = [
{ region: "us-east-1", modelID: "amazon.nova-micro-v1:0", expected: "us.amazon.nova-micro-v1:0" },
{ region: "us-east-1", modelID: "amazon.nova-lite-v1:0", expected: "us.amazon.nova-lite-v1:0" },
{ region: "us-east-1", modelID: "amazon.nova-pro-v1:0", expected: "us.amazon.nova-pro-v1:0" },
{ region: "us-east-1", modelID: "amazon.nova-premier-v1:0", expected: "us.amazon.nova-premier-v1:0" },
{ region: "us-east-1", modelID: "amazon.nova-2-lite-v1:0", expected: "us.amazon.nova-2-lite-v1:0" },
{ region: "us-east-1", modelID: "anthropic.claude-sonnet-4-5", expected: "us.anthropic.claude-sonnet-4-5" },
{ region: "us-east-1", modelID: "deepseek.r1-v1:0", expected: "us.deepseek.r1-v1:0" },
{ region: "us-gov-west-1", modelID: "anthropic.claude-sonnet-4-5", expected: "anthropic.claude-sonnet-4-5" },
{ region: "us-east-1", modelID: "cohere.command-r-plus-v1:0", expected: "cohere.command-r-plus-v1:0" },
{ region: "eu-west-1", modelID: "anthropic.claude-sonnet-4-5", expected: "eu.anthropic.claude-sonnet-4-5" },
{ region: "eu-west-2", modelID: "amazon.nova-lite-v1:0", expected: "eu.amazon.nova-lite-v1:0" },
{ region: "eu-west-3", modelID: "amazon.nova-micro-v1:0", expected: "eu.amazon.nova-micro-v1:0" },
{
region: "eu-north-1",
modelID: "meta.llama3-70b-instruct-v1:0",
expected: "eu.meta.llama3-70b-instruct-v1:0",
},
{ region: "eu-central-1", modelID: "mistral.pixtral-large-v1:0", expected: "eu.mistral.pixtral-large-v1:0" },
{ region: "eu-south-1", modelID: "anthropic.claude-sonnet-4-5", expected: "eu.anthropic.claude-sonnet-4-5" },
{ region: "eu-south-2", modelID: "anthropic.claude-sonnet-4-5", expected: "eu.anthropic.claude-sonnet-4-5" },
{ region: "eu-central-2", modelID: "anthropic.claude-sonnet-4-5", expected: "anthropic.claude-sonnet-4-5" },
{ region: "eu-west-1", modelID: "cohere.command-r-plus-v1:0", expected: "cohere.command-r-plus-v1:0" },
{
region: "ap-southeast-2",
modelID: "anthropic.claude-sonnet-4-5",
expected: "au.anthropic.claude-sonnet-4-5",
},
{
region: "ap-southeast-4",
modelID: "anthropic.claude-haiku-v1:0",
expected: "au.anthropic.claude-haiku-v1:0",
},
{ region: "ap-southeast-2", modelID: "anthropic.claude-opus-4", expected: "apac.anthropic.claude-opus-4" },
{
region: "ap-northeast-1",
modelID: "anthropic.claude-sonnet-4-5",
expected: "jp.anthropic.claude-sonnet-4-5",
},
{ region: "ap-northeast-1", modelID: "amazon.nova-pro-v1:0", expected: "jp.amazon.nova-pro-v1:0" },
{ region: "ap-south-1", modelID: "anthropic.claude-sonnet-4-5", expected: "apac.anthropic.claude-sonnet-4-5" },
{ region: "ap-south-1", modelID: "amazon.nova-lite-v1:0", expected: "apac.amazon.nova-lite-v1:0" },
{ region: "ca-central-1", modelID: "anthropic.claude-sonnet-4-5", expected: "anthropic.claude-sonnet-4-5" },
{
region: "us-east-1",
modelID: "global.anthropic.claude-sonnet-4-5",
expected: "global.anthropic.claude-sonnet-4-5",
},
{ region: "us-east-1", modelID: "us.anthropic.claude-sonnet-4-5", expected: "us.anthropic.claude-sonnet-4-5" },
{ region: "eu-west-1", modelID: "eu.anthropic.claude-sonnet-4-5", expected: "eu.anthropic.claude-sonnet-4-5" },
{
region: "ap-northeast-1",
modelID: "jp.anthropic.claude-sonnet-4-5",
expected: "jp.anthropic.claude-sonnet-4-5",
},
{
region: "ap-south-1",
modelID: "apac.anthropic.claude-sonnet-4-5",
expected: "apac.anthropic.claude-sonnet-4-5",
},
{
region: "ap-southeast-2",
modelID: "au.anthropic.claude-sonnet-4-5",
expected: "au.anthropic.claude-sonnet-4-5",
},
]
yield* plugin.add(AmazonBedrockPlugin)
for (const item of cases) {
yield* plugin.trigger(
"aisdk.language",
{
model: model("amazon-bedrock", item.modelID),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: item.region },
},
{},
)
}
expect(calls).toEqual(cases.map((item) => `languageModel:${item.expected}`))
}),
)
it.effect("ignores non-Bedrock providers for language selection", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* plugin.add(AmazonBedrockPlugin)
const result = yield* plugin.trigger(
"aisdk.language",
{
model: model("openai", "anthropic.claude-sonnet-4-5"),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: { region: "eu-west-1" },
},
{},
)
expect(calls).toEqual([])
expect(result.language).toBeUndefined()
}),
)
})

View file

@ -0,0 +1,91 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AnthropicPlugin } from "@opencode-ai/core/plugin/provider/anthropic"
import { it, model, provider } from "./provider-helper"
describe("AnthropicPlugin", () => {
it.effect("applies legacy beta headers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(AnthropicPlugin)
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("anthropic", {
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
}),
cancel: false,
},
)
expect(result.provider.options.headers["anthropic-beta"]).toBe(
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
)
expect(result.provider.options.headers.Existing).toBe("1")
}),
)
it.effect("ignores non-Anthropic providers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(AnthropicPlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false })
expect(result.provider.options.headers["anthropic-beta"]).toBeUndefined()
}),
)
it.effect("creates Anthropic SDKs with the model provider ID as the SDK name", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const providers: string[] = []
yield* plugin.add(AnthropicPlugin)
yield* plugin.add({
id: PluginV2.ID.make("anthropic-sdk-inspector"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.sync(() => {
providers.push(evt.sdk.languageModel("claude-sonnet-4-5").provider)
}),
}),
})
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-anthropic", "claude-sonnet-4-5"),
package: "@ai-sdk/anthropic",
options: { name: "custom-anthropic", apiKey: "test" },
},
{},
)
expect(providers).toEqual(["custom-anthropic"])
}),
)
it.effect("uses the Anthropic provider ID as the SDK name for the bundled Anthropic provider", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const providers: string[] = []
yield* plugin.add(AnthropicPlugin)
yield* plugin.add({
id: PluginV2.ID.make("anthropic-sdk-inspector"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.sync(() => {
providers.push(evt.sdk.languageModel("claude-sonnet-4-5").provider)
}),
}),
})
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("anthropic", "claude-sonnet-4-5"),
package: "@ai-sdk/anthropic",
options: { name: "anthropic", apiKey: "test" },
},
{},
)
expect(providers).toEqual(["anthropic"])
}),
)
})

View file

@ -0,0 +1,127 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure"
import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper"
describe("AzureCognitiveServicesPlugin", () => {
it.effect("maps the resource env var to the Azure SDK baseURL", () =>
withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "cognitive" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(AzureCognitiveServicesPlugin)
const result = yield* plugin.trigger(
"provider.update",
{},
{ provider: provider("azure-cognitive-services"), cancel: false },
)
expect(result.provider.endpoint).toEqual({
type: "aisdk",
package: "test-provider",
})
expect(result.provider.options.aisdk.provider.baseURL).toBe(
"https://cognitive.cognitiveservices.azure.com/openai",
)
expect(result.provider.options.aisdk.provider.resourceName).toBeUndefined()
}),
),
)
it.effect("leaves baseURL unset without resource env and ignores other providers", () =>
withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(AzureCognitiveServicesPlugin)
const azure = yield* plugin.trigger(
"provider.update",
{},
{ provider: provider("azure-cognitive-services"), cancel: false },
)
const other = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false })
expect(azure.provider.options.aisdk.provider.baseURL).toBeUndefined()
expect(azure.provider.endpoint).toEqual({ type: "aisdk", package: "test-provider" })
expect(other.provider.options.aisdk.provider.baseURL).toBeUndefined()
expect(other.provider.endpoint).toEqual({ type: "aisdk", package: "test-provider" })
}),
),
)
it.effect("selects chat only for completion URLs", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* plugin.add(AzureCognitiveServicesPlugin)
yield* plugin.trigger(
"aisdk.language",
{
model: model("azure-cognitive-services", "deployment"),
sdk: fakeSelectorSdk(calls),
options: { useCompletionUrls: true },
},
{},
)
expect(calls).toEqual(["chat:deployment"])
}),
)
it.effect("uses the legacy Azure selector order and provider guard", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* plugin.add(AzureCognitiveServicesPlugin)
yield* plugin.trigger(
"aisdk.language",
{ model: model("azure-cognitive-services", "deployment"), sdk: fakeSelectorSdk(calls), options: {} },
{},
)
const ignored = yield* plugin.trigger(
"aisdk.language",
{ model: model("openai", "deployment"), sdk: fakeSelectorSdk(calls), options: {} },
{},
)
expect(calls).toEqual(["responses:deployment"])
expect(ignored.language).toBeUndefined()
}),
)
it.effect("falls back from responses to messages, chat, then languageModel", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
const sdk = fakeSelectorSdk(calls)
yield* plugin.add(AzureCognitiveServicesPlugin)
yield* plugin.trigger(
"aisdk.language",
{
model: model("azure-cognitive-services", "messages-deployment"),
sdk: { messages: sdk.messages, chat: sdk.chat, languageModel: sdk.languageModel },
options: {},
},
{},
)
yield* plugin.trigger(
"aisdk.language",
{
model: model("azure-cognitive-services", "chat-deployment"),
sdk: { chat: sdk.chat, languageModel: sdk.languageModel },
options: {},
},
{},
)
yield* plugin.trigger(
"aisdk.language",
{
model: model("azure-cognitive-services", "language-deployment"),
sdk: { languageModel: sdk.languageModel },
options: {},
},
{},
)
expect(calls).toEqual([
"messages:messages-deployment",
"chat:chat-deployment",
"languageModel:language-deployment",
])
}),
)
})

View file

@ -0,0 +1,245 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AuthV2 } from "@opencode-ai/core/auth"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
import { testEffect } from "../../lib/effect"
import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer))
describe("AzurePlugin", () => {
it.effect("resolves resourceName from env", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(AzurePlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("azure"), cancel: false })
expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env")
}),
),
)
it.effect("keeps explicit resourceName over env and ignores other providers", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(AzurePlugin)
const azure = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("azure", {
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "from-config" }, request: {} } },
}),
cancel: false,
},
)
const other = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false })
expect(azure.provider.options.aisdk.provider.resourceName).toBe("from-config")
expect(other.provider.options.aisdk.provider.resourceName).toBeUndefined()
}),
),
)
itWithAuth.effect("prefers auth resourceName over env", () =>
withEnv(
{
AZURE_RESOURCE_NAME: "from-env",
},
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const auth = yield* AuthV2.Service
yield* auth.create({
serviceID: AuthV2.ServiceID.make("azure"),
credential: new AuthV2.ApiKeyCredential({
type: "api",
key: "key",
metadata: { resourceName: "from-auth" },
}),
active: true,
})
yield* plugin.add({
...AuthPlugin,
effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)),
})
yield* plugin.add(AzurePlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("azure"), cancel: false })
expect(result.provider.options.aisdk.provider.resourceName).toBe("from-auth")
}),
),
)
it.effect("falls back to env when configured resourceName is blank", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(AzurePlugin)
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("azure", {
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "" }, request: {} } },
}),
cancel: false,
},
)
expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env")
}),
),
)
it.effect("falls back to env when configured resourceName is whitespace", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(AzurePlugin)
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("azure", {
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: " " }, request: {} } },
}),
cancel: false,
},
)
expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env")
}),
),
)
it.effect("allows configured baseURL without resourceName", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(AzurePlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("azure", "deployment"),
package: "@ai-sdk/azure",
options: { name: "azure", baseURL: "https://proxy.example.com/openai" },
},
{},
)
expect(result.sdk).toBeDefined()
}),
),
)
it.effect("rejects missing resourceName when baseURL is not configured", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(AzurePlugin)
const exit = yield* plugin
.trigger(
"aisdk.sdk",
{ model: model("azure", "deployment"), package: "@ai-sdk/azure", options: { name: "azure" } },
{},
)
.pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
}),
),
)
it.effect("selects chat only for completion URLs", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* plugin.add(AzurePlugin)
yield* plugin.trigger(
"aisdk.language",
{ model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } },
{},
)
expect(calls).toEqual(["chat:deployment"])
}),
)
it.effect("selects chat from per-call useCompletionUrls", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* plugin.add(AzurePlugin)
yield* plugin.trigger(
"aisdk.language",
{ model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } },
{},
)
expect(calls).toEqual(["chat:deployment"])
}),
)
it.effect("ignores model useCompletionUrls when per-call option is unset", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* plugin.add(AzurePlugin)
yield* plugin.trigger(
"aisdk.language",
{
model: model("azure", "deployment", {
options: { headers: {}, body: {}, aisdk: { provider: {}, request: { useCompletionUrls: true } } },
}),
sdk: fakeSelectorSdk(calls),
options: {},
},
{},
)
expect(calls).toEqual(["responses:deployment"])
}),
)
it.effect("uses the legacy Azure selector order and provider guard", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* plugin.add(AzurePlugin)
yield* plugin.trigger(
"aisdk.language",
{ model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: {} },
{},
)
const ignored = yield* plugin.trigger(
"aisdk.language",
{ model: model("openai", "deployment"), sdk: fakeSelectorSdk(calls), options: {} },
{},
)
expect(calls).toEqual(["responses:deployment"])
expect(ignored.language).toBeUndefined()
}),
)
it.effect("falls back through the legacy Azure selector order", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
return { modelId: id, provider: method, specificationVersion: "v3" }
}
yield* plugin.add(AzurePlugin)
yield* plugin.trigger(
"aisdk.language",
{
model: model("azure", "messages-deployment"),
sdk: { messages: make("messages"), chat: make("chat"), languageModel: make("languageModel") },
options: {},
},
{},
)
yield* plugin.trigger(
"aisdk.language",
{ model: model("azure", "language-deployment"), sdk: { languageModel: make("languageModel") }, options: {} },
{},
)
expect(calls).toEqual(["messages:messages-deployment", "languageModel:language-deployment"])
}),
)
})

View file

@ -0,0 +1,102 @@
import { describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras"
import { it, model, provider } from "./provider-helper"
const cerebrasOptions: Record<string, unknown>[] = []
void mock.module("@ai-sdk/cerebras", () => ({
createCerebras: (options: Record<string, unknown>) => {
const snapshot = { ...options }
cerebrasOptions.push(snapshot)
return {
languageModel: (modelID: string) => ({ modelID, provider: snapshot.name, specificationVersion: "v3" }),
}
},
}))
describe("CerebrasPlugin", () => {
it.effect("applies the legacy integration header", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(CerebrasPlugin)
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("cerebras", {
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
}),
cancel: false,
},
)
expect(result.provider.options.headers).toEqual({ Existing: "1", "X-Cerebras-3rd-Party-Integration": "opencode" })
}),
)
it.effect("ignores non-Cerebras providers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(CerebrasPlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("groq"), cancel: false })
expect(result.provider.options.headers).toEqual({})
}),
)
it.effect("creates a bundled Cerebras SDK with the model provider ID as the SDK name", () =>
Effect.gen(function* () {
cerebrasOptions.length = 0
const plugin = yield* PluginV2.Service
yield* plugin.add(CerebrasPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"),
package: "@ai-sdk/cerebras",
options: { name: "custom-cerebras", apiKey: "test" },
},
{},
)
expect(cerebrasOptions).toEqual([{ name: "custom-cerebras", apiKey: "test" }])
expect(result.sdk.languageModel("llama-4-scout-17b-16e-instruct").provider).toBe("custom-cerebras")
}),
)
it.effect("preserves an explicit bundled Cerebras SDK name option", () =>
Effect.gen(function* () {
cerebrasOptions.length = 0
const plugin = yield* PluginV2.Service
yield* plugin.add(CerebrasPlugin)
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"),
package: "@ai-sdk/cerebras",
options: { name: "configured-cerebras", apiKey: "test" },
},
{},
)
expect(cerebrasOptions).toEqual([{ name: "configured-cerebras", apiKey: "test" }])
}),
)
it.effect("ignores non-Cerebras SDK packages", () =>
Effect.gen(function* () {
cerebrasOptions.length = 0
const plugin = yield* PluginV2.Service
yield* plugin.add(CerebrasPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"),
package: "@ai-sdk/groq",
options: { name: "custom-cerebras", apiKey: "test" },
},
{},
)
expect(cerebrasOptions).toEqual([])
expect(result.sdk).toBeUndefined()
}),
)
})

View file

@ -0,0 +1,384 @@
import { describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway"
import { it, model, withEnv } from "./provider-helper"
const aiGatewayCalls: Record<string, unknown>[] = []
const unifiedCalls: string[] = []
const gatewayModelCalls: unknown[] = []
function captureAiGatewayOptions(options: Record<string, unknown>) {
const nested =
options.options && typeof options.options === "object" ? (options.options as Record<string, unknown>) : undefined
return {
...options,
...(nested
? {
options: {
...nested,
headers:
nested.headers && typeof nested.headers === "object"
? { ...(nested.headers as Record<string, unknown>) }
: nested.headers,
},
}
: {}),
}
}
function resetCalls() {
aiGatewayCalls.length = 0
unifiedCalls.length = 0
gatewayModelCalls.length = 0
}
function cloudflareEnv(overrides: Record<string, string | undefined> = {}) {
return {
CLOUDFLARE_ACCOUNT_ID: "env-account",
CLOUDFLARE_GATEWAY_ID: "env-gateway",
CLOUDFLARE_API_TOKEN: "env-token",
CF_AIG_TOKEN: undefined,
...overrides,
}
}
mock.module("ai-gateway-provider", () => ({
createAiGateway(options: Record<string, unknown>) {
aiGatewayCalls.push(captureAiGatewayOptions(options))
return (input: unknown) => {
gatewayModelCalls.push(input)
return {
modelId: input,
provider: "cloudflare-ai-gateway",
specificationVersion: "v3",
}
}
},
}))
mock.module("ai-gateway-provider/providers/unified", () => ({
createUnified() {
return (modelID: string) => {
unifiedCalls.push(modelID)
return { unifiedModelID: modelID }
}
},
}))
describe("CloudflareAIGatewayPlugin", () => {
it.effect("requires account, gateway, and token before creating the unified SDK", () =>
withEnv(
{
CLOUDFLARE_ACCOUNT_ID: "acct",
CLOUDFLARE_GATEWAY_ID: "gateway",
CLOUDFLARE_API_TOKEN: "token",
CF_AIG_TOKEN: undefined,
},
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(CloudflareAIGatewayPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
package: "ai-gateway-provider",
options: { name: "cloudflare-ai-gateway" },
},
{},
)
expect(result.sdk.languageModel("openai/gpt-5")).toBeDefined()
}),
),
)
it.effect("passes legacy metadata, cache, log, and User-Agent values under the AI Gateway options key", () =>
withEnv(cloudflareEnv(), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* plugin.add(CloudflareAIGatewayPlugin)
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
package: "ai-gateway-provider",
options: {
name: "cloudflare-ai-gateway",
metadata: { invoked_by: "test", project: "opencode" },
cacheTtl: 300,
cacheKey: "cache-key",
skipCache: true,
collectLog: false,
},
},
{},
)
expect(aiGatewayCalls).toHaveLength(1)
expect(aiGatewayCalls[0]).toEqual({
accountId: "env-account",
gateway: "env-gateway",
apiKey: "env-token",
options: {
metadata: { invoked_by: "test", project: "opencode" },
cacheTtl: 300,
cacheKey: "cache-key",
skipCache: true,
collectLog: false,
headers: {
"User-Agent": expect.stringContaining("opencode/"),
},
},
})
}),
),
)
it.effect("parses legacy cf-aig-metadata header when metadata option is absent", () =>
withEnv(cloudflareEnv(), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* plugin.add(CloudflareAIGatewayPlugin)
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
package: "ai-gateway-provider",
options: {
name: "cloudflare-ai-gateway",
headers: {
"cf-aig-metadata": JSON.stringify({ invoked_by: "header", project: "opencode" }),
},
},
},
{},
)
expect(aiGatewayCalls[0]?.options).toMatchObject({
metadata: { invoked_by: "header", project: "opencode" },
})
}),
),
)
it.effect("prefers Cloudflare env values over auth/config-derived options", () =>
withEnv(cloudflareEnv(), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* plugin.add(CloudflareAIGatewayPlugin)
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
package: "ai-gateway-provider",
options: {
name: "cloudflare-ai-gateway",
accountId: "auth-account",
gateway: "auth-gateway",
apiKey: "auth-token",
},
},
{},
)
expect(aiGatewayCalls[0]).toMatchObject({
accountId: "env-account",
gateway: "env-gateway",
apiKey: "env-token",
})
}),
),
)
it.effect("accepts gatewayId metadata copied from auth into provider options", () =>
withEnv(
cloudflareEnv({
CLOUDFLARE_ACCOUNT_ID: undefined,
CLOUDFLARE_GATEWAY_ID: undefined,
CLOUDFLARE_API_TOKEN: undefined,
}),
() =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* plugin.add(CloudflareAIGatewayPlugin)
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
package: "ai-gateway-provider",
options: {
name: "cloudflare-ai-gateway",
accountId: "auth-account",
gatewayId: "auth-gateway",
apiKey: "auth-token",
},
},
{},
)
expect(aiGatewayCalls[0]).toMatchObject({
accountId: "auth-account",
gateway: "auth-gateway",
apiKey: "auth-token",
})
}),
),
)
it.effect("falls back to CF_AIG_TOKEN when CLOUDFLARE_API_TOKEN is unset", () =>
withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: "cf-aig-token" }), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* plugin.add(CloudflareAIGatewayPlugin)
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
package: "ai-gateway-provider",
options: { name: "cloudflare-ai-gateway" },
},
{},
)
expect(aiGatewayCalls[0]).toMatchObject({ apiKey: "cf-aig-token" })
}),
),
)
it.effect("does not create an SDK when account and gateway IDs are missing", () =>
withEnv(cloudflareEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined }), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* plugin.add(CloudflareAIGatewayPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
package: "ai-gateway-provider",
options: { name: "cloudflare-ai-gateway" },
},
{},
)
expect(result.sdk).toBeUndefined()
expect(aiGatewayCalls).toHaveLength(0)
}),
),
)
it.effect("does not create an SDK when the token is missing", () =>
withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: undefined }), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* plugin.add(CloudflareAIGatewayPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
package: "ai-gateway-provider",
options: { name: "cloudflare-ai-gateway" },
},
{},
)
expect(result.sdk).toBeUndefined()
expect(aiGatewayCalls).toHaveLength(0)
}),
),
)
it.effect("does not replace a configured baseURL with the Cloudflare AI Gateway SDK", () =>
withEnv(
cloudflareEnv({
CLOUDFLARE_ACCOUNT_ID: undefined,
CLOUDFLARE_GATEWAY_ID: undefined,
CLOUDFLARE_API_TOKEN: undefined,
}),
() =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* plugin.add(CloudflareAIGatewayPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
package: "ai-gateway-provider",
options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" },
},
{},
)
expect(result.sdk).toBeUndefined()
expect(aiGatewayCalls).toHaveLength(0)
}),
),
)
it.effect("maps provider/model IDs through the unified Cloudflare provider unchanged", () =>
withEnv(cloudflareEnv(), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* plugin.add(CloudflareAIGatewayPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-ai-gateway", "anthropic/claude-sonnet-4-5"),
package: "ai-gateway-provider",
options: { name: "cloudflare-ai-gateway" },
},
{},
)
expect(result.sdk.languageModel("anthropic/claude-sonnet-4-5")).toEqual({
modelId: { unifiedModelID: "anthropic/claude-sonnet-4-5" },
provider: "cloudflare-ai-gateway",
specificationVersion: "v3",
})
expect(unifiedCalls).toEqual(["anthropic/claude-sonnet-4-5"])
expect(gatewayModelCalls).toEqual([{ unifiedModelID: "anthropic/claude-sonnet-4-5" }])
}),
),
)
it.effect("ignores non Cloudflare AI Gateway packages", () =>
withEnv(cloudflareEnv(), () =>
Effect.gen(function* () {
resetCalls()
const plugin = yield* PluginV2.Service
yield* plugin.add(CloudflareAIGatewayPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-ai-gateway", "openai/gpt-5"),
package: "@ai-sdk/openai-compatible",
options: { name: "cloudflare-ai-gateway" },
},
{},
)
expect(result.sdk).toBeUndefined()
expect(aiGatewayCalls).toHaveLength(0)
}),
),
)
})

View file

@ -0,0 +1,267 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AuthV2 } from "@opencode-ai/core/auth"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
import { testEffect } from "../../lib/effect"
import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer))
function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") {
return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel(
modelID,
)
}
type CloudflareConfig = {
url: (input: { path: string; modelId: string }) => string
headers: () => Record<string, string> | Promise<Record<string, string>>
}
function cloudflareURL(sdk: unknown, modelID = "@cf/model") {
return cloudflareLanguage(sdk, modelID).config.url({ path: "/chat/completions", modelId: modelID })
}
function cloudflareHeaders(sdk: unknown, modelID = "@cf/model") {
return cloudflareLanguage(sdk, modelID).config.headers()
}
describe("CloudflareWorkersAIPlugin", () => {
it.effect("maps account ID to endpoint URL and creates an OpenAI-compatible SDK", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(CloudflareWorkersAIPlugin)
const updated = yield* plugin.trigger(
"provider.update",
{},
{ provider: provider("cloudflare-workers-ai"), cancel: false },
)
const sdk = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-workers-ai", "@cf/model", { endpoint: updated.provider.endpoint }),
package: "@ai-sdk/openai-compatible",
options: { name: "cloudflare-workers-ai", headers: { custom: "header" } },
},
{},
)
expect(updated.provider.endpoint).toEqual({
type: "aisdk",
package: "test-provider",
url: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1",
})
expect(sdk.sdk).toBeDefined()
}),
),
)
it.effect("preserves a configured endpoint URL instead of deriving one from account ID", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(CloudflareWorkersAIPlugin)
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("cloudflare-workers-ai", {
endpoint: { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" },
}),
cancel: false,
},
)
expect(result.provider.endpoint).toEqual({
type: "aisdk",
package: "test-provider",
url: "https://proxy.example/v1",
})
}),
),
)
it.effect("allows a configured baseURL without account ID", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(CloudflareWorkersAIPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-workers-ai", "@cf/model", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" },
},
{},
)
expect(cloudflareURL(result.sdk)).toBe("https://proxy.example/v1/chat/completions")
}),
),
)
itWithAuth.effect("falls back to auth account metadata when account env is absent", () =>
withEnv(
{
CLOUDFLARE_ACCOUNT_ID: undefined,
CLOUDFLARE_API_KEY: undefined,
},
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const auth = yield* AuthV2.Service
yield* auth.create({
serviceID: AuthV2.ServiceID.make("cloudflare-workers-ai"),
credential: new AuthV2.ApiKeyCredential({
type: "api",
key: "auth-key",
metadata: { accountId: "auth-acct" },
}),
active: true,
})
yield* plugin.add({
...AuthPlugin,
effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)),
})
yield* plugin.add(CloudflareWorkersAIPlugin)
const updated = yield* plugin.trigger(
"provider.update",
{},
{ provider: provider("cloudflare-workers-ai"), cancel: false },
)
expect(updated.provider.endpoint).toEqual({
type: "aisdk",
package: "test-provider",
url: "https://api.cloudflare.com/client/v4/accounts/auth-acct/ai/v1",
})
}),
),
)
it.effect("uses env account ID over configured account ID", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(CloudflareWorkersAIPlugin)
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("cloudflare-workers-ai", {
options: { headers: {}, body: {}, aisdk: { provider: { accountId: "configured-acct" }, request: {} } },
}),
cancel: false,
},
)
expect(result.provider.endpoint).toEqual({
type: "aisdk",
package: "test-provider",
url: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1",
})
}),
),
)
it.effect("uses env API key over auth or configured API key and keeps the Cloudflare User-Agent", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(CloudflareWorkersAIPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-workers-ai", "@cf/model", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" },
}),
package: "@ai-sdk/openai-compatible",
options: {
name: "cloudflare-workers-ai",
apiKey: "auth-key",
baseURL: "https://proxy.example/v1",
headers: { custom: "header" },
},
},
{},
)
const headers = yield* Effect.promise(() => Promise.resolve(cloudflareHeaders(result.sdk)))
expect(headers.authorization).toBe("Bearer env-key")
expect(headers.custom).toBe("header")
expect(headers["user-agent"]).toMatch(/^opencode\/.* cloudflare-workers-ai \(.+\) ai-sdk\/openai-compatible\//)
}),
),
)
it.effect("expands account ID vars in endpoint URLs", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(CloudflareWorkersAIPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-workers-ai", "@cf/model", {
endpoint: {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
},
}),
package: "@ai-sdk/openai-compatible",
options: {
name: "cloudflare-workers-ai",
baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
},
},
{},
)
expect(cloudflareURL(result.sdk)).toBe(
"https://api.cloudflare.com/client/v4/accounts/acct/ai/v1/chat/completions",
)
}),
),
)
it.effect("selects languageModel with the API model ID", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* plugin.add(CloudflareWorkersAIPlugin)
const result = yield* plugin.trigger(
"aisdk.language",
{
model: model("cloudflare-workers-ai", "alias", { apiID: ModelV2.ID.make("@cf/api-model") }),
sdk: fakeSelectorSdk(calls),
options: {},
},
{},
)
expect(result.language).toBeDefined()
expect(calls).toEqual(["languageModel:@cf/api-model"])
}),
)
it.effect("does not create an SDK for non OpenAI-compatible packages", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(CloudflareWorkersAIPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-workers-ai", "@cf/model", {
endpoint: { type: "aisdk", package: "@ai-sdk/anthropic", url: "https://proxy.example/v1" },
}),
package: "@ai-sdk/anthropic",
options: { name: "cloudflare-workers-ai" },
},
{},
)
expect(result.sdk).toBeUndefined()
}),
),
)
})

View file

@ -0,0 +1,86 @@
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 { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere"
import { fakeSelectorSdk, it, model } from "./provider-helper"
const cohereOptions: Record<string, any>[] = []
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
yield* plugin.add(CoherePlugin)
const ignored = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("cohere", "command"), package: "@ai-sdk/openai-compatible", options: { name: "cohere" } },
{},
)
expect(ignored.sdk).toBeUndefined()
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("cohere", "command"), 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
yield* plugin.add(CoherePlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-cohere", "command-r-plus"),
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 calls: string[] = []
const sdk = fakeSelectorSdk(calls)
yield* plugin.add(CoherePlugin)
const result = yield* plugin.trigger(
"aisdk.language",
{ model: model("cohere", "alias", { apiID: ModelV2.ID.make("command-r-plus") }), 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"])
}),
)
})

View file

@ -0,0 +1,129 @@
import { describe, expect, mock } from "bun:test"
import { Effect, Layer } from "effect"
import { AISDK } from "@opencode-ai/core/aisdk"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra"
import { testEffect } from "../../lib/effect"
import { it, model } from "./provider-helper"
const itAISDK = testEffect(Layer.provideMerge(AISDK.layer, PluginV2.defaultLayer))
const deepinfraOptions: Record<string, any>[] = []
const deepinfraLanguageModels: string[] = []
void mock.module("@ai-sdk/deepinfra", () => ({
createDeepInfra: (options: Record<string, any>) => {
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
yield* plugin.add(DeepInfraPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("deepinfra", "model"), 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
yield* plugin.add(DeepInfraPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-deepinfra", "model"),
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
yield* plugin.add(DeepInfraPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("deepinfra", "model"),
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
yield* plugin.add(DeepInfraPlugin)
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* plugin.trigger(
"aisdk.sdk",
{ model: model("deepinfra", "model"), package: item, options: { name: "deepinfra" } },
{},
)
expect(ignored.sdk).toBeUndefined()
}),
)
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("deepinfra", "model"), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" } },
{},
)
expect(result.sdk).toBeDefined()
expect(deepinfraOptions).toEqual([{ name: "deepinfra" }])
}),
)
itAISDK.effect("uses the default languageModel selection for DeepInfra models", () =>
Effect.gen(function* () {
resetDeepInfraMock()
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* plugin.add(DeepInfraPlugin)
const language = yield* aisdk.language(
model("deepinfra", "meta-llama/Llama-3.3-70B-Instruct", {
endpoint: { type: "aisdk", package: "@ai-sdk/deepinfra" },
}),
)
expect(language.provider).toBe("deepinfra.chat")
expect(deepinfraLanguageModels).toEqual(["meta-llama/Llama-3.3-70B-Instruct"])
}),
)
})

View file

@ -0,0 +1,172 @@
import { Npm } from "@opencode-ai/core/npm"
import { describe, expect } from "bun:test"
import { Cause, Effect, Layer, Option } from "effect"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { fileURLToPath } from "url"
import { AISDK } from "@opencode-ai/core/aisdk"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { DynamicProviderPlugin } from "@opencode-ai/core/plugin/provider/dynamic"
import { testEffect } from "../../lib/effect"
import { fixtureProvider, it, model, npmLayer } from "./provider-helper"
const fixtureProviderPath = fileURLToPath(fixtureProvider)
const itWithAISDK = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer)))
function npmEntrypointLayer(entrypoint: Option.Option<string>) {
return Layer.succeed(
Npm.Service,
Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint }),
install: () => Effect.void,
which: () => Effect.succeed(Option.none<string>()),
}),
)
}
function dynamicPlugin(layer = npmLayer) {
return { id: DynamicProviderPlugin.id, effect: DynamicProviderPlugin.effect.pipe(Effect.provide(layer)) }
}
function tempEntrypoint(source: string) {
return Effect.acquireRelease(
Effect.promise(async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-provider-dynamic-"))
const entrypoint = path.join(directory, "provider.mjs")
await Bun.write(entrypoint, source)
return { directory, entrypoint }
}),
(tmp) => Effect.promise(() => fs.rm(tmp.directory, { recursive: true, force: true })),
)
}
describe("DynamicProviderPlugin", () => {
it.effect("creates an SDK from a provider factory export", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(dynamicPlugin())
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom", "test-model"),
package: fixtureProvider,
options: { name: "custom", marker: "dynamic" },
},
{},
)
expect(result.sdk.options).toEqual({ marker: "dynamic", name: "custom" })
expect(result.sdk.languageModel("x")).toEqual({ modelID: "x", options: { marker: "dynamic", name: "custom" } })
}),
)
it.effect("does not override an SDK already supplied by an earlier plugin", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const sdk = { marker: "existing" }
yield* plugin.add(dynamicPlugin())
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom", "test-model"),
package: fixtureProvider,
options: { name: "custom", marker: "dynamic" },
},
{ sdk },
)
expect(result.sdk).toBe(sdk)
}),
)
it.effect("injects the provider ID as the SDK factory name", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(dynamicPlugin())
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-provider", "test-model"),
package: fixtureProvider,
options: { name: "custom-provider", marker: "dynamic" },
},
{},
)
expect(result.sdk.options).toEqual({ marker: "dynamic", name: "custom-provider" })
}),
)
it.effect("loads npm packages through their resolved import entrypoint", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(dynamicPlugin(npmEntrypointLayer(Option.some(fixtureProviderPath))))
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("npm-provider", "test-model"),
package: "fixture-provider",
options: { name: "npm-provider", marker: "npm" },
},
{},
)
expect(result.sdk.languageModel("x")).toEqual({ modelID: "x", options: { marker: "npm", name: "npm-provider" } })
}),
)
itWithAISDK.effect("wraps missing npm entrypoint failures as AISDK init errors", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* plugin.add(dynamicPlugin(npmEntrypointLayer(Option.none<string>())))
const exit = yield* aisdk
.language(model("missing-entrypoint", "alias", { endpoint: { type: "aisdk", package: "fixture-provider" } }))
.pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError")
}),
)
itWithAISDK.effect("wraps dynamic import failures as AISDK init errors", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* plugin.add(dynamicPlugin())
const exit = yield* aisdk
.language(
model("bad-import", "alias", { endpoint: { type: "aisdk", package: "file:///missing/provider-factory.js" } }),
)
.pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError")
}),
)
itWithAISDK.live("wraps missing provider factory exports as AISDK init errors", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n")
yield* plugin.add(dynamicPlugin(npmEntrypointLayer(Option.some(tmp.entrypoint))))
const exit = yield* aisdk
.language(model("missing-factory", "alias", { endpoint: { type: "aisdk", package: "fixture-provider" } }))
.pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError")
}),
)
itWithAISDK.effect("uses the model apiID for the default language model", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* plugin.add(dynamicPlugin())
const language = yield* aisdk.language(
model("custom", "alias", {
apiID: ModelV2.ID.make("test-model-api"),
endpoint: { type: "aisdk", package: fixtureProvider },
}),
)
expect(language).toMatchObject({ modelID: "test-model-api", options: { name: "custom" } })
}),
)
})

View file

@ -0,0 +1,87 @@
import { describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway"
import { it, model } from "./provider-helper"
const gatewayCalls: Record<string, unknown>[] = []
const vercelGatewayModels = ["anthropic/claude-sonnet-4", "openai/gpt-5", "google/gemini-2.5-pro"]
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
yield* plugin.add(GatewayPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("gateway", "model"), 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
yield* plugin.add(GatewayPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("vercel", "anthropic/claude-sonnet-4"),
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
yield* plugin.add(GatewayPlugin)
for (const modelID of vercelGatewayModels) {
const ignored = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("vercel", modelID), package: "@ai-sdk/vercel", options: { name: "vercel" } },
{},
)
expect(ignored.sdk).toBeUndefined()
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("vercel", modelID), package: "@ai-sdk/gateway", options: { name: "vercel" } },
{},
)
expect(result.sdk).toBeDefined()
}
expect(gatewayCalls).toHaveLength(3)
}),
)
})

View file

@ -0,0 +1,346 @@
import { describe, expect, mock } from "bun:test"
import { Effect, Layer } from "effect"
import { AuthV2 } from "@opencode-ai/core/auth"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
import { testEffect } from "../../lib/effect"
import { it, model, npmLayer, provider, withEnv } from "./provider-helper"
const gitlabSDKOptions: Record<string, unknown>[] = []
void mock.module("gitlab-ai-provider", () => ({
VERSION: "test-version",
createGitLab: (options: Record<string, unknown>) => {
gitlabSDKOptions.push(options)
return {
agenticChat: (id: string, options: unknown) => ({ id, options, type: "agentic" }),
workflowChat: (id: string, options: unknown) => ({ id, options, type: "workflow" }),
}
},
discoverWorkflowModels: async () => ({ models: [], project: undefined }),
isWorkflowModel: (id: string) => id === "duo-workflow" || id === "duo-workflow-exact",
}))
const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer))
describe("GitLabPlugin", () => {
it.effect("creates SDKs with legacy default instance URL, token env, headers, and feature flags", () =>
withEnv(
{
GITLAB_INSTANCE_URL: undefined,
GITLAB_TOKEN: "env-token",
},
() =>
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service
yield* plugin.add(GitLabPlugin)
yield* plugin.trigger(
"aisdk.sdk",
{ model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } },
{},
)
expect(gitlabSDKOptions).toHaveLength(1)
expect(gitlabSDKOptions[0].instanceUrl).toBe("https://gitlab.com")
expect(gitlabSDKOptions[0].apiKey).toBe("env-token")
expect(gitlabSDKOptions[0].aiGatewayHeaders).toMatchObject({
"anthropic-beta": "context-1m-2025-08-07",
})
expect(String((gitlabSDKOptions[0].aiGatewayHeaders as Record<string, string>)["User-Agent"])).toContain(
"gitlab-ai-provider/test-version",
)
expect(gitlabSDKOptions[0].featureFlags).toEqual({
duo_agent_platform_agentic_chat: true,
duo_agent_platform: true,
})
}),
),
)
it.effect("uses GITLAB_INSTANCE_URL when instanceUrl is not configured", () =>
withEnv(
{
GITLAB_INSTANCE_URL: "https://env.gitlab.example",
GITLAB_TOKEN: undefined,
},
() =>
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service
yield* plugin.add(GitLabPlugin)
yield* plugin.trigger(
"aisdk.sdk",
{ model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } },
{},
)
expect(gitlabSDKOptions[0].instanceUrl).toBe("https://env.gitlab.example")
}),
),
)
it.effect("keeps configured instance URL, apiKey, aiGatewayHeaders, and featureFlags over env/defaults", () =>
withEnv(
{
GITLAB_INSTANCE_URL: "https://env.gitlab.example",
GITLAB_TOKEN: "env-token",
},
() =>
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service
yield* plugin.add(GitLabPlugin)
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("gitlab", "claude"),
package: "gitlab-ai-provider",
options: {
name: "gitlab",
instanceUrl: "https://configured.gitlab.example",
apiKey: "configured-token",
aiGatewayHeaders: {
"anthropic-beta": "configured-beta",
"x-gitlab-test": "1",
},
featureFlags: {
duo_agent_platform: false,
custom_flag: true,
},
},
},
{},
)
expect(gitlabSDKOptions[0].instanceUrl).toBe("https://configured.gitlab.example")
expect(gitlabSDKOptions[0].apiKey).toBe("configured-token")
expect(gitlabSDKOptions[0].aiGatewayHeaders).toMatchObject({
"anthropic-beta": "configured-beta",
"x-gitlab-test": "1",
})
expect(gitlabSDKOptions[0].featureFlags).toEqual({
duo_agent_platform_agentic_chat: true,
duo_agent_platform: false,
custom_flag: true,
})
}),
),
)
it.effect("ignores non-GitLab SDK packages", () =>
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service
yield* plugin.add(GitLabPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("gitlab", "claude"), package: "@ai-sdk/openai", options: { name: "gitlab" } },
{},
)
expect(result.sdk).toBeUndefined()
expect(gitlabSDKOptions).toHaveLength(0)
}),
)
itWithAuth.effect("uses active API auth token over GITLAB_TOKEN", () =>
withEnv(
{
GITLAB_TOKEN: "env-token",
},
() =>
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service
const auth = yield* AuthV2.Service
yield* auth.create({
serviceID: AuthV2.ServiceID.make("gitlab"),
credential: new AuthV2.ApiKeyCredential({ type: "api", key: "auth-token" }),
active: true,
})
yield* plugin.add({
...AuthPlugin,
effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)),
})
yield* plugin.add(GitLabPlugin)
const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("gitlab"), cancel: false })
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("gitlab", "claude"),
package: "gitlab-ai-provider",
options: updated.provider.options.aisdk.provider,
},
{},
)
expect(gitlabSDKOptions[0].apiKey).toBe("auth-token")
}),
),
)
itWithAuth.effect("uses active OAuth access token when no API auth exists", () =>
withEnv(
{
GITLAB_TOKEN: undefined,
},
() =>
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service
const auth = yield* AuthV2.Service
yield* auth.create({
serviceID: AuthV2.ServiceID.make("gitlab"),
credential: new AuthV2.OAuthCredential({
type: "oauth",
refresh: "refresh-token",
access: "oauth-token",
expires: 9999999999999,
}),
active: true,
})
yield* plugin.add({
...AuthPlugin,
effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)),
})
yield* plugin.add(GitLabPlugin)
const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("gitlab"), cancel: false })
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("gitlab", "claude"),
package: "gitlab-ai-provider",
options: updated.provider.options.aisdk.provider,
},
{},
)
expect(gitlabSDKOptions[0].apiKey).toBe("oauth-token")
}),
),
)
it.effect("uses workflowChat for duo workflow models and preserves selectedModelRef", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: [string, unknown][] = []
yield* plugin.add(GitLabPlugin)
const result = yield* plugin.trigger(
"aisdk.language",
{
model: model("gitlab", "duo-workflow-custom", {
options: {
headers: {},
body: {},
aisdk: { provider: {}, request: { workflowRef: "ref", workflowDefinition: "definition" } },
},
}),
sdk: {
workflowChat: (id: string, options: unknown) => {
calls.push([id, options])
return { id, options }
},
agenticChat: () => undefined,
},
options: { featureFlags: { configured: true } },
},
{},
)
expect(calls).toEqual([
["duo-workflow", { featureFlags: { configured: true }, workflowDefinition: "definition" }],
])
expect(result.language as unknown).toEqual({
id: "duo-workflow",
options: calls[0]?.[1],
selectedModelRef: "ref",
})
}),
)
it.effect("uses exact static workflow model ids when the provider recognizes them", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: [string, unknown][] = []
yield* plugin.add(GitLabPlugin)
const result = yield* plugin.trigger(
"aisdk.language",
{
model: model("gitlab", "duo-workflow-exact"),
sdk: {
workflowChat: (id: string, options: unknown) => {
calls.push([id, options])
return { id, options }
},
agenticChat: () => undefined,
},
options: { featureFlags: { configured: true } },
},
{},
)
expect(calls).toEqual([
["duo-workflow-exact", { featureFlags: { configured: true }, workflowDefinition: undefined }],
])
expect(result.language as unknown).toEqual({ id: "duo-workflow-exact", options: calls[0]?.[1] })
}),
)
it.effect("uses provider feature flags instead of request feature flags", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: [string, unknown][] = []
yield* plugin.add(GitLabPlugin)
yield* plugin.trigger(
"aisdk.language",
{
model: model("gitlab", "duo-workflow-custom", {
options: {
headers: {},
body: {},
aisdk: { provider: {}, request: { featureFlags: { request_flag: true } } },
},
}),
sdk: {
workflowChat: (id: string, options: unknown) => {
calls.push([id, options])
return { id, options }
},
agenticChat: () => undefined,
},
options: { featureFlags: { configured: true } },
},
{},
)
expect(calls).toEqual([["duo-workflow", { featureFlags: { configured: true }, workflowDefinition: undefined }]])
}),
)
it.effect("uses agenticChat with provider aiGatewayHeaders and feature flags for normal models", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: [string, unknown][] = []
yield* plugin.add(GitLabPlugin)
yield* plugin.trigger(
"aisdk.language",
{
model: model("gitlab", "claude", {
options: { headers: { h: "v" }, body: {}, aisdk: { provider: {}, request: {} } },
}),
sdk: {
workflowChat: () => undefined,
agenticChat: (id: string, options: unknown) => {
const selected = options as {
aiGatewayHeaders?: Record<string, string>
featureFlags?: Record<string, boolean>
}
calls.push([
id,
{ aiGatewayHeaders: { ...selected.aiGatewayHeaders }, featureFlags: { ...selected.featureFlags } },
])
},
},
options: { aiGatewayHeaders: { fallback: "header" }, featureFlags: { duo_agent_platform: true } },
},
{},
)
expect(calls).toEqual([
["claude", { aiGatewayHeaders: { fallback: "header" }, featureFlags: { duo_agent_platform: true } }],
])
}),
)
})

View file

@ -0,0 +1,147 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { GoogleVertexAnthropicPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper"
describe("GoogleVertexAnthropicPlugin", () => {
it.effect("resolves legacy project and location env on provider update", () =>
withEnv(
{
GOOGLE_CLOUD_PROJECT: "cloud-project",
GCP_PROJECT: "gcp-project",
GCLOUD_PROJECT: "gcloud-project",
GOOGLE_CLOUD_LOCATION: "cloud-location",
VERTEX_LOCATION: "vertex-location",
GOOGLE_VERTEX_LOCATION: "google-vertex-location",
},
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(GoogleVertexAnthropicPlugin)
const result = yield* plugin.trigger(
"provider.update",
{},
{ provider: provider("google-vertex-anthropic"), cancel: false },
)
expect(result.provider.options.aisdk.provider.project).toBe("cloud-project")
expect(result.provider.options.aisdk.provider.location).toBe("cloud-location")
}),
),
)
it.effect("keeps configured project and location over env fallback", () =>
withEnv({ GOOGLE_CLOUD_PROJECT: "env-project", GOOGLE_CLOUD_LOCATION: "env-location" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(GoogleVertexAnthropicPlugin)
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("google-vertex-anthropic", {
options: {
headers: {},
body: {},
aisdk: { provider: { project: "configured-project", location: "configured-location" }, request: {} },
},
}),
cancel: false,
},
)
expect(result.provider.options.aisdk.provider.project).toBe("configured-project")
expect(result.provider.options.aisdk.provider.location).toBe("configured-location")
}),
),
)
it.effect("creates SDKs from legacy env fallback and default location", () =>
withEnv(
{
GOOGLE_CLOUD_PROJECT: undefined,
GCP_PROJECT: "gcp-project",
GCLOUD_PROJECT: "gcloud-project",
GOOGLE_CLOUD_LOCATION: undefined,
VERTEX_LOCATION: undefined,
GOOGLE_VERTEX_LOCATION: "ignored-location",
},
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(GoogleVertexAnthropicPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("google-vertex-anthropic", "claude-sonnet-4-5"),
package: "@ai-sdk/google-vertex/anthropic",
options: { name: "google-vertex-anthropic" },
},
{},
)
expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe(
"https://aiplatform.googleapis.com/v1/projects/gcp-project/locations/global/publishers/anthropic/models",
)
}),
),
)
it.effect("uses GOOGLE_CLOUD_LOCATION before VERTEX_LOCATION when creating SDKs", () =>
withEnv(
{ GOOGLE_CLOUD_PROJECT: "project", GOOGLE_CLOUD_LOCATION: "cloud-location", VERTEX_LOCATION: "vertex-location" },
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(GoogleVertexAnthropicPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("google-vertex-anthropic", "claude-sonnet-4-5"),
package: "@ai-sdk/google-vertex/anthropic",
options: { name: "google-vertex-anthropic" },
},
{},
)
expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe(
"https://cloud-location-aiplatform.googleapis.com/v1/projects/project/locations/cloud-location/publishers/anthropic/models",
)
}),
),
)
it.effect("trims model IDs before selecting language models", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* plugin.add(GoogleVertexAnthropicPlugin)
yield* plugin.trigger(
"aisdk.language",
{
model: model("google-vertex-anthropic", " claude-sonnet-4-5 "),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {},
},
{},
)
expect(calls).toEqual(["languageModel:claude-sonnet-4-5"])
}),
)
it.effect("ignores non Vertex Anthropic providers for language selection", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* plugin.add(GoogleVertexAnthropicPlugin)
const result = yield* plugin.trigger(
"aisdk.language",
{
model: model("google-vertex", "claude-sonnet-4-5"),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {},
},
{},
)
expect(calls).toEqual([])
expect(result.language).toBeUndefined()
}),
)
})

View file

@ -0,0 +1,300 @@
import { describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper"
const vertexOptions: Record<string, any>[] = []
void mock.module("@ai-sdk/google-vertex", () => ({
createVertex: (options: Record<string, any>) => {
vertexOptions.push(options)
return {
languageModel: (modelID: string) => ({ modelID, provider: "google-vertex", specificationVersion: "v3" }),
}
},
}))
void mock.module("google-auth-library", () => ({
GoogleAuth: class {
async getApplicationDefault() {
return {
credential: {
async getAccessToken() {
return { token: "vertex-token" }
},
},
}
}
},
}))
describe("GoogleVertexPlugin", () => {
it.effect("resolves project and location from env using legacy precedence", () =>
withEnv(
{
GOOGLE_CLOUD_PROJECT: "google-cloud-project",
GCP_PROJECT: "gcp-project",
GCLOUD_PROJECT: "gcloud-project",
GOOGLE_VERTEX_LOCATION: "google-vertex-location",
GOOGLE_CLOUD_LOCATION: "google-cloud-location",
VERTEX_LOCATION: "vertex-location",
},
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(GoogleVertexPlugin)
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("google-vertex", {
endpoint: {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
},
}),
cancel: false,
},
)
expect(result.provider.options.aisdk.provider.project).toBe("google-cloud-project")
expect(result.provider.options.aisdk.provider.location).toBe("google-vertex-location")
expect(result.provider.endpoint).toEqual({
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://google-vertex-location-aiplatform.googleapis.com/v1/projects/google-cloud-project/locations/google-vertex-location",
})
}),
),
)
it.effect("resolves the advertised GOOGLE_VERTEX_PROJECT env for provider updates and SDKs", () =>
withEnv(
{
GOOGLE_VERTEX_PROJECT: "vertex-project",
GOOGLE_CLOUD_PROJECT: undefined,
GCP_PROJECT: undefined,
GCLOUD_PROJECT: undefined,
GOOGLE_VERTEX_LOCATION: "europe-west4",
GOOGLE_CLOUD_LOCATION: undefined,
VERTEX_LOCATION: undefined,
},
() =>
Effect.gen(function* () {
vertexOptions.length = 0
const plugin = yield* PluginV2.Service
yield* plugin.add(GoogleVertexPlugin)
const updated = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("google-vertex", {
endpoint: {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
},
}),
cancel: false,
},
)
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("google-vertex", "gemini", {
endpoint: { type: "aisdk", package: "@ai-sdk/google-vertex" },
}),
package: "@ai-sdk/google-vertex",
options: { name: "google-vertex" },
},
{},
)
expect(updated.provider.options.aisdk.provider.project).toBe("vertex-project")
expect(updated.provider.endpoint).toEqual({
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://europe-west4-aiplatform.googleapis.com/v1/projects/vertex-project/locations/europe-west4",
})
expect(vertexOptions[0].project).toBe("vertex-project")
expect(vertexOptions[0].location).toBe("europe-west4")
}),
),
)
it.effect("keeps configured project and location over env and uses global endpoint", () =>
withEnv(
{
GOOGLE_CLOUD_PROJECT: "env-project",
GCP_PROJECT: "env-gcp-project",
GCLOUD_PROJECT: "env-gcloud-project",
GOOGLE_VERTEX_LOCATION: "env-location",
GOOGLE_CLOUD_LOCATION: "env-google-cloud-location",
VERTEX_LOCATION: "env-vertex-location",
},
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(GoogleVertexPlugin)
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("google-vertex", {
endpoint: {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
},
options: {
headers: {},
body: {},
aisdk: { provider: { project: "config-project", location: "global" }, request: {} },
},
}),
cancel: false,
},
)
expect(result.provider.options.aisdk.provider.project).toBe("config-project")
expect(result.provider.options.aisdk.provider.location).toBe("global")
expect(result.provider.endpoint).toEqual({
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://aiplatform.googleapis.com/v1/projects/config-project/locations/global",
})
}),
),
)
it.effect("defaults location to us-central1 when only project is configured", () =>
withEnv(
{
GOOGLE_CLOUD_PROJECT: undefined,
GCP_PROJECT: undefined,
GCLOUD_PROJECT: undefined,
GOOGLE_VERTEX_LOCATION: undefined,
GOOGLE_CLOUD_LOCATION: undefined,
VERTEX_LOCATION: undefined,
},
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(GoogleVertexPlugin)
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("google-vertex", {
options: { headers: {}, body: {}, aisdk: { provider: { project: "config-project" }, request: {} } },
}),
cancel: false,
},
)
expect(result.provider.options.aisdk.provider.project).toBe("config-project")
expect(result.provider.options.aisdk.provider.location).toBe("us-central1")
}),
),
)
it.effect("does not pass Google auth fetch to the native Vertex SDK", () =>
withEnv(
{
GOOGLE_CLOUD_PROJECT: "env-project",
GOOGLE_VERTEX_LOCATION: "env-location",
},
() =>
Effect.gen(function* () {
vertexOptions.length = 0
const plugin = yield* PluginV2.Service
yield* plugin.add(GoogleVertexPlugin)
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("google-vertex", "gemini", {
endpoint: { type: "aisdk", package: "@ai-sdk/google-vertex" },
}),
package: "@ai-sdk/google-vertex",
options: { name: "google-vertex" },
},
{},
)
expect(vertexOptions).toHaveLength(1)
expect(vertexOptions[0].project).toBe("env-project")
expect(vertexOptions[0].location).toBe("env-location")
expect(vertexOptions[0].fetch).toBeUndefined()
}),
),
)
it.effect("keeps Google auth fetch for OpenAI-compatible Vertex endpoints", () =>
Effect.gen(function* () {
const fetchCalls: { input: Parameters<typeof fetch>[0]; init?: RequestInit }[] = []
const plugin = yield* PluginV2.Service
yield* plugin.add(GoogleVertexPlugin)
yield* plugin.add({
id: PluginV2.ID.make("capture-openai-compatible"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.promise(async () => {
if (evt.model.providerID !== "google-vertex") return
if (evt.package !== "@ai-sdk/openai-compatible") return
expect(typeof evt.options.fetch).toBe("function")
await evt.options.fetch("https://vertex.example", {
headers: { "x-test": "1" },
})
}),
}),
})
const originalFetch = fetch
;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = (async (
input: Parameters<typeof fetch>[0],
init?: RequestInit,
) => {
fetchCalls.push({ input, init })
return new Response("ok")
}) as typeof fetch
yield* Effect.acquireUseRelease(
Effect.void,
() =>
plugin.trigger(
"aisdk.sdk",
{
model: model("google-vertex", "gemini", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible" },
}),
package: "@ai-sdk/openai-compatible",
options: { name: "google-vertex" },
},
{},
),
() =>
Effect.sync(() => {
;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = originalFetch
}),
)
expect(fetchCalls).toHaveLength(1)
expect(fetchCalls[0].input).toBe("https://vertex.example")
expect(new Headers(fetchCalls[0].init?.headers).get("authorization")).toBe("Bearer vertex-token")
expect(new Headers(fetchCalls[0].init?.headers).get("x-test")).toBe("1")
}),
)
it.effect("trims model IDs before selecting language models", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* plugin.add(GoogleVertexPlugin)
yield* plugin.trigger(
"aisdk.language",
{
model: model("google-vertex", " gemini-2.5-pro "),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {},
},
{},
)
expect(calls).toEqual(["languageModel:gemini-2.5-pro"])
}),
)
})

View file

@ -0,0 +1,70 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AISDK } from "@opencode-ai/core/aisdk"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { GooglePlugin } from "@opencode-ai/core/plugin/provider/google"
import { testEffect } from "../../lib/effect"
import { it, model } from "./provider-helper"
const itWithAISDK = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer)))
describe("GooglePlugin", () => {
it.effect("creates a Google Generative AI SDK for @ai-sdk/google using the provider ID as SDK name", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(GooglePlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-google", "gemini"),
package: "@ai-sdk/google",
options: { name: "custom-google", apiKey: "test" },
},
{},
)
expect(result.sdk).toBeDefined()
expect(result.sdk?.languageModel("gemini").provider).toBe("custom-google")
}),
)
it.effect("ignores non-Google SDK packages", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(GooglePlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("google", "gemini"), package: "@ai-sdk/google-vertex", options: { name: "google" } },
{},
)
expect(result.sdk).toBeUndefined()
}),
)
itWithAISDK.effect("uses default languageModel loading with provider ID parity", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* plugin.add(GooglePlugin)
const language = yield* aisdk.language(
model("custom-google", "alias", {
apiID: ModelV2.ID.make("gemini-api"),
endpoint: {
type: "aisdk",
package: "@ai-sdk/google",
},
options: {
headers: {},
body: {},
aisdk: {
provider: { apiKey: "test" },
request: {},
},
},
}),
)
expect(language.modelId).toBe("gemini-api")
expect(language.provider).toBe("custom-google")
}),
)
})

View file

@ -0,0 +1,101 @@
import { describe, expect } from "bun:test"
import { createGroq } from "@ai-sdk/groq"
import { Effect, Layer } from "effect"
import { AISDK } from "@opencode-ai/core/aisdk"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq"
import { it, model } from "./provider-helper"
import { testEffect } from "../../lib/effect"
const aisdkIt = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer)))
describe("GroqPlugin", () => {
it.effect("creates a Groq SDK for @ai-sdk/groq", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(GroqPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("groq", "llama"), 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
yield* plugin.add(GroqPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("groq", "llama"), 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
yield* plugin.add(GroqPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("groq", "llama"), 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
yield* plugin.add(GroqPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-groq", "llama"),
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)
}),
)
aisdkIt.effect("uses the default languageModel(apiID) behavior", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
yield* plugin.add(GroqPlugin)
const result = yield* aisdk.language(
model("groq", "alias", {
apiID: ModelV2.ID.make("llama-api"),
endpoint: {
type: "aisdk",
package: "@ai-sdk/groq",
},
options: {
headers: {},
body: {},
aisdk: {
provider: { apiKey: "test" },
request: {},
},
},
}),
)
expect(result.modelId).toBe("llama-api")
expect(result.provider).toBe("groq.chat")
}),
)
})

View file

@ -0,0 +1,100 @@
import { Npm } from "@opencode-ai/core/npm"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { expect } from "bun:test"
import { Effect, Layer, Option } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../../lib/effect"
export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
export const npmLayer = Layer.succeed(
Npm.Service,
Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: Option.none<string>() }),
install: () => Effect.void,
which: () => Effect.succeed(Option.none<string>()),
}),
)
export const it = testEffect(Layer.mergeAll(PluginV2.defaultLayer, npmLayer))
export function provider(providerID: string, options?: Partial<ProviderV2.Info>) {
return new ProviderV2.Info({
...ProviderV2.Info.empty(ProviderV2.ID.make(providerID)),
endpoint: {
type: "aisdk",
package: "test-provider",
},
...options,
options: {
headers: {},
body: {},
aisdk: {
provider: {},
request: {},
},
...options?.options,
},
})
}
export function model(providerID: string, modelID: string, options?: Partial<ModelV2.Info>) {
return new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
apiID: ModelV2.ID.make(modelID),
endpoint: {
type: "aisdk",
package: "test-provider",
},
...options,
options: {
headers: {},
body: {},
aisdk: {
provider: {},
request: {},
},
...options?.options,
},
})
}
export function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
for (const [key, value] of Object.entries(vars)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
return previous
}),
() => fx(),
(previous) =>
Effect.sync(() => {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
}),
)
}
export 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"),
}
}
export function expectPluginRegistered(ids: string[], id: string) {
expect(ids).toContain(PluginV2.ID.make(id))
}

View file

@ -0,0 +1,90 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo"
import { expectPluginRegistered, it, provider } from "./provider-helper"
describe("KiloPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() =>
expectPluginRegistered(
ProviderPlugins.map((item) => item.id),
"kilo",
),
),
)
it.effect("applies legacy referer headers only to kilo", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(KiloPlugin)
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("kilo", {
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
}),
cancel: false,
},
)
const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("openrouter"), cancel: false })
expect(result.provider.options.headers).toEqual({
Existing: "value",
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
})
expect(ignored.provider.options.headers).toEqual({})
}),
)
it.effect("uses the exact legacy Kilo header casing and set", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(KiloPlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("kilo"), cancel: false })
expect(result.provider.options.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
})
expect(result.provider.options.headers).not.toHaveProperty("http-referer")
expect(result.provider.options.headers).not.toHaveProperty("x-title")
expect(result.provider.options.headers).not.toHaveProperty("X-Source")
}),
)
it.effect("uses the legacy provider-id guard instead of endpoint package matching", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(KiloPlugin)
const matchingID = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("kilo", {
endpoint: { type: "aisdk", package: "not-kilo" },
}),
cancel: false,
},
)
const matchingPackage = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("custom-kilo", {
endpoint: { type: "aisdk", package: "kilo" },
}),
cancel: false,
},
)
expect(matchingID.provider.options.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
})
expect(matchingPackage.provider.options.headers).toEqual({})
}),
)
})

View file

@ -0,0 +1,63 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway"
import { expectPluginRegistered, it, provider } from "./provider-helper"
describe("LLMGatewayPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() =>
expectPluginRegistered(
ProviderPlugins.map((item) => item.id),
"llmgateway",
),
),
)
it.effect("applies legacy referer headers only to enabled llmgateway", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(LLMGatewayPlugin)
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("llmgateway", {
enabled: { via: "env", name: "LLMGATEWAY_API_KEY" },
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
}),
cancel: false,
},
)
const ignored = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("openrouter", {
enabled: { via: "env", name: "OPENROUTER_API_KEY" },
}),
cancel: false,
},
)
expect(result.provider.options.headers).toEqual({
Existing: "value",
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
"X-Source": "opencode",
})
expect(ignored.provider.options.headers).toEqual({})
}),
)
it.effect("does not apply legacy headers to a disabled llmgateway provider", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(LLMGatewayPlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("llmgateway"), cancel: false })
expect(result.provider.enabled).toBe(false)
expect(result.provider.options.headers).toEqual({})
}),
)
})

View file

@ -0,0 +1,106 @@
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 { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral"
import { fakeSelectorSdk, it, model } from "./provider-helper"
describe("MistralPlugin", () => {
it.effect("creates a Mistral SDK for @ai-sdk/mistral", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(MistralPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("mistral", "mistral-large"), 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
yield* plugin.add(MistralPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("mistral", "mistral-large"),
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 providers: string[] = []
yield* plugin.add(MistralPlugin)
yield* plugin.add({
id: PluginV2.ID.make("mistral-sdk-inspector"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.sync(() => {
providers.push(evt.sdk.languageModel("mistral-large").provider)
}),
}),
})
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("mistral", "mistral-large"), 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 providers: string[] = []
yield* plugin.add(MistralPlugin)
yield* plugin.add({
id: PluginV2.ID.make("mistral-sdk-inspector"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.sync(() => {
providers.push(evt.sdk.languageModel("mistral-large").provider)
}),
}),
})
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-mistral", "mistral-large"),
package: "@ai-sdk/mistral",
options: { name: "custom-mistral" },
},
{},
)
expect(providers).toEqual(["mistral.chat"])
}),
)
it.effect("leaves Mistral language selection on the default sdk.languageModel(apiID) path", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
const sdk = fakeSelectorSdk(calls)
yield* plugin.add(MistralPlugin)
const result = yield* plugin.trigger(
"aisdk.language",
{ model: model("mistral", "alias", { apiID: ModelV2.ID.make("mistral-large") }), sdk, options: {} },
{},
)
const language = result.language ?? sdk.languageModel(result.model.apiID)
expect(calls).toEqual(["languageModel:mistral-large"])
expect(language).toBeDefined()
}),
)
})

View file

@ -0,0 +1,41 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia"
import { expectPluginRegistered, it, provider } from "./provider-helper"
describe("NvidiaPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() =>
expectPluginRegistered(
ProviderPlugins.map((item) => item.id),
"nvidia",
),
),
)
it.effect("applies legacy referer headers only to nvidia", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(NvidiaPlugin)
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("nvidia", {
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
}),
cancel: false,
},
)
const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("openrouter"), cancel: false })
expect(result.provider.options.headers).toEqual({
Existing: "value",
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
})
expect(ignored.provider.options.headers).toEqual({})
}),
)
})

View file

@ -0,0 +1,101 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { OpenAICompatiblePlugin } from "@opencode-ai/core/plugin/provider/openai-compatible"
import { it, model } from "./provider-helper"
describe("OpenAICompatiblePlugin", () => {
it.effect("preserves explicit includeUsage false and defaults it to true", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(OpenAICompatiblePlugin)
const defaulted = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("custom", "model"), package: "@ai-sdk/openai-compatible", options: { name: "custom" } },
{},
)
const disabled = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom", "model"),
package: "@ai-sdk/openai-compatible",
options: { name: "custom", includeUsage: false },
},
{},
)
expect(defaulted.options.includeUsage).toBe(true)
expect(disabled.options.includeUsage).toBe(false)
}),
)
it.effect("defaults includeUsage for OpenAI-compatible package matches", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(OpenAICompatiblePlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom", "model"),
package: "file:///tmp/@ai-sdk/openai-compatible-provider.js",
options: { name: "custom" },
},
{},
)
expect(result.options.includeUsage).toBe(true)
}),
)
it.effect("uses the provider ID as the OpenAI-compatible provider name", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const observed: string[] = []
yield* plugin.add(OpenAICompatiblePlugin)
yield* plugin.add({
id: PluginV2.ID.make("inspector"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.sync(() => {
observed.push(evt.sdk.languageModel("model").provider)
}),
}),
})
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-provider", "model"),
package: "@ai-sdk/openai-compatible",
options: { name: "custom-provider", baseURL: "https://example.com/v1" },
},
{},
)
expect(observed).toEqual(["custom-provider.chat"])
}),
)
it.effect("does not overwrite an SDK created by an earlier provider-specific plugin", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const sentinel = { languageModel: (modelID: string) => ({ modelID }) }
yield* plugin.add({
id: PluginV2.ID.make("sentinel"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.sync(() => {
evt.sdk = sentinel
}),
}),
})
yield* plugin.add(OpenAICompatiblePlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-workers-ai", "model"),
package: "@ai-sdk/openai-compatible",
options: { name: "cloudflare-workers-ai" },
},
{},
)
expect(result.sdk).toBe(sentinel)
}),
)
})

View file

@ -0,0 +1,100 @@
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 { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
import { fakeSelectorSdk, it, model } from "./provider-helper"
describe("OpenAIPlugin", () => {
it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(OpenAIPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-openai", "gpt-5"),
package: "@ai-sdk/openai",
options: { name: "custom-openai", apiKey: "test" },
},
{},
)
expect(result.sdk?.responses("gpt-5").provider).toBe("custom-openai.responses")
}),
)
it.effect("ignores non-OpenAI SDK packages", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(OpenAIPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("openai", "gpt-5"), package: "@ai-sdk/openai-compatible", options: { name: "openai" } },
{},
)
expect(result.sdk).toBeUndefined()
}),
)
it.effect("uses the Responses API for language models", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* plugin.add(OpenAIPlugin)
const result = yield* plugin.trigger(
"aisdk.language",
{
model: model("openai", "alias", { apiID: ModelV2.ID.make("gpt-5") }),
sdk: fakeSelectorSdk(calls),
options: {},
},
{},
)
expect(calls).toEqual(["responses:gpt-5"])
expect(result.language).toBeDefined()
}),
)
it.effect("ignores non-OpenAI providers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* plugin.add(OpenAIPlugin)
const result = yield* plugin.trigger(
"aisdk.language",
{ model: model("anthropic", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} },
{},
)
expect(calls).toEqual([])
expect(result.language).toBeUndefined()
}),
)
it.effect("cancels gpt-5-chat-latest during model updates", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(OpenAIPlugin)
const normal = yield* plugin.trigger("model.update", {}, { model: model("openai", "gpt-5"), cancel: false })
const filtered = yield* plugin.trigger(
"model.update",
{},
{ model: model("openai", "gpt-5-chat-latest"), cancel: false },
)
expect(normal.cancel).toBe(false)
expect(filtered.cancel).toBe(true)
}),
)
it.effect("does not cancel gpt-5-chat-latest for non-OpenAI providers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(OpenAIPlugin)
const result = yield* plugin.trigger(
"model.update",
{},
{ model: model("custom-openai", "gpt-5-chat-latest"), cancel: false },
)
expect(result.cancel).toBe(false)
}),
)
})

View file

@ -0,0 +1,195 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { it, model, provider, withEnv } from "./provider-helper"
const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
describe("OpencodePlugin", () => {
it.effect("uses a public key and cancels paid models without credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(OpencodePlugin)
const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false })
const paid = yield* plugin.trigger(
"model.update",
{},
{ model: model("opencode", "paid", { cost: cost(1) }), cancel: false },
)
expect(updated.provider.options.aisdk.provider.apiKey).toBe("public")
expect(paid.cancel).toBe(true)
}),
),
)
it.effect("keeps free models without credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(OpencodePlugin)
yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false })
const free = yield* plugin.trigger(
"model.update",
{},
{ model: model("opencode", "free", { cost: cost(0) }), cancel: false },
)
expect(free.cancel).toBe(false)
}),
),
)
it.effect("treats output-only cost as free without credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(OpencodePlugin)
yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false })
const outputOnly = yield* plugin.trigger(
"model.update",
{},
{ model: model("opencode", "output-only", { cost: cost(0, 1) }), cancel: false },
)
expect(outputOnly.cancel).toBe(false)
}),
),
)
it.effect("uses OPENCODE_API_KEY as credentials", () =>
withEnv({ OPENCODE_API_KEY: "secret" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(OpencodePlugin)
const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false })
const paid = yield* plugin.trigger(
"model.update",
{},
{ model: model("opencode", "paid", { cost: cost(1) }), cancel: false },
)
expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined()
expect(paid.cancel).toBe(false)
}),
),
)
it.effect("uses configured provider env vars as credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(OpencodePlugin)
const updated = yield* plugin.trigger(
"provider.update",
{},
{ provider: provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] }), cancel: false },
)
const paid = yield* plugin.trigger(
"model.update",
{},
{ model: model("opencode", "paid", { cost: cost(1) }), cancel: false },
)
expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined()
expect(paid.cancel).toBe(false)
}),
),
)
it.effect("uses configured apiKey as credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(OpencodePlugin)
const updated = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("opencode", {
options: {
headers: {},
body: {},
aisdk: {
provider: { apiKey: "configured" },
request: {},
},
},
}),
cancel: false,
},
)
const paid = yield* plugin.trigger(
"model.update",
{},
{ model: model("opencode", "paid", { cost: cost(1) }), cancel: false },
)
expect(updated.provider.options.aisdk.provider.apiKey).toBe("configured")
expect(paid.cancel).toBe(false)
}),
),
)
it.effect("uses auth-enabled providers as credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(OpencodePlugin)
const updated = yield* plugin.trigger(
"provider.update",
{},
{ provider: provider("opencode", { enabled: { via: "auth", service: "opencode" } }), cancel: false },
)
const paid = yield* plugin.trigger(
"model.update",
{},
{ model: model("opencode", "paid", { cost: cost(1) }), cancel: false },
)
expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined()
expect(paid.cancel).toBe(false)
}),
),
)
it.effect("ignores non-opencode providers and models", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(OpencodePlugin)
const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false })
const paid = yield* plugin.trigger(
"model.update",
{},
{ model: model("openai", "paid", { cost: cost(1) }), cancel: false },
)
expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined()
expect(paid.cancel).toBe(false)
}),
),
)
it.effect("prefers gpt-5-nano as the opencode small model", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.opencode
yield* catalog.provider.update(providerID, () => {})
yield* catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => {
model.capabilities.input = ["text"]
model.capabilities.output = ["text"]
model.cost = cost(1, 1)
model.time.released = DateTime.makeUnsafe(Date.now())
})
yield* catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => {
model.capabilities.input = ["text"]
model.capabilities.output = ["text"]
model.cost = cost(10, 10)
model.time.released = DateTime.makeUnsafe(Date.now())
})
const selected = yield* catalog.model.small(providerID)
expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
}).pipe(Effect.provide(Catalog.defaultLayer)),
)
})

View file

@ -0,0 +1,105 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter"
import { expectPluginRegistered, it, model, provider } from "./provider-helper"
describe("OpenRouterPlugin", () => {
it.effect("is registered so legacy OpenRouter behavior can be applied", () =>
Effect.sync(() =>
expectPluginRegistered(
ProviderPlugins.map((item) => item.id),
"openrouter",
),
),
)
it.effect("applies legacy referer headers only to openrouter", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(OpenRouterPlugin)
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("openrouter", {
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
}),
cancel: false,
},
)
const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("nvidia"), cancel: false })
expect(result.provider.options.headers).toEqual({
Existing: "value",
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
})
expect(ignored.provider.options.headers).toEqual({})
}),
)
it.effect("creates an SDK only for the OpenRouter package", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(OpenRouterPlugin)
const ignored = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("openrouter", "openai/gpt-5"),
package: "@ai-sdk/openai-compatible",
options: { name: "openrouter" },
},
{},
)
expect(ignored.sdk).toBeUndefined()
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("custom", "openai/gpt-5"), package: "@openrouter/ai-sdk-provider", options: { name: "custom" } },
{},
)
expect(result.sdk).toBeDefined()
}),
)
it.effect("filters OpenRouter's gpt-5 chat alias", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(OpenRouterPlugin)
const result = yield* plugin.trigger(
"model.update",
{},
{ model: model("openrouter", "openai/gpt-5-chat"), cancel: false },
)
const regular = yield* plugin.trigger(
"model.update",
{},
{ model: model("openrouter", "openai/gpt-5"), cancel: false },
)
const ignored = yield* plugin.trigger(
"model.update",
{},
{ model: model("openai", "openai/gpt-5-chat"), cancel: false },
)
expect(result.cancel).toBe(true)
expect(regular.cancel).toBe(false)
expect(ignored.cancel).toBe(false)
}),
)
it.effect("does not filter gpt-5-chat-latest for non-OpenRouter providers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(OpenRouterPlugin)
const result = yield* plugin.trigger(
"model.update",
{},
{ model: model("custom-openrouter", "gpt-5-chat-latest"), cancel: false },
)
expect(result.cancel).toBe(false)
}),
)
})

View file

@ -0,0 +1,107 @@
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 { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity"
import { fakeSelectorSdk, it, model } from "./provider-helper"
describe("PerplexityPlugin", () => {
it.effect("creates a Perplexity SDK for the exact @ai-sdk/perplexity package", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(PerplexityPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("perplexity", "sonar"), 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
yield* plugin.add(PerplexityPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("perplexity", "sonar"),
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 providers: string[] = []
yield* plugin.add(PerplexityPlugin)
yield* plugin.add({
id: PluginV2.ID.make("perplexity-sdk-inspector"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.sync(() => {
providers.push(evt.sdk.languageModel("sonar").provider)
}),
}),
})
yield* plugin.trigger(
"aisdk.sdk",
{ model: model("perplexity", "sonar"), package: "@ai-sdk/perplexity", options: { name: "perplexity" } },
{},
)
expect(providers).toEqual(["perplexity"])
}),
)
it.effect("creates bundled Perplexity SDKs for custom provider IDs", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const providers: string[] = []
yield* plugin.add(PerplexityPlugin)
yield* plugin.add({
id: PluginV2.ID.make("custom-perplexity-sdk-inspector"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.sync(() => {
providers.push(evt.sdk.languageModel("sonar").provider)
}),
}),
})
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-perplexity", "sonar"),
package: "@ai-sdk/perplexity",
options: { name: "custom-perplexity" },
},
{},
)
expect(providers).toEqual(["perplexity"])
}),
)
it.effect("leaves Perplexity language selection to the default languageModel fallback", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* plugin.add(PerplexityPlugin)
const result = yield* plugin.trigger(
"aisdk.language",
{
model: model("perplexity", "alias", { apiID: ModelV2.ID.make("sonar") }),
sdk: fakeSelectorSdk(calls),
options: {},
},
{},
)
expect(calls).toEqual([])
expect(result.language).toBeUndefined()
}),
)
})

View file

@ -0,0 +1,127 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { SapAICorePlugin } from "@opencode-ai/core/plugin/provider/sap-ai-core"
import { fixtureProvider, it, model, npmLayer, withEnv } from "./provider-helper"
const pluginWithNpm = { id: SapAICorePlugin.id, effect: SapAICorePlugin.effect.pipe(Effect.provide(npmLayer)) }
describe("SapAICorePlugin", () => {
it.effect("copies serviceKey option into AICORE_SERVICE_KEY but keeps SDK options to deployment metadata", () =>
withEnv(
{ AICORE_SERVICE_KEY: undefined, AICORE_DEPLOYMENT_ID: "deployment", AICORE_RESOURCE_GROUP: "resource-group" },
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(pluginWithNpm)
const sdk = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("sap-ai-core", "sap-model"),
package: fixtureProvider,
options: { name: "sap-ai-core", serviceKey: "service-key" },
},
{},
)
expect(process.env.AICORE_SERVICE_KEY).toBe("service-key")
expect(sdk.sdk.options).toEqual({ deploymentId: "deployment", resourceGroup: "resource-group" })
}),
),
)
it.effect("preserves existing AICORE_SERVICE_KEY over serviceKey option", () =>
withEnv(
{
AICORE_SERVICE_KEY: "env-service-key",
AICORE_DEPLOYMENT_ID: "deployment",
AICORE_RESOURCE_GROUP: "resource-group",
},
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(pluginWithNpm)
const sdk = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("sap-ai-core", "sap-model"),
package: fixtureProvider,
options: { name: "sap-ai-core", serviceKey: "option-service-key" },
},
{},
)
expect(process.env.AICORE_SERVICE_KEY).toBe("env-service-key")
expect(sdk.sdk.options).toEqual({ deploymentId: "deployment", resourceGroup: "resource-group" })
}),
),
)
it.effect("omits deployment and resourceGroup SDK options when no service key is available", () =>
withEnv(
{ AICORE_SERVICE_KEY: undefined, AICORE_DEPLOYMENT_ID: "deployment", AICORE_RESOURCE_GROUP: "resource-group" },
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(pluginWithNpm)
const sdk = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("sap-ai-core", "sap-model"), package: fixtureProvider, options: { name: "sap-ai-core" } },
{},
)
expect(process.env.AICORE_SERVICE_KEY).toBeUndefined()
expect(sdk.sdk.options).toEqual({})
}),
),
)
it.effect("uses the callable SDK for language selection", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(pluginWithNpm)
const sdk = Object.assign((modelID: string) => ({ modelID, provider: "callable" }), {
languageModel() {
throw new Error("SAP AI Core should call the SDK directly")
},
})
const language = yield* plugin.trigger(
"aisdk.language",
{ model: model("sap-ai-core", "sap-model"), sdk, options: {} },
{},
)
expect(language.language as unknown).toEqual({ modelID: "sap-model", provider: "callable" })
}),
)
it.effect("ignores non-SAP AI Core providers", () =>
withEnv(
{ AICORE_SERVICE_KEY: undefined, AICORE_DEPLOYMENT_ID: "deployment", AICORE_RESOURCE_GROUP: "resource-group" },
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(pluginWithNpm)
const sdk = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("openai", "sap-model"),
package: fixtureProvider,
options: { name: "openai", serviceKey: "service-key" },
},
{},
)
const language = yield* plugin.trigger(
"aisdk.language",
{
model: model("openai", "sap-model"),
sdk: () => {
throw new Error("SAP AI Core should ignore other providers")
},
options: {},
},
{},
)
expect(process.env.AICORE_SERVICE_KEY).toBeUndefined()
expect(sdk.sdk).toBeUndefined()
expect(language.language).toBeUndefined()
}),
),
)
})

View file

@ -0,0 +1,97 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { TogetherAIPlugin } from "@opencode-ai/core/plugin/provider/togetherai"
import { fakeSelectorSdk, it, model } from "./provider-helper"
describe("TogetherAIPlugin", () => {
it.effect("creates a TogetherAI SDK for @ai-sdk/togetherai", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(TogetherAIPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("togetherai", "model"), 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
yield* plugin.add(TogetherAIPlugin)
const ignored = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("togetherai", "model"),
package: "file:///tmp/@ai-sdk/togetherai-provider.js",
options: { name: "togetherai" },
},
{},
)
expect(ignored.sdk).toBeUndefined()
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("togetherai", "model"), 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 observed: string[] = []
yield* plugin.add(TogetherAIPlugin)
yield* plugin.add({
id: PluginV2.ID.make("inspector"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.sync(() => {
observed.push(evt.sdk.languageModel("model").provider)
}),
}),
})
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-togetherai", "model"),
package: "@ai-sdk/togetherai",
options: { name: "custom-togetherai" },
},
{},
)
expect(observed).toEqual(["togetherai.chat"])
}),
)
it.effect("defaults language selection to sdk.languageModel with the model API ID", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* plugin.add(TogetherAIPlugin)
const result = yield* plugin.trigger(
"aisdk.language",
{
model: model("togetherai", "meta-llama/Llama-3.3-70B-Instruct-Turbo"),
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
options: {},
},
{},
)
expect(result.language).toBeUndefined()
expect(calls).toEqual([])
expect(result.language ?? fakeSelectorSdk(calls).languageModel(result.model.apiID)).toBeDefined()
expect(calls).toEqual(["languageModel:meta-llama/Llama-3.3-70B-Instruct-Turbo"])
}),
)
})

View file

@ -0,0 +1,86 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice"
import { fakeSelectorSdk, it, model } from "./provider-helper"
describe("VenicePlugin", () => {
it.effect("creates a Venice SDK for venice-ai-sdk-provider", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(VenicePlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("venice", "model"), 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 observed: string[] = []
yield* plugin.add(VenicePlugin)
yield* plugin.add({
id: PluginV2.ID.make("inspector"),
effect: Effect.succeed({
"aisdk.sdk": (evt) =>
Effect.sync(() => {
observed.push(evt.sdk.languageModel("model").provider)
}),
}),
})
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("custom-venice", "model"),
package: "venice-ai-sdk-provider",
options: { name: "custom-venice", apiKey: "test" },
},
{},
)
expect(result.sdk).toBeDefined()
expect(observed).toEqual(["custom-venice.chat"])
}),
)
it.effect("only handles the bundled venice-ai-sdk-provider package", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(VenicePlugin)
const similar = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("venice", "model"),
package: "file:///tmp/venice-ai-sdk-provider.js",
options: { name: "venice" },
},
{},
)
const other = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("venice", "model"), 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 calls: string[] = []
yield* plugin.add(VenicePlugin)
const result = yield* plugin.trigger(
"aisdk.language",
{ model: model("venice", "alias"), sdk: fakeSelectorSdk(calls), options: {} },
{},
)
expect(calls).toEqual([])
expect(result.language).toBeUndefined()
}),
)
})

View file

@ -0,0 +1,62 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel"
import { it, model, provider } from "./provider-helper"
describe("VercelPlugin", () => {
it.effect("applies legacy lower-case referer headers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(VercelPlugin)
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("vercel", {
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
}),
cancel: false,
},
)
expect(result.provider.options.headers).toEqual({
Existing: "1",
"http-referer": "https://opencode.ai/",
"x-title": "opencode",
})
}),
)
it.effect("does not add legacy upper-case referer headers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(VercelPlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("vercel"), cancel: false })
expect(result.provider.options.headers).not.toHaveProperty("HTTP-Referer")
expect(result.provider.options.headers).not.toHaveProperty("X-Title")
}),
)
it.effect("creates @ai-sdk/vercel SDKs for custom provider IDs", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(VercelPlugin)
const event = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("custom-vercel", "v0-1.0-md"), package: "@ai-sdk/vercel", options: { name: "custom-vercel" } },
{},
)
expect(event.sdk).toBeDefined()
expect(event.sdk.languageModel("v0-1.0-md").provider).toBe("vercel.chat")
}),
)
it.effect("ignores non-Vercel providers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(VercelPlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("gateway"), cancel: false })
expect(result.provider.options.headers).toEqual({})
}),
)
})

View file

@ -0,0 +1,115 @@
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 { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../../lib/effect"
import { fakeSelectorSdk } from "./provider-helper"
const it = testEffect(PluginV2.defaultLayer)
const model = new ModelV2.Info({
...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")),
apiID: ModelV2.ID.make("grok-4"),
endpoint: {
type: "aisdk",
package: "@ai-sdk/xai",
},
})
describe("XAIPlugin", () => {
it.effect("creates an xAI SDK only for @ai-sdk/xai", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(XAIPlugin)
const ignored = yield* plugin.trigger(
"aisdk.sdk",
{ model, package: "@ai-sdk/openai-compatible", options: {} },
{},
)
const result = yield* plugin.trigger("aisdk.sdk", { model, package: "@ai-sdk/xai", options: {} }, {})
expect(ignored.sdk).toBeUndefined()
expect(typeof result.sdk?.responses).toBe("function")
}),
)
it.effect("creates xAI SDKs for custom provider IDs", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const providers: string[] = []
yield* plugin.add(XAIPlugin)
yield* plugin.add(
PluginV2.define({
id: PluginV2.ID.make("xai-sdk-name-observer"),
effect: Effect.gen(function* () {
return {
"aisdk.sdk": Effect.fn(function* (evt) {
if (!evt.sdk) return
providers.push(evt.sdk.responses("grok-4").provider)
}),
}
}),
}),
)
yield* plugin.trigger(
"aisdk.sdk",
{
model: new ModelV2.Info({ ...model, providerID: ProviderV2.ID.make("custom-xai") }),
package: "@ai-sdk/xai",
options: {},
},
{},
)
expect(providers).toEqual(["xai.responses"])
}),
)
it.effect("uses responses with the model apiID for xAI language models", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* plugin.add(XAIPlugin)
const result = yield* plugin.trigger(
"aisdk.language",
{
model: new ModelV2.Info({ ...model, id: ModelV2.ID.make("alias"), apiID: ModelV2.ID.make("grok-4") }),
sdk: fakeSelectorSdk(calls),
options: {},
},
{},
)
expect(calls).toEqual(["responses:grok-4"])
expect(result.language).toBeDefined()
}),
)
it.effect("ignores non-xAI providers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* plugin.add(XAIPlugin)
const result = yield* plugin.trigger(
"aisdk.language",
{
model: new ModelV2.Info({ ...model, providerID: ProviderV2.ID.openai }),
sdk: fakeSelectorSdk(calls),
options: {},
},
{},
)
expect(calls).toEqual([])
expect(result.language).toBeUndefined()
}),
)
})

View file

@ -0,0 +1,103 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux"
import { expectPluginRegistered, it, provider } from "./provider-helper"
describe("ZenmuxPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() =>
expectPluginRegistered(
ProviderPlugins.map((item) => item.id),
"zenmux",
),
),
)
it.effect("applies the exact legacy Zenmux headers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(ZenmuxPlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("zenmux"), cancel: false })
expect(result.provider.options.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" })
expect(Object.keys(result.provider.options.headers).sort()).toEqual(["HTTP-Referer", "X-Title"])
expect(result.cancel).toBe(false)
}),
)
it.effect("merges legacy Zenmux headers with existing headers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(ZenmuxPlugin)
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("zenmux", {
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
}),
cancel: false,
},
)
expect(result.provider.options.headers).toEqual({
Existing: "value",
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
})
}),
)
it.effect("lets configured Zenmux legacy headers override defaults", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(ZenmuxPlugin)
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("zenmux", {
options: {
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
body: {},
aisdk: { provider: {}, request: {} },
},
}),
cancel: false,
},
)
expect(result.provider.options.headers).toEqual({
"HTTP-Referer": "https://example.com/",
"X-Title": "custom-title",
})
}),
)
it.effect("guards legacy Zenmux headers to the exact zenmux provider id", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(ZenmuxPlugin)
const ignored = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("openrouter", {
options: {
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
body: {},
aisdk: { provider: {}, request: {} },
},
}),
cancel: false,
},
)
expect(ignored.provider.options.headers).toEqual({
"HTTP-Referer": "https://example.com/",
"X-Title": "custom-title",
})
}),
)
})