core: expose v2 model listing API (#25821)
This commit is contained in:
parent
bebe5442a5
commit
8345152319
138 changed files with 8188 additions and 302 deletions
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
592
packages/core/test/github-copilot/copilot-chat-model.test.ts
Normal file
592
packages/core/test/github-copilot/copilot-chat-model.test.ts
Normal 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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
188
packages/core/test/plugin/provider-github-copilot.test.ts
Normal file
188
packages/core/test/plugin/provider-github-copilot.test.ts
Normal 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()
|
||||
}),
|
||||
)
|
||||
})
|
||||
199
packages/core/test/v2/catalog.test.ts
Normal file
199
packages/core/test/v2/catalog.test.ts
Normal 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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -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 }
|
||||
},
|
||||
})
|
||||
}
|
||||
67
packages/core/test/v2/plugin/provider-alibaba.test.ts
Normal file
67
packages/core/test/v2/plugin/provider-alibaba.test.ts
Normal 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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
464
packages/core/test/v2/plugin/provider-amazon-bedrock.test.ts
Normal file
464
packages/core/test/v2/plugin/provider-amazon-bedrock.test.ts
Normal 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()
|
||||
}),
|
||||
)
|
||||
})
|
||||
91
packages/core/test/v2/plugin/provider-anthropic.test.ts
Normal file
91
packages/core/test/v2/plugin/provider-anthropic.test.ts
Normal 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"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -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",
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
245
packages/core/test/v2/plugin/provider-azure.test.ts
Normal file
245
packages/core/test/v2/plugin/provider-azure.test.ts
Normal 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"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
102
packages/core/test/v2/plugin/provider-cerebras.test.ts
Normal file
102
packages/core/test/v2/plugin/provider-cerebras.test.ts
Normal 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()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -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)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
@ -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()
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
86
packages/core/test/v2/plugin/provider-cohere.test.ts
Normal file
86
packages/core/test/v2/plugin/provider-cohere.test.ts
Normal 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"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
129
packages/core/test/v2/plugin/provider-deepinfra.test.ts
Normal file
129
packages/core/test/v2/plugin/provider-deepinfra.test.ts
Normal 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"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
172
packages/core/test/v2/plugin/provider-dynamic.test.ts
Normal file
172
packages/core/test/v2/plugin/provider-dynamic.test.ts
Normal 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" } })
|
||||
}),
|
||||
)
|
||||
})
|
||||
87
packages/core/test/v2/plugin/provider-gateway.test.ts
Normal file
87
packages/core/test/v2/plugin/provider-gateway.test.ts
Normal 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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
346
packages/core/test/v2/plugin/provider-gitlab.test.ts
Normal file
346
packages/core/test/v2/plugin/provider-gitlab.test.ts
Normal 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 } }],
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -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()
|
||||
}),
|
||||
)
|
||||
})
|
||||
300
packages/core/test/v2/plugin/provider-google-vertex.test.ts
Normal file
300
packages/core/test/v2/plugin/provider-google-vertex.test.ts
Normal 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"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
70
packages/core/test/v2/plugin/provider-google.test.ts
Normal file
70
packages/core/test/v2/plugin/provider-google.test.ts
Normal 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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
101
packages/core/test/v2/plugin/provider-groq.test.ts
Normal file
101
packages/core/test/v2/plugin/provider-groq.test.ts
Normal 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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
100
packages/core/test/v2/plugin/provider-helper.ts
Normal file
100
packages/core/test/v2/plugin/provider-helper.ts
Normal 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))
|
||||
}
|
||||
90
packages/core/test/v2/plugin/provider-kilo.test.ts
Normal file
90
packages/core/test/v2/plugin/provider-kilo.test.ts
Normal 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({})
|
||||
}),
|
||||
)
|
||||
})
|
||||
63
packages/core/test/v2/plugin/provider-llmgateway.test.ts
Normal file
63
packages/core/test/v2/plugin/provider-llmgateway.test.ts
Normal 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({})
|
||||
}),
|
||||
)
|
||||
})
|
||||
106
packages/core/test/v2/plugin/provider-mistral.test.ts
Normal file
106
packages/core/test/v2/plugin/provider-mistral.test.ts
Normal 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()
|
||||
}),
|
||||
)
|
||||
})
|
||||
41
packages/core/test/v2/plugin/provider-nvidia.test.ts
Normal file
41
packages/core/test/v2/plugin/provider-nvidia.test.ts
Normal 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({})
|
||||
}),
|
||||
)
|
||||
})
|
||||
101
packages/core/test/v2/plugin/provider-openai-compatible.test.ts
Normal file
101
packages/core/test/v2/plugin/provider-openai-compatible.test.ts
Normal 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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
100
packages/core/test/v2/plugin/provider-openai.test.ts
Normal file
100
packages/core/test/v2/plugin/provider-openai.test.ts
Normal 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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
195
packages/core/test/v2/plugin/provider-opencode.test.ts
Normal file
195
packages/core/test/v2/plugin/provider-opencode.test.ts
Normal 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)),
|
||||
)
|
||||
})
|
||||
105
packages/core/test/v2/plugin/provider-openrouter.test.ts
Normal file
105
packages/core/test/v2/plugin/provider-openrouter.test.ts
Normal 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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
107
packages/core/test/v2/plugin/provider-perplexity.test.ts
Normal file
107
packages/core/test/v2/plugin/provider-perplexity.test.ts
Normal 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()
|
||||
}),
|
||||
)
|
||||
})
|
||||
127
packages/core/test/v2/plugin/provider-sap-ai-core.test.ts
Normal file
127
packages/core/test/v2/plugin/provider-sap-ai-core.test.ts
Normal 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()
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
97
packages/core/test/v2/plugin/provider-togetherai.test.ts
Normal file
97
packages/core/test/v2/plugin/provider-togetherai.test.ts
Normal 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"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
86
packages/core/test/v2/plugin/provider-venice.test.ts
Normal file
86
packages/core/test/v2/plugin/provider-venice.test.ts
Normal 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()
|
||||
}),
|
||||
)
|
||||
})
|
||||
62
packages/core/test/v2/plugin/provider-vercel.test.ts
Normal file
62
packages/core/test/v2/plugin/provider-vercel.test.ts
Normal 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({})
|
||||
}),
|
||||
)
|
||||
})
|
||||
115
packages/core/test/v2/plugin/provider-xai.test.ts
Normal file
115
packages/core/test/v2/plugin/provider-xai.test.ts
Normal 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()
|
||||
}),
|
||||
)
|
||||
})
|
||||
103
packages/core/test/v2/plugin/provider-zenmux.test.ts
Normal file
103
packages/core/test/v2/plugin/provider-zenmux.test.ts
Normal 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",
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue