Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98aa4623c7 | ||
|
|
f1e66751d5 |
@@ -33,9 +33,8 @@ runs:
|
||||
shell: bash
|
||||
run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore Bun dependencies
|
||||
id: bun-cache
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ${{ steps.cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
@@ -57,10 +56,3 @@ runs:
|
||||
bun install ${{ inputs.install-flags }}
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
- name: Save Bun dependencies
|
||||
if: steps.bun-cache.outputs.cache-hit != 'true' && github.event_name != 'pull_request' && github.event_name != 'pull_request_target'
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ${{ steps.cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
|
||||
@@ -244,9 +244,9 @@ jobs:
|
||||
- host: "blacksmith-4vcpu-ubuntu-2404"
|
||||
target: x86_64-unknown-linux-gnu
|
||||
platform_flag: --linux
|
||||
- host: "blacksmith-4vcpu-ubuntu-2404-arm"
|
||||
- host: "blacksmith-4vcpu-ubuntu-2404"
|
||||
target: aarch64-unknown-linux-gnu
|
||||
platform_flag: --linux --arm64
|
||||
platform_flag: --linux
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
@@ -73,29 +73,6 @@ function foo() {
|
||||
}
|
||||
```
|
||||
|
||||
### Complex Logic
|
||||
|
||||
When a function has several validation branches or supporting details, make the main function read as the happy path and move supporting details into small helpers below it.
|
||||
|
||||
```ts
|
||||
// Good
|
||||
export function loadThing(input: unknown) {
|
||||
const config = requireConfig(input)
|
||||
const metadata = readMetadata(input)
|
||||
return createThing({ config, metadata })
|
||||
}
|
||||
|
||||
function requireConfig(input: unknown) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
- Keep helpers close to the code they support, below the main export when that improves readability.
|
||||
- Do not over-abstract simple expressions into many single-use helpers; extract only when it names a real concept like `requireConfig` or `readMetadata`.
|
||||
- Do not return `Effect` from helpers unless they actually perform effectful work. Synchronous parsing, validation, and option building should stay synchronous.
|
||||
- Prefer Effect schema helpers such as `Schema.UnknownFromJsonString` and `Schema.decodeUnknownOption` over manual `JSON.parse` wrapped in `Effect.try` when parsing untrusted JSON strings.
|
||||
- Add comments for non-obvious constraints and surprising behavior, not for obvious assignments or control flow.
|
||||
|
||||
### Schema Definitions (Drizzle)
|
||||
|
||||
Use snake_case for field names so column names don't need to be redefined as strings.
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
exact = true
|
||||
# Only install newly resolved package versions published at least 3 days ago.
|
||||
minimumReleaseAge = 259200
|
||||
minimumReleaseAgeExcludes = ["@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-x64", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid"]
|
||||
|
||||
[test]
|
||||
root = "./do-not-run-tests-from-root"
|
||||
|
||||
@@ -293,13 +293,3 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
////////////////
|
||||
// HELPERS
|
||||
////////////////
|
||||
|
||||
export const stat = new sst.cloudflare.Worker("Stat", {
|
||||
handler: "packages/console/function/src/stat.ts",
|
||||
link: [database],
|
||||
url: true,
|
||||
})
|
||||
|
||||
@@ -111,34 +111,6 @@ const providerHttpErrorsQuery = (product: "go" | "zen") => {
|
||||
}).json
|
||||
}
|
||||
|
||||
const modelLowTpsQuery = (product: "go" | "zen") => {
|
||||
const filters = [
|
||||
{ column: "model", op: "exists" },
|
||||
{ column: "event_type", op: "=", value: "completions" },
|
||||
{ column: "user_agent", op: "contains", value: "opencode" },
|
||||
{ column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" },
|
||||
{ column: "status", op: ">=", value: "200" },
|
||||
{ column: "status", op: "<", value: "400" },
|
||||
{ column: "tps.output", op: "exists" },
|
||||
]
|
||||
|
||||
return honeycomb.getQuerySpecificationOutput({
|
||||
breakdowns: ["model"],
|
||||
calculations: [
|
||||
{ op: "COUNT", name: "TOTAL", filterCombination: "AND", filters },
|
||||
{
|
||||
op: "P50",
|
||||
name: "TPS",
|
||||
column: "tps.output",
|
||||
filterCombination: "AND",
|
||||
filters,
|
||||
},
|
||||
],
|
||||
formulas: [{ name: "LOW_TPS", expression: "IF(GTE($TOTAL, 100), $TPS, 999)" }],
|
||||
timeRange: 1800,
|
||||
}).json
|
||||
}
|
||||
|
||||
new honeycomb.Trigger("IncreasedModelHttpErrorsGo", {
|
||||
name: "Increased Model HTTP Errors [Go]",
|
||||
description,
|
||||
@@ -177,44 +149,6 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsZen", {
|
||||
],
|
||||
})
|
||||
|
||||
new honeycomb.Trigger("LowModelTpsGo", {
|
||||
name: "Low Model TPS [Go]",
|
||||
description,
|
||||
queryJson: modelLowTpsQuery("go"),
|
||||
alertType: "on_change",
|
||||
frequency: 600,
|
||||
thresholds: [{ op: "<=", value: 10, exceededLimit: 1 }],
|
||||
recipients: [
|
||||
{
|
||||
id: webhookRecipient.id,
|
||||
notificationDetails: [
|
||||
{
|
||||
variables: [{ name: "type", value: "model_low_tps" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
new honeycomb.Trigger("LowModelTpsZen", {
|
||||
name: "Low Model TPS [Zen]",
|
||||
description,
|
||||
queryJson: modelLowTpsQuery("zen"),
|
||||
alertType: "on_change",
|
||||
frequency: 600,
|
||||
thresholds: [{ op: "<=", value: 10, exceededLimit: 1 }],
|
||||
recipients: [
|
||||
{
|
||||
id: webhookRecipient.id,
|
||||
notificationDetails: [
|
||||
{
|
||||
variables: [{ name: "type", value: "model_low_tps" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
new honeycomb.Trigger("IncreasedProviderHttpErrorsGo", {
|
||||
name: "Increased Provider HTTP Errors [Go]",
|
||||
description,
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-QIj9PhOXR/GngV/dPjCF7n5rKro2fcTNGzJ47a41Z2Q=",
|
||||
"aarch64-linux": "sha256-fQl7BjjTYtRKT3HRVhubaIVww/puUFSTzVV5bTy8II8=",
|
||||
"aarch64-darwin": "sha256-81IAmdjiYZz8IgMJt0+VxzdOS80gTHc5SendwEW/vD4=",
|
||||
"x86_64-darwin": "sha256-5OMX4VVBMfEmkYvzd09oksAt5hKkxDs84miO804LBI8="
|
||||
"x86_64-linux": "sha256-MUHog06sZEi6bXR1m8exdkjSNW9bHEv9bPQXACJ7SFw=",
|
||||
"aarch64-linux": "sha256-3dwdZ3It++OsdGT8xMOQ10Arz8eeODp/LXOrI4DLEhY=",
|
||||
"aarch64-darwin": "sha256-TmUPGDCewjsrT13npVH6B55J43NKKut67p/HgPJpQNM=",
|
||||
"x86_64-darwin": "sha256-j8I7t3MZoUQUMFRWyaFO75TRbAw5TauSZAa4yKOHFMA="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-6
@@ -35,9 +35,9 @@
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.2.9",
|
||||
"@opentui/keymap": "0.2.9",
|
||||
"@opentui/solid": "0.2.9",
|
||||
"@opentui/core": "0.2.6",
|
||||
"@opentui/keymap": "0.2.6",
|
||||
"@opentui/solid": "0.2.6",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
@@ -128,9 +128,6 @@
|
||||
"electron"
|
||||
],
|
||||
"overrides": {
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.48",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -240,7 +240,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return paths
|
||||
})
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const working = createMemo(() => sync.data.session_working(params.id ?? ""))
|
||||
const status = createMemo(
|
||||
() =>
|
||||
sync.data.session_status[params.id ?? ""] ?? {
|
||||
type: "idle",
|
||||
},
|
||||
)
|
||||
const working = createMemo(() => status()?.type !== "idle")
|
||||
const imageAttachments = createMemo(() =>
|
||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||
)
|
||||
|
||||
@@ -208,10 +208,6 @@ export function createChildStoreManager(input: {
|
||||
session: [],
|
||||
sessionTotal: 0,
|
||||
session_status: {},
|
||||
session_working(id: string) {
|
||||
const type = this.session_status[id]?.type
|
||||
return (type ?? "idle") !== "idle"
|
||||
},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
|
||||
@@ -46,7 +46,6 @@ export type State = {
|
||||
session_status: {
|
||||
[sessionID: string]: SessionStatus
|
||||
}
|
||||
session_working(id: string): boolean
|
||||
session_diff: {
|
||||
[sessionID: string]: SnapshotFileDiff[]
|
||||
}
|
||||
|
||||
@@ -166,7 +166,18 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
})
|
||||
const isWorking = createMemo(() => {
|
||||
if (hasPermissions()) return false
|
||||
return sessionStore.session_working(props.session.id)
|
||||
const pending = (sessionStore.message[props.session.id] ?? []).findLast(
|
||||
(message) =>
|
||||
message.role === "assistant" &&
|
||||
typeof (message as { time?: { completed?: unknown } }).time?.completed !== "number",
|
||||
)
|
||||
const status = sessionStore.session_status[props.session.id]
|
||||
return (
|
||||
pending !== undefined ||
|
||||
status?.type === "busy" ||
|
||||
status?.type === "retry" ||
|
||||
(status !== undefined && status.type !== "idle")
|
||||
)
|
||||
})
|
||||
|
||||
const tint = createMemo(() => messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent))
|
||||
|
||||
@@ -305,7 +305,7 @@ export const SortableProject = (props: {
|
||||
const isWorking = createMemo(() =>
|
||||
dirs().some((directory) => {
|
||||
const [store] = globalSync.child(directory, { bootstrap: false })
|
||||
return Object.keys(store.session_status).some((id) => store.session_working(id))
|
||||
return Object.values(store.session_status).some((status) => status?.type === "busy" || status?.type === "retry")
|
||||
}),
|
||||
)
|
||||
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
|
||||
|
||||
@@ -1496,7 +1496,12 @@ export default function Page() {
|
||||
return out
|
||||
})
|
||||
|
||||
const busy = (sessionID: string) => sync.data.session_working(sessionID)
|
||||
const busy = (sessionID: string) => {
|
||||
if ((sync.data.session_status[sessionID] ?? { type: "idle" as const }).type !== "idle") return true
|
||||
return (sync.data.message[sessionID] ?? []).some(
|
||||
(item) => item.role === "assistant" && typeof item.time.completed !== "number",
|
||||
)
|
||||
}
|
||||
|
||||
const queuedFollowups = createMemo(() => {
|
||||
const id = params.id
|
||||
|
||||
@@ -57,7 +57,14 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
||||
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
|
||||
)
|
||||
|
||||
const live = createMemo(() => sync.data.session_working(params.id ?? "") || blocked())
|
||||
const status = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return idle
|
||||
return sync.data.session_status[id] ?? idle
|
||||
})
|
||||
|
||||
const busy = createMemo(() => status().type !== "idle")
|
||||
const live = createMemo(() => busy() || blocked())
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
responding: undefined as string | undefined,
|
||||
|
||||
@@ -32,7 +32,7 @@ export interface SessionReviewTabProps {
|
||||
focusedComment?: { file: string; id: string } | null
|
||||
onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
|
||||
focusedFile?: string
|
||||
onScrollRef?: (el: HTMLDivElement | undefined) => void
|
||||
onScrollRef?: (el: HTMLDivElement) => void
|
||||
commentMentions?: {
|
||||
items: (query: string) => string[] | Promise<string[]>
|
||||
}
|
||||
@@ -126,7 +126,6 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
||||
|
||||
onCleanup(() => {
|
||||
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
|
||||
props.onScrollRef?.(undefined)
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -221,241 +221,239 @@ export function SessionSidePanel(props: {
|
||||
}}
|
||||
style={{ width: panelWidth() }}
|
||||
>
|
||||
<Show when={open()}>
|
||||
<div class="size-full flex border-l border-border-weaker-base">
|
||||
<div
|
||||
aria-hidden={!reviewOpen()}
|
||||
inert={!reviewOpen()}
|
||||
class="relative min-w-0 h-full flex-1 overflow-hidden bg-background-base"
|
||||
classList={{
|
||||
"pointer-events-none": !reviewOpen(),
|
||||
}}
|
||||
>
|
||||
<div class="size-full min-w-0 h-full bg-background-base">
|
||||
<DragDropProvider
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<Tabs value={activeTab()} onChange={openTab}>
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List
|
||||
ref={(el: HTMLDivElement) => {
|
||||
const stop = createFileTabListSync({ el, contextOpen })
|
||||
onCleanup(stop)
|
||||
}}
|
||||
>
|
||||
<Show when={reviewTab() && props.canReview()}>
|
||||
<Tabs.Trigger value="review">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div>{language.t("session.tab.review")}</div>
|
||||
<Show when={props.hasReview()}>
|
||||
<div>{props.reviewCount()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<Show when={contextOpen()}>
|
||||
<Tabs.Trigger
|
||||
value="context"
|
||||
closeButton={
|
||||
<TooltipKeybind
|
||||
title={language.t("common.closeTab")}
|
||||
keybind={command.keybind("tab.close")}
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
variant="ghost"
|
||||
class="h-5 w-5"
|
||||
onClick={() => tabs().close("context")}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
hideCloseButton
|
||||
onMiddleClick={() => tabs().close("context")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<SessionContextUsage variant="indicator" />
|
||||
<div>{language.t("session.tab.context")}</div>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<SortableProvider ids={openedTabs()}>
|
||||
<For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
|
||||
</SortableProvider>
|
||||
<div class="bg-background-stronger h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3">
|
||||
<TooltipKeybind
|
||||
title={language.t("command.file.open")}
|
||||
keybind={command.keybind("file.open")}
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
class="!rounded-md"
|
||||
onClick={() => {
|
||||
void import("@/components/dialog-select-file").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
|
||||
})
|
||||
}}
|
||||
aria-label={language.t("command.file.open")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
|
||||
<Show when={reviewTab() && props.canReview()}>
|
||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={reviewOpen() && activeTab() === "review"}>{props.reviewPanel()}</Show>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={activeTab() === "empty"}>
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">
|
||||
{language.t("session.files.selectToOpen")}
|
||||
</div>
|
||||
<div class="size-full flex border-l border-border-weaker-base">
|
||||
<div
|
||||
aria-hidden={!reviewOpen()}
|
||||
inert={!reviewOpen()}
|
||||
class="relative min-w-0 h-full flex-1 overflow-hidden bg-background-base"
|
||||
classList={{
|
||||
"pointer-events-none": !reviewOpen(),
|
||||
}}
|
||||
>
|
||||
<div class="size-full min-w-0 h-full bg-background-base">
|
||||
<DragDropProvider
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<Tabs value={activeTab()} onChange={openTab}>
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List
|
||||
ref={(el: HTMLDivElement) => {
|
||||
const stop = createFileTabListSync({ el, contextOpen })
|
||||
onCleanup(stop)
|
||||
}}
|
||||
>
|
||||
<Show when={reviewTab() && props.canReview()}>
|
||||
<Tabs.Trigger value="review">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div>{language.t("session.tab.review")}</div>
|
||||
<Show when={props.hasReview()}>
|
||||
<div>{props.reviewCount()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<Show when={contextOpen()}>
|
||||
<Tabs.Trigger
|
||||
value="context"
|
||||
closeButton={
|
||||
<TooltipKeybind
|
||||
title={language.t("common.closeTab")}
|
||||
keybind={command.keybind("tab.close")}
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
variant="ghost"
|
||||
class="h-5 w-5"
|
||||
onClick={() => tabs().close("context")}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
hideCloseButton
|
||||
onMiddleClick={() => tabs().close("context")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<SessionContextUsage variant="indicator" />
|
||||
<div>{language.t("session.tab.context")}</div>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<SortableProvider ids={openedTabs()}>
|
||||
<For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
|
||||
</SortableProvider>
|
||||
<div class="bg-background-stronger h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3">
|
||||
<TooltipKeybind
|
||||
title={language.t("command.file.open")}
|
||||
keybind={command.keybind("file.open")}
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
class="!rounded-md"
|
||||
onClick={() => {
|
||||
void import("@/components/dialog-select-file").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
|
||||
})
|
||||
}}
|
||||
aria-label={language.t("command.file.open")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
|
||||
<Show when={reviewTab() && props.canReview()}>
|
||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={activeTab() === "empty"}>
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">
|
||||
{language.t("session.files.selectToOpen")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Tabs.Content>
|
||||
|
||||
<Show when={contextOpen()}>
|
||||
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={activeTab() === "context"}>
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<SessionContextTab />
|
||||
</div>
|
||||
</Show>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Show when={contextOpen()}>
|
||||
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={activeTab() === "context"}>
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<SessionContextTab />
|
||||
</div>
|
||||
</Show>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Show when={activeFileTab()} keyed>
|
||||
{(tab) => <FileTabContent tab={tab} />}
|
||||
</Show>
|
||||
</Tabs>
|
||||
<DragOverlay>
|
||||
<Show when={store.activeDraggable} keyed>
|
||||
{(tab) => {
|
||||
const path = file.pathFromTab(tab)
|
||||
return (
|
||||
<div data-component="tabs-drag-preview">
|
||||
<Show when={path}>{(p) => <FileVisual active path={p()} />}</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
</div>
|
||||
<Show when={activeFileTab()} keyed>
|
||||
{(tab) => <FileTabContent tab={tab} />}
|
||||
</Show>
|
||||
</Tabs>
|
||||
<DragOverlay>
|
||||
<Show when={store.activeDraggable} keyed>
|
||||
{(tab) => {
|
||||
const path = file.pathFromTab(tab)
|
||||
return (
|
||||
<div data-component="tabs-drag-preview">
|
||||
<Show when={path}>{(p) => <FileVisual active path={p()} />}</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={shown()}>
|
||||
<Show when={shown()}>
|
||||
<div
|
||||
id="file-tree-panel"
|
||||
aria-hidden={!fileOpen()}
|
||||
inert={!fileOpen()}
|
||||
class="relative min-w-0 h-full shrink-0 overflow-hidden"
|
||||
classList={{
|
||||
"pointer-events-none": !fileOpen(),
|
||||
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!props.size.active(),
|
||||
}}
|
||||
style={{ width: treeWidth() }}
|
||||
>
|
||||
<div
|
||||
id="file-tree-panel"
|
||||
aria-hidden={!fileOpen()}
|
||||
inert={!fileOpen()}
|
||||
class="relative min-w-0 h-full shrink-0 overflow-hidden"
|
||||
classList={{
|
||||
"pointer-events-none": !fileOpen(),
|
||||
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!props.size.active(),
|
||||
}}
|
||||
style={{ width: treeWidth() }}
|
||||
class="h-full flex flex-col overflow-hidden group/filetree"
|
||||
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
|
||||
>
|
||||
<div
|
||||
class="h-full flex flex-col overflow-hidden group/filetree"
|
||||
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
|
||||
<Tabs
|
||||
variant="pill"
|
||||
value={fileTreeTab()}
|
||||
onChange={setFileTreeTabValue}
|
||||
class="h-full"
|
||||
data-scope="filetree"
|
||||
>
|
||||
<Tabs
|
||||
variant="pill"
|
||||
value={fileTreeTab()}
|
||||
onChange={setFileTreeTabValue}
|
||||
class="h-full"
|
||||
data-scope="filetree"
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{props.reviewCount()}{" "}
|
||||
{language.t(
|
||||
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
|
||||
)}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{language.t("session.files.all")}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={props.hasReview() || !props.diffsReady()}>
|
||||
<Show
|
||||
when={props.diffsReady()}
|
||||
fallback={
|
||||
<div class="px-2 py-2 text-12-regular text-text-weak">
|
||||
{language.t("common.loading")}
|
||||
{language.t("common.loading.ellipsis")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FileTree
|
||||
path=""
|
||||
class="pt-3"
|
||||
allowed={diffFiles()}
|
||||
kinds={kinds()}
|
||||
draggable={false}
|
||||
active={props.activeDiff}
|
||||
onFileClick={(node) => props.focusReviewDiff(node.path)}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
|
||||
<Match when={true}>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{props.reviewCount()}{" "}
|
||||
{language.t(
|
||||
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
|
||||
)}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{language.t("session.files.all")}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={props.hasReview() || !props.diffsReady()}>
|
||||
<Show
|
||||
when={props.diffsReady()}
|
||||
fallback={
|
||||
<div class="px-2 py-2 text-12-regular text-text-weak">
|
||||
{language.t("common.loading")}
|
||||
{language.t("common.loading.ellipsis")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FileTree
|
||||
path=""
|
||||
class="pt-3"
|
||||
modified={diffFiles()}
|
||||
allowed={diffFiles()}
|
||||
kinds={kinds()}
|
||||
onFileClick={(node) => openTab(file.tab(node.path))}
|
||||
draggable={false}
|
||||
active={props.activeDiff}
|
||||
onFileClick={(node) => props.focusReviewDiff(node.path)}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
<Show when={fileOpen()}>
|
||||
<div onPointerDown={() => props.size.start()}>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
edge="start"
|
||||
size={layout.fileTree.width()}
|
||||
min={200}
|
||||
max={480}
|
||||
onResize={(width) => {
|
||||
props.size.touch()
|
||||
layout.fileTree.resize(width)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
|
||||
<Match when={true}>
|
||||
<FileTree
|
||||
path=""
|
||||
class="pt-3"
|
||||
modified={diffFiles()}
|
||||
kinds={kinds()}
|
||||
onFileClick={(node) => openTab(file.tab(node.path))}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={fileOpen()}>
|
||||
<div onPointerDown={() => props.size.start()}>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
edge="start"
|
||||
size={layout.fileTree.width()}
|
||||
min={200}
|
||||
max={480}
|
||||
onResize={(width) => {
|
||||
props.size.touch()
|
||||
layout.fileTree.resize(width)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</aside>
|
||||
</Show>
|
||||
)
|
||||
|
||||
@@ -75,6 +75,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" ||
|
||||
settings.general.showFileTree()
|
||||
|
||||
const idle = { type: "idle" as const }
|
||||
const status = () => sync.data.session_status[params.id ?? ""] ?? idle
|
||||
const messages = () => {
|
||||
const id = params.id
|
||||
if (!id) return []
|
||||
@@ -288,7 +290,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
if (sync.data.session_working(params.id ?? "")) {
|
||||
if (status().type !== "idle") {
|
||||
await sdk.client.session.abort({ sessionID }).catch(() => {})
|
||||
}
|
||||
|
||||
|
||||
@@ -128,17 +128,4 @@ describe("formatServerError", () => {
|
||||
["Modelo nao encontrado: x/y", "Voce quis dizer: x/y2, x/y3", "Revise provider/model no config"].join("\n"),
|
||||
)
|
||||
})
|
||||
|
||||
test("unwraps SDK-wrapped errors from cause.body", () => {
|
||||
const body = {
|
||||
name: "ConfigInvalidError",
|
||||
data: {
|
||||
message: "Missing host",
|
||||
},
|
||||
} satisfies ConfigInvalidError
|
||||
|
||||
const wrapped = new Error("ConfigInvalidError", { cause: { body, status: 400 } })
|
||||
|
||||
expect(formatServerError(wrapped, language.t)).toBe("Arquivo de config em config invalido: Missing host")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,22 +26,14 @@ function tr(translator: Translator | undefined, key: string, text: string, vars?
|
||||
}
|
||||
|
||||
export function formatServerError(error: unknown, translate?: Translator, fallback?: string) {
|
||||
const unwrapped = unwrapNamedError(error)
|
||||
if (isConfigInvalidErrorLike(unwrapped)) return parseReadableConfigInvalidError(unwrapped, translate)
|
||||
if (isProviderModelNotFoundErrorLike(unwrapped)) return parseReadableProviderModelNotFoundError(unwrapped, translate)
|
||||
if (isConfigInvalidErrorLike(error)) return parseReadableConfigInvalidError(error, translate)
|
||||
if (isProviderModelNotFoundErrorLike(error)) return parseReadableProviderModelNotFoundError(error, translate)
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
if (typeof error === "string" && error) return error
|
||||
if (fallback) return fallback
|
||||
return tr(translate, "error.chain.unknown", "Unknown error")
|
||||
}
|
||||
|
||||
function unwrapNamedError(error: unknown): unknown {
|
||||
if (error instanceof Error && error.cause && typeof error.cause === "object" && "body" in error.cause) {
|
||||
return (error.cause as Record<string, unknown>).body
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
function isConfigInvalidErrorLike(error: unknown): error is ConfigInvalidError {
|
||||
if (typeof error !== "object" || error === null) return false
|
||||
const o = error as Record<string, unknown>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.48",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -12,22 +12,13 @@ const basePayload = z.object({
|
||||
url: z.string(),
|
||||
})
|
||||
|
||||
const groups = z
|
||||
.object({
|
||||
result: z.union([z.number(), z.string()]).nullish(),
|
||||
group: z.object({ key: z.string(), value: z.string() }).array(),
|
||||
})
|
||||
.array()
|
||||
const groups = z.object({ group: z.object({ key: z.string(), value: z.string() }).array() }).array()
|
||||
|
||||
const honeycombWebhookPayload = z.discriminatedUnion("type", [
|
||||
basePayload.extend({
|
||||
type: z.literal("model_http_errors"),
|
||||
groups,
|
||||
}),
|
||||
basePayload.extend({
|
||||
type: z.literal("model_low_tps"),
|
||||
groups,
|
||||
}),
|
||||
basePayload.extend({
|
||||
type: z.literal("provider_http_errors"),
|
||||
groups,
|
||||
@@ -38,25 +29,14 @@ const honeycombWebhookPayload = z.discriminatedUnion("type", [
|
||||
])
|
||||
|
||||
const postDiscordMessage = async (payload: z.infer<typeof honeycombWebhookPayload>) => {
|
||||
const names =
|
||||
payload.type === "custom"
|
||||
? []
|
||||
: payload.groups.flatMap((item) =>
|
||||
item.group.map((g) => {
|
||||
const result = item.result == null ? undefined : Number(item.result)
|
||||
return `- ${g.value}${
|
||||
result !== undefined && Number.isFinite(result)
|
||||
? payload.type === "model_low_tps"
|
||||
? ` (${Math.round(result)} TPS)`
|
||||
: ` (${Math.round(result * 100)}% errors)`
|
||||
: ""
|
||||
}`
|
||||
}),
|
||||
)
|
||||
const group =
|
||||
payload.type === "model_http_errors" ? "model" : payload.type === "provider_http_errors" ? "provider" : undefined
|
||||
const names = payload.type === "custom" ? [] : payload.groups.flatMap((item) => item.group.map((g) => g.value))
|
||||
|
||||
const content = [
|
||||
`[**${payload.isTest ? "[TEST] " : ""}${payload.name ?? "Honeycomb alert"}**](${payload.url})`,
|
||||
...names,
|
||||
group && names.length > 0 ? `Affected ${group}s:` : undefined,
|
||||
...names.map((name) => `- ${name}`),
|
||||
"",
|
||||
`<@&${DISCORD_ALERT_ROLE_ID}>`,
|
||||
]
|
||||
|
||||
@@ -123,7 +123,7 @@ export async function handler(
|
||||
? createIpRateLimiter(modelInfo.id, modelInfo.rateLimit, ip, input.request)
|
||||
: createKeyRateLimiter(modelInfo.id, modelInfo.rateLimit, zenApiKey, input.request)
|
||||
await rateLimiter?.check()
|
||||
const stickyTracker = createStickyTracker(modelInfo.id, modelInfo.stickyProvider, sessionId)
|
||||
const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId)
|
||||
const stickyProvider = await stickyTracker?.get()
|
||||
const authInfo = await authenticate(modelInfo, zenApiKey)
|
||||
const billingSource = validateBilling(authInfo, modelInfo)
|
||||
@@ -216,7 +216,7 @@ export async function handler(
|
||||
// ie. 400 error is usually provider error like malformed request
|
||||
res.status !== 400 &&
|
||||
// ie. openai 404 error: Item with id 'msg_0ead8b004a3b165d0069436a6b6834819896da85b63b196a3f' not found.
|
||||
!(modelInfo.id.startsWith("gpt-") && res.status === 404) &&
|
||||
res.status !== 404 &&
|
||||
// ie. cannot change codex model providers mid-session
|
||||
modelInfo.stickyProvider !== "strict" &&
|
||||
modelInfo.fallbackProvider &&
|
||||
@@ -238,7 +238,7 @@ export async function handler(
|
||||
dataDumper?.provideRequest(reqBody)
|
||||
|
||||
// Store sticky provider
|
||||
if (res.status === 200) await stickyTracker?.set(providerInfo.id)
|
||||
await stickyTracker?.set(providerInfo.id)
|
||||
|
||||
// Temporarily change 404 to 400 status code b/c solid start automatically override 404 response
|
||||
const resStatus = res.status === 404 ? 400 : res.status
|
||||
@@ -320,7 +320,6 @@ export async function handler(
|
||||
await modelTpsLimiter?.track(
|
||||
providerInfo.id,
|
||||
providerInfo.model,
|
||||
providerInfo.tpsGoal,
|
||||
timestampFirstByte,
|
||||
timestampLastByte,
|
||||
usageInfo,
|
||||
@@ -526,7 +525,7 @@ export async function handler(
|
||||
})
|
||||
.filter((provider) => {
|
||||
if (!provider.tpsGoal) return true
|
||||
const isLowTps = modelTpsLimits?.[`${provider.id}/${provider.model}/${provider.tpsGoal}`] ?? false
|
||||
const isLowTps = modelTpsLimits?.[`${provider.id}/${provider.model}`] ?? false
|
||||
return !isLowTps
|
||||
})
|
||||
.map((provider) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { UsageInfo } from "./provider/provider"
|
||||
export function createModelTpsLimiter(providers: { id: string; model: string; tpsGoal?: number }[]) {
|
||||
const tpsGoals = Object.fromEntries(
|
||||
providers.flatMap((p) => {
|
||||
return p.tpsGoal ? [[`${p.id}/${p.model}/${p.tpsGoal}`, p.tpsGoal]] : []
|
||||
return p.tpsGoal ? [[`${p.id}/${p.model}`, p.tpsGoal]] : []
|
||||
}),
|
||||
)
|
||||
const ids = Object.keys(tpsGoals)
|
||||
@@ -56,17 +56,11 @@ export function createModelTpsLimiter(providers: { id: string; model: string; tp
|
||||
}),
|
||||
)
|
||||
},
|
||||
track: async (
|
||||
provider: string,
|
||||
model: string,
|
||||
tpsGoal: number | undefined,
|
||||
tsFirstByte: number,
|
||||
tsLastByte: number,
|
||||
usageInfo: UsageInfo,
|
||||
) => {
|
||||
if (!tpsGoal) return
|
||||
const id = `${provider}/${model}/${tpsGoal}`
|
||||
track: async (provider: string, model: string, tsFirstByte: number, tsLastByte: number, usageInfo: UsageInfo) => {
|
||||
const id = `${provider}/${model}`
|
||||
if (!ids.includes(id)) return
|
||||
const tpsGoal = tpsGoals[id]
|
||||
if (!tpsGoal) return
|
||||
if (tsFirstByte <= 0 || tsLastByte <= 0) return
|
||||
const tokens = usageInfo.outputTokens
|
||||
if (tokens <= 10) return
|
||||
|
||||
@@ -1,42 +1,16 @@
|
||||
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { ModelStickyProviderTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
|
||||
export function createStickyTracker(modelId: string, stickyProvider: "strict" | "prefer" | undefined, session: string) {
|
||||
export function createStickyTracker(stickyProvider: "strict" | "prefer" | undefined, session: string) {
|
||||
if (!stickyProvider) return
|
||||
if (!session) return
|
||||
const id = `${modelId}/${session}`
|
||||
let _providerId: string | undefined
|
||||
const key = `sticky:${session}`
|
||||
|
||||
return {
|
||||
get: async () => {
|
||||
const data = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
providerId: ModelStickyProviderTable.providerId,
|
||||
})
|
||||
.from(ModelStickyProviderTable)
|
||||
.where(eq(ModelStickyProviderTable.id, id))
|
||||
.limit(1),
|
||||
)
|
||||
_providerId = data[0]?.providerId
|
||||
return _providerId
|
||||
return await Resource.GatewayKv.get(key)
|
||||
},
|
||||
set: async (providerId: string) => {
|
||||
if (_providerId === providerId) return
|
||||
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.insert(ModelStickyProviderTable)
|
||||
.values({
|
||||
id,
|
||||
providerId,
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
providerId,
|
||||
},
|
||||
}),
|
||||
)
|
||||
await Resource.GatewayKv.put(key, providerId, { expirationTtl: 86400 })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
CREATE TABLE `model_sticky_provider` (
|
||||
`id` varchar(255) PRIMARY KEY,
|
||||
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
|
||||
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
`time_deleted` timestamp(3),
|
||||
`provider_id` varchar(255) NOT NULL
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.48",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -51,13 +51,3 @@ export const ModelTpsRateLimitTable = mysqlTable(
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.id, table.interval] })],
|
||||
)
|
||||
|
||||
export const ModelStickyProviderTable = mysqlTable(
|
||||
"model_sticky_provider",
|
||||
{
|
||||
id: varchar("id", { length: 255 }).notNull(),
|
||||
...timestamps,
|
||||
providerId: varchar("provider_id", { length: 255 }).notNull(),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.id] })],
|
||||
)
|
||||
|
||||
Vendored
-1
@@ -298,7 +298,6 @@ declare module "sst" {
|
||||
"EnterpriseStorage": cloudflare.R2Bucket
|
||||
"GatewayKv": cloudflare.KVNamespace
|
||||
"LogProcessor": cloudflare.Service
|
||||
"Stat": cloudflare.Service
|
||||
"ZenData": cloudflare.R2Bucket
|
||||
"ZenDataNew": cloudflare.R2Bucket
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.48",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { and, Database, inArray } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { ModelTpsRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
||||
|
||||
type Result = Record<string, { interval: number; qualify: number; unqualify: number }[]>
|
||||
|
||||
export default {
|
||||
async fetch(request: Request) {
|
||||
if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405 })
|
||||
|
||||
const body = (await request.json()) as { ids: string[] }
|
||||
const ids = body.ids
|
||||
if (ids.length === 0) return Response.json({} satisfies Result)
|
||||
|
||||
const toInterval = (date: Date) =>
|
||||
parseInt(
|
||||
date
|
||||
.toISOString()
|
||||
.replace(/[^0-9]/g, "")
|
||||
.substring(0, 12),
|
||||
)
|
||||
const now = Date.now()
|
||||
const intervals = Array.from({ length: 30 }, (_, i) => toInterval(new Date(now - i * 60 * 1000)))
|
||||
|
||||
const rows = await Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(ModelTpsRateLimitTable)
|
||||
.where(and(inArray(ModelTpsRateLimitTable.id, ids), inArray(ModelTpsRateLimitTable.interval, intervals))),
|
||||
)
|
||||
|
||||
const rowsByKey = new Map(rows.map((row) => [`${row.id}:${row.interval}`, row]))
|
||||
const result: Result = Object.fromEntries(
|
||||
ids.map((id) => [
|
||||
id,
|
||||
intervals.map((interval) => {
|
||||
const row = rowsByKey.get(`${id}:${interval}`)
|
||||
return { interval, qualify: row?.qualify ?? 0, unqualify: row?.unqualify ?? 0 }
|
||||
}),
|
||||
]),
|
||||
)
|
||||
return Response.json(result)
|
||||
},
|
||||
}
|
||||
-1
@@ -298,7 +298,6 @@ declare module "sst" {
|
||||
"EnterpriseStorage": cloudflare.R2Bucket
|
||||
"GatewayKv": cloudflare.KVNamespace
|
||||
"LogProcessor": cloudflare.Service
|
||||
"Stat": cloudflare.Service
|
||||
"ZenData": cloudflare.R2Bucket
|
||||
"ZenDataNew": cloudflare.R2Bucket
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
-1
@@ -298,7 +298,6 @@ declare module "sst" {
|
||||
"EnterpriseStorage": cloudflare.R2Bucket
|
||||
"GatewayKv": cloudflare.KVNamespace
|
||||
"LogProcessor": cloudflare.Service
|
||||
"Stat": cloudflare.Service
|
||||
"ZenData": cloudflare.R2Bucket
|
||||
"ZenDataNew": cloudflare.R2Bucket
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.49",
|
||||
"version": "1.14.48",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -26,27 +26,6 @@
|
||||
"@types/semver": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.96",
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
"@ai-sdk/google": "3.0.63",
|
||||
"@ai-sdk/google-vertex": "4.0.112",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/openai": "3.0.53",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@ai-sdk/provider-utils": "4.0.23",
|
||||
"@ai-sdk/togetherai": "2.0.41",
|
||||
"@ai-sdk/vercel": "2.0.39",
|
||||
"@ai-sdk/xai": "3.0.82",
|
||||
"@aws-sdk/credential-providers": "3.993.0",
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
@@ -55,21 +34,14 @@
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
|
||||
"@opentelemetry/sdk-trace-base": "2.6.1",
|
||||
"@openrouter/ai-sdk-provider": "2.8.1",
|
||||
"ai-gateway-provider": "3.1.2",
|
||||
"cross-spawn": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"gitlab-ai-provider": "6.6.0",
|
||||
"cross-spawn": "catalog:",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"immer": "11.1.4",
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"semver": "^7.6.3",
|
||||
"venice-ai-sdk-provider": "2.0.1",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"zod": "catalog:"
|
||||
"xdg-basedir": "5.1.0"
|
||||
},
|
||||
"overrides": {
|
||||
"drizzle-orm": "catalog:"
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
export * as AISDK from "./aisdk"
|
||||
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Cause, Context, Effect, Layer, Schema } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
import { PluginV2 } from "./plugin"
|
||||
import { ProviderV2 } from "./provider"
|
||||
|
||||
type SDK = any
|
||||
|
||||
function wrapSSE(res: Response, ms: number, ctl: AbortController) {
|
||||
if (typeof ms !== "number" || ms <= 0) return res
|
||||
if (!res.body) return res
|
||||
if (!res.headers.get("content-type")?.includes("text/event-stream")) return res
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async pull(ctrl) {
|
||||
const part = await new Promise<Awaited<ReturnType<typeof reader.read>>>((resolve, reject) => {
|
||||
const id = setTimeout(() => {
|
||||
const err = new Error("SSE read timed out")
|
||||
ctl.abort(err)
|
||||
void reader.cancel(err)
|
||||
reject(err)
|
||||
}, ms)
|
||||
|
||||
reader.read().then(
|
||||
(part) => {
|
||||
clearTimeout(id)
|
||||
resolve(part)
|
||||
},
|
||||
(err) => {
|
||||
clearTimeout(id)
|
||||
reject(err)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
if (part.done) {
|
||||
ctrl.close()
|
||||
return
|
||||
}
|
||||
|
||||
ctrl.enqueue(part.value)
|
||||
},
|
||||
async cancel(reason) {
|
||||
ctl.abort(reason)
|
||||
await reader.cancel(reason)
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(body, {
|
||||
headers: new Headers(res.headers),
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
})
|
||||
}
|
||||
|
||||
function prepareOptions(model: ModelV2.Info, pkg: string) {
|
||||
const options: Record<string, any> = { name: model.providerID, ...model.options.aisdk.provider }
|
||||
if (model.endpoint.type === "aisdk" && model.endpoint.url) options.baseURL = model.endpoint.url
|
||||
|
||||
const customFetch = options.fetch
|
||||
const chunkTimeout = options.chunkTimeout
|
||||
delete options.chunkTimeout
|
||||
options.fetch = async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
const opts = { ...(init ?? {}) }
|
||||
const signals = [
|
||||
opts.signal,
|
||||
typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined,
|
||||
options.timeout !== undefined && options.timeout !== null && options.timeout !== false
|
||||
? AbortSignal.timeout(options.timeout)
|
||||
: undefined,
|
||||
].filter((item): item is AbortSignal | AbortController => Boolean(item))
|
||||
const chunkAbortCtl = signals.find((item): item is AbortController => item instanceof AbortController)
|
||||
const abortSignals = signals.map((item) => (item instanceof AbortController ? item.signal : item))
|
||||
if (abortSignals.length === 1) opts.signal = abortSignals[0]
|
||||
if (abortSignals.length > 1) opts.signal = AbortSignal.any(abortSignals)
|
||||
|
||||
if ((pkg === "@ai-sdk/openai" || pkg === "@ai-sdk/azure") && opts.body && opts.method === "POST") {
|
||||
const body = JSON.parse(opts.body as string)
|
||||
if (body.store !== true && Array.isArray(body.input)) {
|
||||
for (const item of body.input) {
|
||||
if ("id" in item) delete item.id
|
||||
}
|
||||
opts.body = JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
|
||||
const res = await (typeof customFetch === "function" ? customFetch : fetch)(input, {
|
||||
...opts,
|
||||
timeout: false,
|
||||
})
|
||||
if (!chunkAbortCtl || typeof chunkTimeout !== "number") return res
|
||||
return wrapSSE(res, chunkTimeout, chunkAbortCtl)
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
export class InitError extends Schema.TaggedErrorClass<InitError>()("AISDK.InitError", {
|
||||
providerID: ProviderV2.ID,
|
||||
cause: Schema.Defect,
|
||||
}) {}
|
||||
|
||||
function initError(providerID: ProviderV2.ID) {
|
||||
return Effect.catchCause((cause) => Effect.fail(new InitError({ providerID, cause: Cause.squash(cause) })))
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly language: (model: ModelV2.Info) => Effect.Effect<LanguageModelV3, InitError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/AISDK") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const languages = new Map<string, LanguageModelV3>()
|
||||
const sdks = new Map<string, SDK>()
|
||||
|
||||
return Service.of({
|
||||
language: Effect.fn("AISDK.language")(function* (model) {
|
||||
const key = `${model.providerID}/${model.id}/${model.options.variant ?? "default"}`
|
||||
const existing = languages.get(key)
|
||||
if (existing) return existing
|
||||
if (model.endpoint.type !== "aisdk")
|
||||
return yield* new InitError({
|
||||
providerID: model.providerID,
|
||||
cause: new Error(`Unsupported endpoint ${model.endpoint.type}`),
|
||||
})
|
||||
|
||||
const options = prepareOptions(model, model.endpoint.package)
|
||||
const sdkKey = JSON.stringify({
|
||||
providerID: model.providerID,
|
||||
endpoint: model.endpoint,
|
||||
options,
|
||||
})
|
||||
const sdk =
|
||||
sdks.get(sdkKey) ??
|
||||
(yield* plugin
|
||||
.trigger("aisdk.sdk", { model, package: model.endpoint.package, options }, {})
|
||||
.pipe(initError(model.providerID))).sdk
|
||||
if (!sdk)
|
||||
return yield* new InitError({
|
||||
providerID: model.providerID,
|
||||
cause: new Error("No AISDK provider plugin returned an SDK"),
|
||||
})
|
||||
sdks.set(sdkKey, sdk)
|
||||
const result = yield* plugin
|
||||
.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model,
|
||||
sdk,
|
||||
options,
|
||||
},
|
||||
{},
|
||||
)
|
||||
.pipe(initError(model.providerID))
|
||||
const language = yield* Effect.sync(() => result.language ?? sdk.languageModel(model.apiID)).pipe(
|
||||
initError(model.providerID),
|
||||
)
|
||||
languages.set(key, language)
|
||||
return language
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer))
|
||||
@@ -1,258 +0,0 @@
|
||||
export * as Catalog from "./catalog"
|
||||
|
||||
import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array } from "effect"
|
||||
import { produce, type Draft } from "immer"
|
||||
import { ModelV2 } from "./model"
|
||||
import { PluginV2 } from "./plugin"
|
||||
import { ProviderV2 } from "./provider"
|
||||
|
||||
type ProviderRecord = {
|
||||
provider: ProviderV2.Info
|
||||
models: HashMap.HashMap<ModelV2.ID, ModelV2.Info>
|
||||
}
|
||||
|
||||
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
|
||||
"CatalogV2.ProviderNotFound",
|
||||
{
|
||||
providerID: ProviderV2.ID,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("CatalogV2.ModelNotFound", {
|
||||
providerID: ProviderV2.ID,
|
||||
modelID: ModelV2.ID,
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly provider: {
|
||||
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError>
|
||||
readonly update: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => Effect.Effect<void>
|
||||
readonly all: () => Effect.Effect<ProviderV2.Info[]>
|
||||
readonly available: () => Effect.Effect<ProviderV2.Info[]>
|
||||
}
|
||||
readonly model: {
|
||||
readonly get: (
|
||||
providerID: ProviderV2.ID,
|
||||
modelID: ModelV2.ID,
|
||||
) => Effect.Effect<ModelV2.Info, ProviderNotFoundError | ModelNotFoundError>
|
||||
readonly update: (
|
||||
providerID: ProviderV2.ID,
|
||||
modelID: ModelV2.ID,
|
||||
fn: (model: Draft<ModelV2.Info>) => void,
|
||||
) => Effect.Effect<void, ProviderNotFoundError>
|
||||
readonly all: () => Effect.Effect<ModelV2.Info[]>
|
||||
readonly available: () => Effect.Effect<ModelV2.Info[]>
|
||||
readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>>
|
||||
readonly setDefault: (
|
||||
providerID: ProviderV2.ID,
|
||||
modelID: ModelV2.ID,
|
||||
) => Effect.Effect<void, ProviderNotFoundError | ModelNotFoundError>
|
||||
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<Option.Option<ModelV2.Info>>
|
||||
}
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Catalog") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
let records = HashMap.empty<ProviderV2.ID, ProviderRecord>()
|
||||
let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined
|
||||
const plugin = yield* PluginV2.Service
|
||||
|
||||
const resolve = (model: ModelV2.Info) => {
|
||||
const provider = Option.getOrThrow(HashMap.get(records, model.providerID)).provider
|
||||
const endpoint =
|
||||
model.endpoint.type === "unknown"
|
||||
? provider.endpoint
|
||||
: model.endpoint.type === "aisdk" && provider.endpoint.type === "aisdk" && !model.endpoint.url
|
||||
? { ...model.endpoint, url: provider.endpoint.url }
|
||||
: model.endpoint
|
||||
const options = {
|
||||
headers: {
|
||||
...provider.options.headers,
|
||||
...model.options.headers,
|
||||
},
|
||||
body: {
|
||||
...provider.options.body,
|
||||
...model.options.body,
|
||||
},
|
||||
aisdk: {
|
||||
provider: {
|
||||
...provider.options.aisdk.provider,
|
||||
...model.options.aisdk.provider,
|
||||
},
|
||||
request: model.options.aisdk.request,
|
||||
},
|
||||
variant: model.options.variant,
|
||||
}
|
||||
return new ModelV2.Info({
|
||||
...model,
|
||||
endpoint,
|
||||
options,
|
||||
})
|
||||
}
|
||||
|
||||
function* getRecord(providerID: ProviderV2.ID) {
|
||||
const match = HashMap.get(records, providerID)
|
||||
if (!match.valueOrUndefined) return yield* new ProviderNotFoundError({ providerID })
|
||||
return match.value
|
||||
}
|
||||
|
||||
const result: Interface = {
|
||||
provider: {
|
||||
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
|
||||
const record = yield* getRecord(providerID)
|
||||
return record.provider
|
||||
}),
|
||||
|
||||
update: Effect.fnUntraced(function* (providerID, fn) {
|
||||
const current = Option.getOrUndefined(HashMap.get(records, providerID))
|
||||
const provider = produce(current?.provider ?? ProviderV2.Info.empty(providerID), (draft) => {
|
||||
fn(draft)
|
||||
if (draft.endpoint.type === "aisdk" && typeof draft.options.aisdk.provider.baseURL === "string") {
|
||||
draft.endpoint.url = draft.options.aisdk.provider.baseURL
|
||||
delete draft.options.aisdk.provider.baseURL
|
||||
}
|
||||
})
|
||||
const updated = yield* plugin.trigger("provider.update", {}, { provider, cancel: false })
|
||||
records = HashMap.set(records, providerID, {
|
||||
provider: updated.provider,
|
||||
models: current?.models ?? HashMap.empty<ModelV2.ID, ModelV2.Info>(),
|
||||
})
|
||||
}),
|
||||
|
||||
all: Effect.fn("CatalogV2.provider.all")(function* () {
|
||||
return globalThis.Array.from(HashMap.values(records)).map((record) => record.provider)
|
||||
}),
|
||||
|
||||
available: Effect.fn("CatalogV2.provider.available")(function* () {
|
||||
return globalThis.Array.from(HashMap.values(records))
|
||||
.map((record) => record.provider)
|
||||
.filter((provider) => provider.enabled)
|
||||
}),
|
||||
},
|
||||
|
||||
model: {
|
||||
get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) {
|
||||
const record = yield* getRecord(providerID)
|
||||
const model = Option.getOrUndefined(HashMap.get(record.models, modelID))
|
||||
if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
|
||||
return resolve(model)
|
||||
}),
|
||||
|
||||
update: Effect.fnUntraced(function* (providerID, modelID, fn) {
|
||||
const record = yield* getRecord(providerID)
|
||||
const model = produce(
|
||||
HashMap.get(record.models, modelID).pipe(Option.getOrElse(() => ModelV2.Info.empty(providerID, modelID))),
|
||||
(draft) => {
|
||||
fn(draft)
|
||||
if (draft.endpoint.type === "aisdk" && typeof draft.options.aisdk.provider.baseURL === "string") {
|
||||
draft.endpoint.url = draft.options.aisdk.provider.baseURL
|
||||
delete draft.options.aisdk.provider.baseURL
|
||||
}
|
||||
},
|
||||
)
|
||||
const updated = yield* plugin.trigger("model.update", {}, { model, cancel: false })
|
||||
if (updated.cancel) return
|
||||
records = HashMap.set(records, providerID, {
|
||||
provider: record.provider,
|
||||
models: HashMap.set(
|
||||
record.models,
|
||||
modelID,
|
||||
new ModelV2.Info({ ...updated.model, id: modelID, providerID }),
|
||||
),
|
||||
})
|
||||
return
|
||||
}),
|
||||
|
||||
all: Effect.fn("CatalogV2.model.all")(function* () {
|
||||
return pipe(
|
||||
records,
|
||||
HashMap.toValues,
|
||||
Array.flatMap((record) => HashMap.toValues(record.models)),
|
||||
Array.map(resolve),
|
||||
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
|
||||
)
|
||||
}),
|
||||
|
||||
available: Effect.fn("CatalogV2.model.available")(function* () {
|
||||
return (yield* result.model.all()).filter((model) => {
|
||||
const record = Option.getOrUndefined(HashMap.get(records, model.providerID))
|
||||
return record?.provider.enabled !== false && model.enabled
|
||||
})
|
||||
}),
|
||||
|
||||
default: Effect.fn("CatalogV2.model.default")(function* () {
|
||||
if (defaultModel) {
|
||||
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
|
||||
if (Option.isSome(model) && model.value.enabled) return model
|
||||
}
|
||||
|
||||
return pipe(
|
||||
yield* result.model.available(),
|
||||
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
|
||||
Array.head,
|
||||
)
|
||||
}),
|
||||
|
||||
setDefault: Effect.fn("CatalogV2.model.setDefault")(function* (providerID, modelID) {
|
||||
yield* result.model.get(providerID, modelID)
|
||||
defaultModel = { providerID, modelID }
|
||||
}),
|
||||
|
||||
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
|
||||
const record = Option.getOrUndefined(HashMap.get(records, providerID))
|
||||
if (!record) return Option.none<ModelV2.Info>()
|
||||
|
||||
if (providerID === ProviderV2.ID.opencode) {
|
||||
const gpt5Nano = Option.getOrUndefined(HashMap.get(record.models, ModelV2.ID.make("gpt-5-nano")))
|
||||
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano))
|
||||
}
|
||||
|
||||
const candidates = pipe(
|
||||
HashMap.toValues(record.models),
|
||||
Array.filter(
|
||||
(model) =>
|
||||
model.providerID === providerID &&
|
||||
model.enabled &&
|
||||
model.status === "active" &&
|
||||
model.capabilities.input.some((item) => item.startsWith("text")) &&
|
||||
model.capabilities.output.some((item) => item.startsWith("text")),
|
||||
),
|
||||
Array.map((model) => ({
|
||||
model,
|
||||
cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999,
|
||||
age: (Date.now() - model.time.released.epochMilliseconds) / (1000 * 60 * 60 * 24 * 30),
|
||||
small: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()),
|
||||
})),
|
||||
Array.filter((item) => item.cost > 0 && item.age <= 18),
|
||||
)
|
||||
|
||||
const pick = (items: typeof candidates) => {
|
||||
const maxCost = Math.max(...items.map((item) => item.cost), 0.01)
|
||||
const maxAge = Math.max(...items.map((item) => item.age), 0.01)
|
||||
return pipe(
|
||||
items,
|
||||
Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number),
|
||||
Array.map((item) => resolve(item.model)),
|
||||
Array.head,
|
||||
)
|
||||
}
|
||||
|
||||
return pipe(
|
||||
candidates,
|
||||
Array.filter((item) => item.small),
|
||||
(items) => (items.length > 0 ? pick(items) : pick(candidates)),
|
||||
)
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
return Service.of(result)
|
||||
}),
|
||||
)
|
||||
|
||||
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer))
|
||||
@@ -1,116 +0,0 @@
|
||||
import { DateTime, Schema } from "effect"
|
||||
import { DateTimeUtcFromMillis } from "effect/Schema"
|
||||
import { ProviderV2 } from "./provider"
|
||||
|
||||
export const ID = Schema.String.pipe(Schema.brand("ModelV2.ID"))
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export const VariantID = Schema.String.pipe(Schema.brand("VariantID"))
|
||||
export type VariantID = typeof VariantID.Type
|
||||
|
||||
// Grouping of models, eg claude opus, claude sonnet
|
||||
export const Family = Schema.String.pipe(Schema.brand("Family"))
|
||||
export type Family = typeof Family.Type
|
||||
|
||||
export const Capabilities = Schema.Struct({
|
||||
tools: Schema.Boolean,
|
||||
// mime patterns, image, audio, video/*, text/*
|
||||
input: Schema.String.pipe(Schema.Array),
|
||||
output: Schema.String.pipe(Schema.Array),
|
||||
})
|
||||
export type Capabilities = typeof Capabilities.Type
|
||||
|
||||
export const Cost = Schema.Struct({
|
||||
tier: Schema.Struct({
|
||||
type: Schema.Literal("context"),
|
||||
size: Schema.Int,
|
||||
}).pipe(Schema.optional),
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
cache: Schema.Struct({
|
||||
read: Schema.Finite,
|
||||
write: Schema.Finite,
|
||||
}),
|
||||
})
|
||||
|
||||
export const Ref = Schema.Struct({
|
||||
id: ID,
|
||||
providerID: ProviderV2.ID,
|
||||
variant: VariantID,
|
||||
})
|
||||
export type Ref = typeof Ref.Type
|
||||
|
||||
export class Info extends Schema.Class<Info>("ModelV2.Info")({
|
||||
id: ID,
|
||||
apiID: ID,
|
||||
providerID: ProviderV2.ID,
|
||||
family: Family.pipe(Schema.optional),
|
||||
name: Schema.String,
|
||||
endpoint: ProviderV2.Endpoint,
|
||||
capabilities: Capabilities,
|
||||
options: Schema.Struct({
|
||||
...ProviderV2.Options.fields,
|
||||
variant: Schema.String.pipe(Schema.optional),
|
||||
}),
|
||||
variants: Schema.Struct({
|
||||
id: VariantID,
|
||||
...ProviderV2.Options.fields,
|
||||
}).pipe(Schema.Array),
|
||||
time: Schema.Struct({
|
||||
released: DateTimeUtcFromMillis,
|
||||
}),
|
||||
cost: Cost.pipe(Schema.Array),
|
||||
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
|
||||
enabled: Schema.Boolean,
|
||||
limit: Schema.Struct({
|
||||
context: Schema.Int,
|
||||
input: Schema.Int.pipe(Schema.optional),
|
||||
output: Schema.Int,
|
||||
}),
|
||||
}) {
|
||||
static empty(providerID: ProviderV2.ID, modelID: ID) {
|
||||
return new Info({
|
||||
id: modelID,
|
||||
apiID: modelID,
|
||||
providerID,
|
||||
name: modelID,
|
||||
endpoint: {
|
||||
type: "unknown",
|
||||
},
|
||||
capabilities: {
|
||||
tools: false,
|
||||
input: [],
|
||||
output: [],
|
||||
},
|
||||
options: {
|
||||
headers: {},
|
||||
body: {},
|
||||
aisdk: {
|
||||
provider: {},
|
||||
request: {},
|
||||
},
|
||||
},
|
||||
variants: [],
|
||||
time: {
|
||||
released: DateTime.makeUnsafe(0),
|
||||
},
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: {
|
||||
context: 0,
|
||||
output: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } {
|
||||
const [providerID, ...modelID] = input.split("/")
|
||||
return {
|
||||
providerID: ProviderV2.ID.make(providerID),
|
||||
modelID: ID.make(modelID.join("/")),
|
||||
}
|
||||
}
|
||||
|
||||
export * as ModelV2 from "./model"
|
||||
@@ -1,146 +0,0 @@
|
||||
export * as PluginV2 from "./plugin"
|
||||
|
||||
import { createDraft, finishDraft, type Draft } from "immer"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { type ProviderV2 } from "./provider"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import type { ModelV2 } from "./model"
|
||||
|
||||
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
type HookSpec = {
|
||||
"provider.update": {
|
||||
input: {}
|
||||
output: {
|
||||
provider: ProviderV2.Info
|
||||
cancel: boolean
|
||||
}
|
||||
}
|
||||
"model.update": {
|
||||
input: {}
|
||||
output: {
|
||||
model: ModelV2.Info
|
||||
cancel: boolean
|
||||
}
|
||||
}
|
||||
"aisdk.language": {
|
||||
input: {
|
||||
model: ModelV2.Info
|
||||
sdk: any
|
||||
options: Record<string, any>
|
||||
}
|
||||
output: {
|
||||
language?: LanguageModelV3
|
||||
}
|
||||
}
|
||||
"aisdk.sdk": {
|
||||
input: {
|
||||
model: ModelV2.Info
|
||||
package: string
|
||||
options: Record<string, any>
|
||||
}
|
||||
output: {
|
||||
sdk?: any
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type Hooks = {
|
||||
[Name in keyof HookSpec]: Readonly<HookSpec[Name]["input"]> & {
|
||||
-readonly [Field in keyof HookSpec[Name]["output"]]: HookSpec[Name]["output"][Field] extends object
|
||||
? Draft<HookSpec[Name]["output"][Field]>
|
||||
: HookSpec[Name]["output"][Field]
|
||||
}
|
||||
}
|
||||
|
||||
export type HookFunctions = {
|
||||
[key in keyof Hooks]?: (input: Hooks[key]) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export type HookInput<Name extends keyof Hooks> = HookSpec[Name]["input"]
|
||||
export type HookOutput<Name extends keyof Hooks> = HookSpec[Name]["output"]
|
||||
|
||||
export type Effect = Effect.Effect<HookFunctions | void, never, never>
|
||||
|
||||
export function define<R>(input: { id: ID; effect: Effect.Effect<HookFunctions | void, never, R> }) {
|
||||
return input
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly add: (input: { id: ID; effect: Effect }) => Effect.Effect<void>
|
||||
readonly remove: (id: ID) => Effect.Effect<void>
|
||||
readonly trigger: <Name extends keyof Hooks>(
|
||||
name: Name,
|
||||
input: HookInput<Name>,
|
||||
output: HookOutput<Name>,
|
||||
) => Effect.Effect<HookInput<Name> & HookOutput<Name>>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Plugin") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
let hooks: {
|
||||
id: ID
|
||||
hooks: HookFunctions
|
||||
}[] = []
|
||||
|
||||
const svc = Service.of({
|
||||
add: Effect.fn("Plugin.add")(function* (input) {
|
||||
const result = yield* input.effect
|
||||
if (!result) return
|
||||
hooks = [
|
||||
...hooks.filter((item) => item.id !== input.id),
|
||||
{
|
||||
id: input.id,
|
||||
hooks: result,
|
||||
},
|
||||
]
|
||||
}),
|
||||
trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) {
|
||||
const draftEntries = new Map<string, ReturnType<typeof createDraft>>()
|
||||
const event = {
|
||||
...input,
|
||||
...output,
|
||||
} as Record<string, unknown>
|
||||
|
||||
for (const [field, value] of Object.entries(output)) {
|
||||
if (value && typeof value === "object") {
|
||||
draftEntries.set(field, createDraft(value))
|
||||
event[field] = draftEntries.get(field)
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of hooks) {
|
||||
const match = item.hooks[name]
|
||||
if (!match) continue
|
||||
yield* match(event as any).pipe(
|
||||
Effect.withSpan(`Plugin.hook.${name}`, {
|
||||
attributes: {
|
||||
plugin: item.id,
|
||||
hook: name,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const [field, draft] of draftEntries) {
|
||||
event[field] = finishDraft(draft)
|
||||
}
|
||||
|
||||
return event as any
|
||||
}),
|
||||
remove: Effect.fn("Plugin.remove")(function* (id) {
|
||||
hooks = hooks.filter((item) => item.id !== id)
|
||||
}),
|
||||
})
|
||||
return svc
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
// opencode
|
||||
// sdcok
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { AuthV2 } from "../auth"
|
||||
import { PluginV2 } from "../plugin"
|
||||
|
||||
export const AuthPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("auth"),
|
||||
effect: Effect.gen(function* () {
|
||||
const auth = yield* AuthV2.Service
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
const account = yield* auth.active(AuthV2.ServiceID.make(evt.provider.id)).pipe(Effect.orDie)
|
||||
if (!account) return
|
||||
evt.provider.enabled = {
|
||||
via: "auth",
|
||||
service: account.serviceID,
|
||||
}
|
||||
if (account.credential.type === "api") {
|
||||
evt.provider.options.aisdk.provider.apiKey = account.credential.key
|
||||
Object.assign(evt.provider.options.aisdk.provider, account.credential.metadata ?? {})
|
||||
}
|
||||
if (account.credential.type === "oauth") {
|
||||
evt.provider.options.aisdk.provider.apiKey = account.credential.access
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../plugin"
|
||||
|
||||
export const EnvPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("env"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
const key = evt.provider.env.find((item) => process.env[item])
|
||||
if (!key) return
|
||||
evt.provider.enabled = {
|
||||
via: "env",
|
||||
name: key,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,94 +0,0 @@
|
||||
export * as LayerMapExample from "./layer-map.example"
|
||||
|
||||
import { Context, Effect, Layer, LayerMap } from "effect"
|
||||
import { Npm } from "../npm"
|
||||
|
||||
/**
|
||||
* Tutorial: split global services from context-specific services.
|
||||
*
|
||||
* Use this pattern when part of the app should be constructed once at the app edge,
|
||||
* while another part should be cached per request/project/workspace key.
|
||||
*
|
||||
* In this example:
|
||||
* - Npm.Service is the global service. It is not keyed by request context and should
|
||||
* be provided once by the application runtime.
|
||||
* - ConfigService is context-specific. It is built from a RequestContext key and is
|
||||
* cached by LayerMap for that key.
|
||||
* - ConfigServiceMap.layer owns the cache. Provide it once globally, then each
|
||||
* request can provide ConfigServiceMap.get(context) to select the right instance.
|
||||
*
|
||||
* Lifetime model:
|
||||
* - ConfigServiceMap.layer has the app/global lifetime and depends on Npm.Service.
|
||||
* - ConfigServiceMap.get(context) has the request/context lifetime and provides
|
||||
* ConfigService for exactly that context key.
|
||||
* - The cached ConfigService entry stays alive while something is using it. Once idle,
|
||||
* it remains cached for idleTimeToLive, then its scope is finalized.
|
||||
* - invalidate(context) removes the cache entry for future lookups. Active users keep
|
||||
* running on the old instance; the next lookup can create a fresh instance.
|
||||
*
|
||||
* Key model:
|
||||
* - Keys can be strings, structs, classes, arrays, etc.
|
||||
* - Prefer primitive or immutable keys. Effect uses Hash / Equal semantics for cache
|
||||
* lookup, so mutating an object after it has been used as a key is a bug.
|
||||
*/
|
||||
|
||||
export type RequestContext = {
|
||||
readonly directory: string
|
||||
readonly workspace: string
|
||||
}
|
||||
|
||||
export class RequestContextRef extends Context.Service<RequestContextRef, RequestContext>()(
|
||||
"@opencode/example/RequestContextRef",
|
||||
) {}
|
||||
|
||||
export interface ConfigServiceShape {
|
||||
readonly directory: string
|
||||
readonly workspace: string
|
||||
readonly nextUse: () => Effect.Effect<number>
|
||||
readonly which: Npm.Interface["which"]
|
||||
}
|
||||
|
||||
export class ConfigService extends Context.Service<ConfigService, ConfigServiceShape>()(
|
||||
"@opencode/example/ConfigService",
|
||||
) {}
|
||||
|
||||
const configServiceLayer = Layer.effect(
|
||||
ConfigService,
|
||||
Effect.gen(function* () {
|
||||
const context = yield* RequestContextRef
|
||||
const npm = yield* Npm.Service
|
||||
|
||||
let useCount = 0
|
||||
|
||||
return ConfigService.of({
|
||||
directory: context.directory,
|
||||
workspace: context.workspace,
|
||||
nextUse: () => Effect.succeed(++useCount),
|
||||
which: npm.which,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export class ConfigServiceMap extends LayerMap.Service<ConfigServiceMap>()("@opencode/example/ConfigServiceMap", {
|
||||
lookup: (context: RequestContext) =>
|
||||
configServiceLayer.pipe(Layer.provide(Layer.succeed(RequestContextRef, RequestContextRef.of(context)))),
|
||||
idleTimeToLive: "5 minutes",
|
||||
}) {}
|
||||
|
||||
export const appLayer = ConfigServiceMap.layer
|
||||
|
||||
export const readConfig = Effect.fn("LayerMapExample.readConfig")(function* () {
|
||||
const config = yield* ConfigService
|
||||
|
||||
return {
|
||||
directory: config.directory,
|
||||
workspace: config.workspace,
|
||||
useCount: yield* config.nextUse(),
|
||||
}
|
||||
})
|
||||
|
||||
export const handleRequest = Effect.fn("LayerMapExample.handleRequest")(function* (context: RequestContext) {
|
||||
return yield* readConfig().pipe(Effect.provide(ConfigServiceMap.get(context)))
|
||||
})
|
||||
|
||||
export const invalidateContext = (context: RequestContext) => ConfigServiceMap.invalidate(context)
|
||||
@@ -1 +0,0 @@
|
||||
export { ProviderPlugins } from "./provider/index"
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
|
||||
export const AlibabaPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("alibaba"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/alibaba") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba"))
|
||||
evt.sdk = mod.createAlibaba(evt.options)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,94 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
// Bedrock cross-region inference profiles require regional prefixes only for
|
||||
// specific model/region combinations. Keep the mapping narrow and avoid
|
||||
// double-prefixing model IDs that models.dev already marks as global/us/eu/etc.
|
||||
function resolveModelID(modelID: string, region: string | undefined) {
|
||||
const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."]
|
||||
if (crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))) return modelID
|
||||
|
||||
const resolvedRegion = region ?? "us-east-1"
|
||||
const regionPrefix = resolvedRegion.split("-")[0]
|
||||
if (regionPrefix === "us") {
|
||||
const requiresPrefix = ["nova-micro", "nova-lite", "nova-pro", "nova-premier", "nova-2", "claude", "deepseek"].some(
|
||||
(item) => modelID.includes(item),
|
||||
)
|
||||
if (requiresPrefix && !resolvedRegion.startsWith("us-gov")) return `${regionPrefix}.${modelID}`
|
||||
return modelID
|
||||
}
|
||||
if (regionPrefix === "eu") {
|
||||
const regionRequiresPrefix = [
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
"eu-north-1",
|
||||
"eu-central-1",
|
||||
"eu-south-1",
|
||||
"eu-south-2",
|
||||
].some((item) => resolvedRegion.includes(item))
|
||||
const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "llama3", "pixtral"].some((item) =>
|
||||
modelID.includes(item),
|
||||
)
|
||||
return regionRequiresPrefix && modelRequiresPrefix ? `${regionPrefix}.${modelID}` : modelID
|
||||
}
|
||||
if (regionPrefix !== "ap") return modelID
|
||||
|
||||
const australia = ["ap-southeast-2", "ap-southeast-4"].includes(resolvedRegion)
|
||||
if (australia && ["anthropic.claude-sonnet-4-5", "anthropic.claude-haiku"].some((item) => modelID.includes(item))) {
|
||||
return `au.${modelID}`
|
||||
}
|
||||
|
||||
const prefix = resolvedRegion === "ap-northeast-1" ? "jp" : "apac"
|
||||
return ["claude", "nova-lite", "nova-micro", "nova-pro"].some((item) => modelID.includes(item))
|
||||
? `${prefix}.${modelID}`
|
||||
: modelID
|
||||
}
|
||||
|
||||
export const AmazonBedrockPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("amazon-bedrock"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.amazonBedrock) return
|
||||
if (evt.provider.endpoint.type !== "aisdk") return
|
||||
if (typeof evt.provider.options.aisdk.provider.endpoint !== "string") return
|
||||
// The AI SDK expects a base URL, but users configure Bedrock private/VPC
|
||||
// endpoints as `endpoint`; move it into the catalog endpoint URL once.
|
||||
evt.provider.endpoint.url = evt.provider.options.aisdk.provider.endpoint
|
||||
delete evt.provider.options.aisdk.provider.endpoint
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/amazon-bedrock") return
|
||||
const options = { ...evt.options }
|
||||
const profile = typeof options.profile === "string" ? options.profile : process.env.AWS_PROFILE
|
||||
const region = typeof options.region === "string" ? options.region : (process.env.AWS_REGION ?? "us-east-1")
|
||||
const bearerToken =
|
||||
process.env.AWS_BEARER_TOKEN_BEDROCK ??
|
||||
(typeof options.bearerToken === "string" ? options.bearerToken : undefined)
|
||||
if (bearerToken && !process.env.AWS_BEARER_TOKEN_BEDROCK) process.env.AWS_BEARER_TOKEN_BEDROCK = bearerToken
|
||||
const containerCreds = Boolean(
|
||||
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI,
|
||||
)
|
||||
|
||||
options.region = region
|
||||
if (typeof options.endpoint === "string") options.baseURL = options.endpoint
|
||||
if (!bearerToken && options.credentialProvider === undefined) {
|
||||
// Do not gate SDK creation on explicit AWS env vars. The default chain
|
||||
// also handles ~/.aws/credentials, SSO, process creds, and instance roles.
|
||||
const { fromNodeProviderChain } = yield* Effect.promise(() => import("@aws-sdk/credential-providers"))
|
||||
options.credentialProvider = fromNodeProviderChain(profile ? { profile } : {})
|
||||
}
|
||||
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/amazon-bedrock"))
|
||||
evt.sdk = mod.createAmazonBedrock(options)
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return
|
||||
const region = typeof evt.options.region === "string" ? evt.options.region : process.env.AWS_REGION
|
||||
evt.language = evt.sdk.languageModel(resolveModelID(evt.model.apiID, region))
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const AnthropicPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("anthropic"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.anthropic) return
|
||||
evt.provider.options.headers["anthropic-beta"] =
|
||||
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/anthropic") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic"))
|
||||
evt.sdk = mod.createAnthropic(evt.options)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
|
||||
if (useChat && sdk.chat) return sdk.chat(modelID)
|
||||
if (sdk.responses) return sdk.responses(modelID)
|
||||
if (sdk.messages) return sdk.messages(modelID)
|
||||
if (sdk.chat) return sdk.chat(modelID)
|
||||
return sdk.languageModel(modelID)
|
||||
}
|
||||
|
||||
export const AzurePlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("azure"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.azure) return
|
||||
const configured = evt.provider.options.aisdk.provider.resourceName
|
||||
const resourceName =
|
||||
typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME
|
||||
if (resourceName) evt.provider.options.aisdk.provider.resourceName = resourceName
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/azure") return
|
||||
if (evt.model.providerID === ProviderV2.ID.azure) {
|
||||
if (
|
||||
!evt.options.resourceName &&
|
||||
!evt.options.baseURL &&
|
||||
(evt.model.endpoint.type !== "aisdk" || !evt.model.endpoint.url)
|
||||
) {
|
||||
throw new Error(
|
||||
"AZURE_RESOURCE_NAME is missing, set it using env var or reconnecting the azure provider and setting it",
|
||||
)
|
||||
}
|
||||
}
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/azure"))
|
||||
evt.sdk = mod.createAzure(evt.options)
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.azure) return
|
||||
evt.language = selectLanguage(evt.sdk, evt.model.apiID, Boolean(evt.options.useCompletionUrls))
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export const AzureCognitiveServicesPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("azure-cognitive-services"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.make("azure-cognitive-services")) return
|
||||
const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
|
||||
if (resourceName)
|
||||
evt.provider.options.aisdk.provider.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai`
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return
|
||||
evt.language = selectLanguage(evt.sdk, evt.model.apiID, Boolean(evt.options.useCompletionUrls))
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const CerebrasPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("cerebras"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.make("cerebras")) return
|
||||
evt.provider.options.headers["X-Cerebras-3rd-Party-Integration"] = "opencode"
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/cerebras") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras"))
|
||||
evt.sdk = mod.createCerebras(evt.options)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,81 +0,0 @@
|
||||
import os from "os"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
|
||||
export const CloudflareAIGatewayPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("cloudflare-ai-gateway"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "ai-gateway-provider") return
|
||||
if (evt.options.baseURL) return
|
||||
|
||||
const config = gatewayConfig(evt.options)
|
||||
if (!config) return
|
||||
const metadata = gatewayMetadata(evt.options)
|
||||
const { createAiGateway } = yield* Effect.promise(() => import("ai-gateway-provider")).pipe(Effect.orDie)
|
||||
const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified")).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
const gateway = createAiGateway({
|
||||
accountId: config.accountId,
|
||||
gateway: config.gatewayId,
|
||||
apiKey: config.apiKey,
|
||||
options: gatewayOptions(evt.options, metadata),
|
||||
} as any)
|
||||
const unified = createUnified()
|
||||
evt.sdk = {
|
||||
languageModel(modelID: string) {
|
||||
return gateway(unified(modelID))
|
||||
},
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
type GatewayConfig = {
|
||||
accountId: string
|
||||
gatewayId: string
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
|
||||
function gatewayConfig(options: Record<string, unknown>): GatewayConfig | undefined {
|
||||
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId")
|
||||
// AuthPlugin copies CLI prompt metadata into options. The prompt stores the
|
||||
// gateway as gatewayId, while older config examples may use gateway.
|
||||
const gatewayId =
|
||||
process.env.CLOUDFLARE_GATEWAY_ID ?? stringOption(options, "gatewayId") ?? stringOption(options, "gateway")
|
||||
const apiKey = process.env.CLOUDFLARE_API_TOKEN ?? process.env.CF_AIG_TOKEN ?? stringOption(options, "apiKey")
|
||||
if (!accountId || !gatewayId || !apiKey) return undefined
|
||||
|
||||
return { accountId, gatewayId, apiKey }
|
||||
}
|
||||
|
||||
function gatewayMetadata(options: Record<string, unknown>) {
|
||||
// Preserve the legacy cf-aig-metadata header escape hatch for gateway logging
|
||||
// metadata, but prefer the typed metadata option when present.
|
||||
if (options.metadata !== undefined) return options.metadata
|
||||
const raw = (options.headers as Record<string, string> | undefined)?.["cf-aig-metadata"]
|
||||
return raw ? Option.getOrUndefined(decodeJson(raw)) : undefined
|
||||
}
|
||||
|
||||
function gatewayOptions(options: Record<string, unknown>, metadata: unknown) {
|
||||
return {
|
||||
metadata,
|
||||
cacheTtl: options.cacheTtl,
|
||||
cacheKey: options.cacheKey,
|
||||
skipCache: options.skipCache,
|
||||
collectLog: options.collectLog,
|
||||
headers: {
|
||||
"User-Agent": `opencode/${InstallationVersion} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function stringOption(options: Record<string, unknown>, key: string) {
|
||||
return typeof options[key] === "string" ? options[key] : undefined
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import os from "os"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
const providerID = ProviderV2.ID.make("cloudflare-workers-ai")
|
||||
|
||||
export const CloudflareWorkersAIPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("cloudflare-workers-ai"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== providerID) return
|
||||
if (evt.provider.endpoint.type !== "aisdk") return
|
||||
if (evt.provider.endpoint.url) return
|
||||
|
||||
const accountId = resolveAccountId(evt.provider.options.aisdk.provider)
|
||||
if (accountId) evt.provider.endpoint.url = workersEndpoint(accountId)
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== providerID) return
|
||||
if (evt.package !== "@ai-sdk/openai-compatible") return
|
||||
|
||||
if (!hasWorkersEndpoint(evt.model.endpoint)) return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
|
||||
evt.sdk = mod.createOpenAICompatible(sdkOptions(evt.options) as any)
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== providerID) return
|
||||
evt.language = evt.sdk.languageModel(evt.model.apiID)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
function resolveAccountId(options: Record<string, unknown>) {
|
||||
return process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId")
|
||||
}
|
||||
|
||||
function workersEndpoint(accountId: string) {
|
||||
return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`
|
||||
}
|
||||
|
||||
function hasWorkersEndpoint(endpoint: ProviderV2.Endpoint) {
|
||||
return endpoint.type === "aisdk" && Boolean(endpoint.url)
|
||||
}
|
||||
|
||||
function sdkOptions(options: Record<string, any>) {
|
||||
return {
|
||||
...options,
|
||||
baseURL: expandAccountId(options.baseURL),
|
||||
apiKey: process.env.CLOUDFLARE_API_KEY ?? options.apiKey,
|
||||
headers: {
|
||||
"User-Agent": `opencode/${InstallationVersion} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||
...options.headers,
|
||||
},
|
||||
name: providerID,
|
||||
}
|
||||
}
|
||||
|
||||
function expandAccountId(baseURL: unknown) {
|
||||
if (typeof baseURL !== "string") return baseURL
|
||||
return baseURL.replaceAll("${CLOUDFLARE_ACCOUNT_ID}", process.env.CLOUDFLARE_ACCOUNT_ID ?? "${CLOUDFLARE_ACCOUNT_ID}")
|
||||
}
|
||||
|
||||
function stringOption(options: Record<string, unknown>, key: string) {
|
||||
return typeof options[key] === "string" ? options[key] : undefined
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
|
||||
export const CoherePlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("cohere"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/cohere") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/cohere"))
|
||||
evt.sdk = mod.createCohere(evt.options)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
|
||||
export const DeepInfraPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("deepinfra"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/deepinfra") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra"))
|
||||
evt.sdk = mod.createDeepInfra(evt.options)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Npm } from "../../npm"
|
||||
import { Effect, Option } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
|
||||
export const DynamicProviderPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("dynamic-provider"),
|
||||
effect: Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.sdk) return
|
||||
|
||||
const installedPath = evt.package.startsWith("file://")
|
||||
? evt.package
|
||||
: Option.getOrUndefined((yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint)
|
||||
if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`)
|
||||
|
||||
const mod = yield* Effect.promise(async () => {
|
||||
return (await import(
|
||||
installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href
|
||||
)) as Record<string, (options: any) => any>
|
||||
}).pipe(Effect.orDie)
|
||||
const match = Object.keys(mod).find((name) => name.startsWith("create"))
|
||||
if (!match) throw new Error(`Package ${evt.package} has no provider factory export`)
|
||||
|
||||
evt.sdk = mod[match](evt.options)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
|
||||
export const GatewayPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("gateway"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/gateway") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/gateway"))
|
||||
evt.sdk = mod.createGateway(evt.options)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
function shouldUseResponses(modelID: string) {
|
||||
// Copilot supports Responses for GPT-5 class models, except mini variants
|
||||
// which still need the chat-completions endpoint.
|
||||
const match = /^gpt-(\d+)/.exec(modelID)
|
||||
if (!match) return false
|
||||
return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")
|
||||
}
|
||||
|
||||
export const GithubCopilotPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("github-copilot"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.githubCopilot) return
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/github-copilot") return
|
||||
const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider"))
|
||||
evt.sdk = mod.createOpenaiCompatible(evt.options)
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return
|
||||
if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) {
|
||||
evt.language = evt.sdk.languageModel(evt.model.apiID)
|
||||
return
|
||||
}
|
||||
evt.language = shouldUseResponses(evt.model.apiID)
|
||||
? evt.sdk.responses(evt.model.apiID)
|
||||
: evt.sdk.chat(evt.model.apiID)
|
||||
}),
|
||||
"model.update": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return
|
||||
// This chat-only alias conflicts with the Copilot GPT-5 Responses route,
|
||||
// so hide it only for Copilot rather than for every provider catalog.
|
||||
if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
import os from "os"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const GitLabPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("gitlab"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "gitlab-ai-provider") return
|
||||
const mod = yield* Effect.promise(() => import("gitlab-ai-provider"))
|
||||
evt.sdk = mod.createGitLab({
|
||||
...evt.options,
|
||||
instanceUrl:
|
||||
typeof evt.options.instanceUrl === "string"
|
||||
? evt.options.instanceUrl
|
||||
: (process.env.GITLAB_INSTANCE_URL ?? "https://gitlab.com"),
|
||||
apiKey: typeof evt.options.apiKey === "string" ? evt.options.apiKey : process.env.GITLAB_TOKEN,
|
||||
aiGatewayHeaders: {
|
||||
"User-Agent": `opencode/${InstallationVersion} gitlab-ai-provider/${mod.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||
"anthropic-beta": "context-1m-2025-08-07",
|
||||
...evt.options.aiGatewayHeaders,
|
||||
},
|
||||
featureFlags: {
|
||||
duo_agent_platform_agentic_chat: true,
|
||||
duo_agent_platform: true,
|
||||
...evt.options.featureFlags,
|
||||
},
|
||||
})
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.gitlab) return
|
||||
const featureFlags =
|
||||
typeof evt.options.featureFlags === "object" && evt.options.featureFlags ? evt.options.featureFlags : {}
|
||||
if (evt.model.apiID.startsWith("duo-workflow-")) {
|
||||
const gitlab = yield* Effect.promise(() => import("gitlab-ai-provider")).pipe(Effect.orDie)
|
||||
const workflowRef =
|
||||
typeof evt.model.options.aisdk.request.workflowRef === "string"
|
||||
? evt.model.options.aisdk.request.workflowRef
|
||||
: undefined
|
||||
const workflowDefinition =
|
||||
typeof evt.model.options.aisdk.request.workflowDefinition === "string"
|
||||
? evt.model.options.aisdk.request.workflowDefinition
|
||||
: undefined
|
||||
const language = evt.sdk.workflowChat(
|
||||
gitlab.isWorkflowModel(evt.model.apiID) ? evt.model.apiID : "duo-workflow",
|
||||
{
|
||||
featureFlags,
|
||||
workflowDefinition,
|
||||
},
|
||||
)
|
||||
if (workflowRef) language.selectedModelRef = workflowRef
|
||||
evt.language = language
|
||||
return
|
||||
}
|
||||
evt.language = evt.sdk.agenticChat(evt.model.apiID, {
|
||||
aiGatewayHeaders: evt.options.aiGatewayHeaders,
|
||||
featureFlags,
|
||||
})
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,141 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
function resolveProject(options: Record<string, any>) {
|
||||
// models.dev advertises GOOGLE_VERTEX_PROJECT for Vertex, while Google SDKs
|
||||
// and ADC examples commonly use the broader Google Cloud project aliases.
|
||||
return (
|
||||
options.project ??
|
||||
process.env.GOOGLE_VERTEX_PROJECT ??
|
||||
process.env.GOOGLE_CLOUD_PROJECT ??
|
||||
process.env.GCP_PROJECT ??
|
||||
process.env.GCLOUD_PROJECT
|
||||
)
|
||||
}
|
||||
|
||||
function resolveLocation(options: Record<string, any>) {
|
||||
return (
|
||||
options.location ??
|
||||
process.env.GOOGLE_VERTEX_LOCATION ??
|
||||
process.env.GOOGLE_CLOUD_LOCATION ??
|
||||
process.env.VERTEX_LOCATION ??
|
||||
"us-central1"
|
||||
)
|
||||
}
|
||||
|
||||
function vertexEndpoint(location: string) {
|
||||
return location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`
|
||||
}
|
||||
|
||||
function replaceVertexVars(value: string, project: string | undefined, location: string) {
|
||||
// Vertex OpenAI-compatible endpoints are stored as templates in the catalog;
|
||||
// expand them after provider config/env project and location have been resolved.
|
||||
return value
|
||||
.replaceAll("${GOOGLE_VERTEX_PROJECT}", project ?? "${GOOGLE_VERTEX_PROJECT}")
|
||||
.replaceAll("${GOOGLE_VERTEX_LOCATION}", location)
|
||||
.replaceAll("${GOOGLE_VERTEX_ENDPOINT}", vertexEndpoint(location))
|
||||
}
|
||||
|
||||
function authFetch(fetchWithRuntimeOptions?: unknown) {
|
||||
// Native Vertex SDKs handle ADC internally. OpenAI-compatible Vertex endpoints
|
||||
// do not, so inject a Google access token into their fetch path.
|
||||
return async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
const { GoogleAuth } = await import("google-auth-library")
|
||||
const auth = new GoogleAuth()
|
||||
const client = await auth.getApplicationDefault()
|
||||
const token = await client.credential.getAccessToken()
|
||||
const headers = new Headers(init?.headers)
|
||||
headers.set("Authorization", `Bearer ${token.token}`)
|
||||
return typeof fetchWithRuntimeOptions === "function"
|
||||
? fetchWithRuntimeOptions(input, { ...init, headers })
|
||||
: fetch(input, { ...init, headers })
|
||||
}
|
||||
}
|
||||
|
||||
export const GoogleVertexPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("google-vertex"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.googleVertex) return
|
||||
const project = resolveProject(evt.provider.options.aisdk.provider)
|
||||
const location = String(resolveLocation(evt.provider.options.aisdk.provider))
|
||||
if (project) evt.provider.options.aisdk.provider.project = project
|
||||
evt.provider.options.aisdk.provider.location = location
|
||||
if (evt.provider.endpoint.type === "aisdk" && evt.provider.endpoint.url) {
|
||||
evt.provider.endpoint.url = replaceVertexVars(evt.provider.endpoint.url, project, location)
|
||||
}
|
||||
if (
|
||||
evt.provider.endpoint.type === "aisdk" &&
|
||||
evt.provider.endpoint.package.includes("@ai-sdk/openai-compatible")
|
||||
) {
|
||||
evt.provider.options.aisdk.provider.fetch = authFetch(evt.provider.options.aisdk.provider.fetch)
|
||||
}
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) {
|
||||
evt.options.fetch = authFetch(evt.options.fetch)
|
||||
return
|
||||
}
|
||||
if (evt.package !== "@ai-sdk/google-vertex") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex"))
|
||||
const project = resolveProject(evt.options)
|
||||
const location = resolveLocation(evt.options)
|
||||
const options = { ...evt.options }
|
||||
delete options.fetch
|
||||
evt.sdk = mod.createVertex({
|
||||
...options,
|
||||
project,
|
||||
location,
|
||||
})
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.googleVertex) return
|
||||
evt.language = evt.sdk.languageModel(String(evt.model.apiID).trim())
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export const GoogleVertexAnthropicPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("google-vertex-anthropic"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.make("google-vertex-anthropic")) return
|
||||
const project =
|
||||
evt.provider.options.aisdk.provider.project ??
|
||||
process.env.GOOGLE_CLOUD_PROJECT ??
|
||||
process.env.GCP_PROJECT ??
|
||||
process.env.GCLOUD_PROJECT
|
||||
const location =
|
||||
evt.provider.options.aisdk.provider.location ??
|
||||
process.env.GOOGLE_CLOUD_LOCATION ??
|
||||
process.env.VERTEX_LOCATION ??
|
||||
"global"
|
||||
if (project) evt.provider.options.aisdk.provider.project = project
|
||||
evt.provider.options.aisdk.provider.location = location
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/google-vertex/anthropic") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic"))
|
||||
evt.sdk = mod.createVertexAnthropic({
|
||||
...evt.options,
|
||||
project:
|
||||
typeof evt.options.project === "string"
|
||||
? evt.options.project
|
||||
: (process.env.GOOGLE_CLOUD_PROJECT ?? process.env.GCP_PROJECT ?? process.env.GCLOUD_PROJECT),
|
||||
location:
|
||||
typeof evt.options.location === "string"
|
||||
? evt.options.location
|
||||
: (process.env.GOOGLE_CLOUD_LOCATION ?? process.env.VERTEX_LOCATION ?? "global"),
|
||||
})
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return
|
||||
evt.language = evt.sdk.languageModel(String(evt.model.apiID).trim())
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
|
||||
export const GooglePlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("google"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/google") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/google"))
|
||||
evt.sdk = mod.createGoogleGenerativeAI(evt.options)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
|
||||
export const GroqPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("groq"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/groq") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/groq"))
|
||||
evt.sdk = mod.createGroq(evt.options)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,67 +0,0 @@
|
||||
import { AlibabaPlugin } from "./alibaba"
|
||||
import { AmazonBedrockPlugin } from "./amazon-bedrock"
|
||||
import { AnthropicPlugin } from "./anthropic"
|
||||
import { AzureCognitiveServicesPlugin, AzurePlugin } from "./azure"
|
||||
import { CerebrasPlugin } from "./cerebras"
|
||||
import { CloudflareAIGatewayPlugin } from "./cloudflare-ai-gateway"
|
||||
import { CloudflareWorkersAIPlugin } from "./cloudflare-workers-ai"
|
||||
import { CoherePlugin } from "./cohere"
|
||||
import { DeepInfraPlugin } from "./deepinfra"
|
||||
import { DynamicProviderPlugin } from "./dynamic"
|
||||
import { GatewayPlugin } from "./gateway"
|
||||
import { GithubCopilotPlugin } from "./github-copilot"
|
||||
import { GitLabPlugin } from "./gitlab"
|
||||
import { GooglePlugin } from "./google"
|
||||
import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "./google-vertex"
|
||||
import { GroqPlugin } from "./groq"
|
||||
import { KiloPlugin } from "./kilo"
|
||||
import { LLMGatewayPlugin } from "./llmgateway"
|
||||
import { MistralPlugin } from "./mistral"
|
||||
import { NvidiaPlugin } from "./nvidia"
|
||||
import { OpenAIPlugin } from "./openai"
|
||||
import { OpenAICompatiblePlugin } from "./openai-compatible"
|
||||
import { OpencodePlugin } from "./opencode"
|
||||
import { OpenRouterPlugin } from "./openrouter"
|
||||
import { PerplexityPlugin } from "./perplexity"
|
||||
import { SapAICorePlugin } from "./sap-ai-core"
|
||||
import { TogetherAIPlugin } from "./togetherai"
|
||||
import { VercelPlugin } from "./vercel"
|
||||
import { VenicePlugin } from "./venice"
|
||||
import { XAIPlugin } from "./xai"
|
||||
import { ZenmuxPlugin } from "./zenmux"
|
||||
|
||||
export const ProviderPlugins = [
|
||||
AlibabaPlugin,
|
||||
AmazonBedrockPlugin,
|
||||
AnthropicPlugin,
|
||||
AzureCognitiveServicesPlugin,
|
||||
AzurePlugin,
|
||||
CerebrasPlugin,
|
||||
CloudflareAIGatewayPlugin,
|
||||
CloudflareWorkersAIPlugin,
|
||||
CoherePlugin,
|
||||
DeepInfraPlugin,
|
||||
GatewayPlugin,
|
||||
GithubCopilotPlugin,
|
||||
GitLabPlugin,
|
||||
GooglePlugin,
|
||||
GoogleVertexAnthropicPlugin,
|
||||
GoogleVertexPlugin,
|
||||
GroqPlugin,
|
||||
KiloPlugin,
|
||||
LLMGatewayPlugin,
|
||||
MistralPlugin,
|
||||
NvidiaPlugin,
|
||||
OpencodePlugin,
|
||||
OpenAICompatiblePlugin,
|
||||
OpenAIPlugin,
|
||||
OpenRouterPlugin,
|
||||
PerplexityPlugin,
|
||||
SapAICorePlugin,
|
||||
TogetherAIPlugin,
|
||||
VercelPlugin,
|
||||
VenicePlugin,
|
||||
XAIPlugin,
|
||||
ZenmuxPlugin,
|
||||
DynamicProviderPlugin,
|
||||
]
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const KiloPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("kilo"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.make("kilo")) return
|
||||
evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
evt.provider.options.headers["X-Title"] = "opencode"
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const LLMGatewayPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("llmgateway"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.make("llmgateway")) return
|
||||
if (evt.provider.enabled === false) return
|
||||
evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
evt.provider.options.headers["X-Title"] = "opencode"
|
||||
evt.provider.options.headers["X-Source"] = "opencode"
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
|
||||
export const MistralPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("mistral"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/mistral") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/mistral"))
|
||||
evt.sdk = mod.createMistral(evt.options)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const NvidiaPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("nvidia"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.make("nvidia")) return
|
||||
evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
evt.provider.options.headers["X-Title"] = "opencode"
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
|
||||
export const OpenAICompatiblePlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("openai-compatible"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.sdk) return
|
||||
if (!evt.package.includes("@ai-sdk/openai-compatible")) return
|
||||
if (evt.options.includeUsage !== false) evt.options.includeUsage = true
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
|
||||
evt.sdk = mod.createOpenAICompatible(evt.options as any)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const OpenAIPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("openai"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/openai") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/openai"))
|
||||
evt.sdk = mod.createOpenAI(evt.options)
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.openai) return
|
||||
evt.language = evt.sdk.responses(evt.model.apiID)
|
||||
}),
|
||||
"model.update": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.openai) return
|
||||
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
|
||||
// chat-completions-only model, so remove it only from OpenAI's catalog.
|
||||
if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const OpencodePlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("opencode"),
|
||||
effect: Effect.gen(function* () {
|
||||
let hasKey = false
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.opencode) return
|
||||
hasKey = Boolean(
|
||||
process.env.OPENCODE_API_KEY ||
|
||||
evt.provider.env.some((item) => process.env[item]) ||
|
||||
evt.provider.options.aisdk.provider.apiKey ||
|
||||
(evt.provider.enabled && evt.provider.enabled.via === "auth"),
|
||||
)
|
||||
if (!hasKey) evt.provider.options.aisdk.provider.apiKey = "public"
|
||||
}),
|
||||
"model.update": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.opencode) return
|
||||
if (hasKey) return
|
||||
if (evt.model.cost.some((item) => item.input > 0)) evt.cancel = true
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const OpenRouterPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("openrouter"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.openrouter) return
|
||||
evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
evt.provider.options.headers["X-Title"] = "opencode"
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@openrouter/ai-sdk-provider") return
|
||||
const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider"))
|
||||
evt.sdk = mod.createOpenRouter(evt.options)
|
||||
}),
|
||||
"model.update": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.openrouter) return
|
||||
// These are OpenRouter-specific OpenAI chat aliases that do not work on
|
||||
// the generic path. Keep custom providers with matching IDs untouched.
|
||||
if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true
|
||||
if (evt.model.id === ModelV2.ID.make("openai/gpt-5-chat")) evt.cancel = true
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
|
||||
export const PerplexityPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("perplexity"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/perplexity") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity"))
|
||||
evt.sdk = mod.createPerplexity(evt.options)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Npm } from "../../npm"
|
||||
import { Effect, Option } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const SapAICorePlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("sap-ai-core"),
|
||||
effect: Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
|
||||
const serviceKey =
|
||||
process.env.AICORE_SERVICE_KEY ??
|
||||
(typeof evt.options.serviceKey === "string" ? evt.options.serviceKey : undefined)
|
||||
if (serviceKey && !process.env.AICORE_SERVICE_KEY) process.env.AICORE_SERVICE_KEY = serviceKey
|
||||
|
||||
const installedPath = evt.package.startsWith("file://")
|
||||
? evt.package
|
||||
: Option.getOrUndefined((yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint)
|
||||
if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`)
|
||||
|
||||
const mod = yield* Effect.promise(async () => {
|
||||
return (await import(
|
||||
installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href
|
||||
)) as Record<string, (options: any) => any>
|
||||
}).pipe(Effect.orDie)
|
||||
const match = Object.keys(mod).find((name) => name.startsWith("create"))
|
||||
if (!match) throw new Error(`Package ${evt.package} has no provider factory export`)
|
||||
|
||||
evt.sdk = mod[match](
|
||||
serviceKey
|
||||
? { deploymentId: process.env.AICORE_DEPLOYMENT_ID, resourceGroup: process.env.AICORE_RESOURCE_GROUP }
|
||||
: {},
|
||||
)
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
|
||||
evt.language = evt.sdk(evt.model.apiID)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
|
||||
export const TogetherAIPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("togetherai"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/togetherai") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai"))
|
||||
evt.sdk = mod.createTogetherAI(evt.options)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
|
||||
export const VenicePlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("venice"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "venice-ai-sdk-provider") return
|
||||
const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider"))
|
||||
evt.sdk = mod.createVenice(evt.options)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const VercelPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("vercel"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.make("vercel")) return
|
||||
evt.provider.options.headers["http-referer"] = "https://opencode.ai/"
|
||||
evt.provider.options.headers["x-title"] = "opencode"
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/vercel") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/vercel"))
|
||||
evt.sdk = mod.createVercel(evt.options)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const XAIPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("xai"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/xai") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/xai"))
|
||||
evt.sdk = mod.createXai(evt.options)
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("xai")) return
|
||||
evt.language = evt.sdk.responses(evt.model.apiID)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const ZenmuxPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("zenmux"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.make("zenmux")) return
|
||||
evt.provider.options.headers["HTTP-Referer"] ??= "https://opencode.ai/"
|
||||
evt.provider.options.headers["X-Title"] ??= "opencode"
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -1,232 +0,0 @@
|
||||
import { Context, Duration, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import type { PlatformError } from "effect/PlatformError"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { CrossSpawnSpawner } from "./cross-spawn-spawner"
|
||||
|
||||
export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()("AppProcessError", {
|
||||
command: Schema.String,
|
||||
exitCode: Schema.optional(Schema.Number),
|
||||
stderr: Schema.optional(Schema.String),
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export interface RunOptions {
|
||||
readonly maxOutputBytes?: number
|
||||
readonly maxErrorBytes?: number
|
||||
readonly signal?: AbortSignal
|
||||
readonly timeout?: Duration.Input
|
||||
readonly stdin?: string | Uint8Array | Stream.Stream<Uint8Array, PlatformError>
|
||||
}
|
||||
|
||||
export interface RunStreamOptions {
|
||||
readonly signal?: AbortSignal
|
||||
readonly includeStderr?: boolean
|
||||
readonly okExitCodes?: ReadonlyArray<number>
|
||||
readonly maxErrorBytes?: number
|
||||
}
|
||||
|
||||
export interface RunResult {
|
||||
readonly command: string
|
||||
readonly exitCode: number
|
||||
readonly stdout: Buffer
|
||||
readonly stderr: Buffer
|
||||
readonly truncated: boolean
|
||||
}
|
||||
|
||||
export type Interface = ChildProcessSpawner["Service"] & {
|
||||
readonly run: (command: ChildProcess.Command, options?: RunOptions) => Effect.Effect<RunResult, AppProcessError>
|
||||
readonly runStream: (
|
||||
command: ChildProcess.Command,
|
||||
options?: RunStreamOptions,
|
||||
) => Stream.Stream<string, AppProcessError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/AppProcess") {}
|
||||
|
||||
export const requireSuccess = (result: RunResult): Effect.Effect<RunResult, AppProcessError> =>
|
||||
result.exitCode === 0
|
||||
? Effect.succeed(result)
|
||||
: Effect.fail(
|
||||
new AppProcessError({
|
||||
command: result.command,
|
||||
exitCode: result.exitCode,
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
}),
|
||||
)
|
||||
|
||||
export const requireExitIn =
|
||||
(codes: ReadonlyArray<number>) =>
|
||||
(result: RunResult): Effect.Effect<RunResult, AppProcessError> =>
|
||||
codes.includes(result.exitCode)
|
||||
? Effect.succeed(result)
|
||||
: Effect.fail(
|
||||
new AppProcessError({
|
||||
command: result.command,
|
||||
exitCode: result.exitCode,
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
}),
|
||||
)
|
||||
|
||||
const describeCommand = (command: ChildProcess.Command): string => {
|
||||
if (command._tag === "StandardCommand") {
|
||||
return command.args.length ? `${command.command} ${command.args.join(" ")}` : command.command
|
||||
}
|
||||
return `${describeCommand(command.left)} | ${describeCommand(command.right)}`
|
||||
}
|
||||
|
||||
const wrapError = (description: string, cause: unknown): AppProcessError =>
|
||||
cause instanceof AppProcessError ? cause : new AppProcessError({ command: description, cause })
|
||||
|
||||
const abortError = (signal: AbortSignal): Error => {
|
||||
const reason = signal.reason
|
||||
if (reason instanceof Error) return reason
|
||||
const err = new Error("Aborted")
|
||||
err.name = "AbortError"
|
||||
return err
|
||||
}
|
||||
|
||||
const waitForAbort = (signal: AbortSignal) =>
|
||||
Effect.callback<never, Error>((resume) => {
|
||||
if (signal.aborted) {
|
||||
resume(Effect.fail(abortError(signal)))
|
||||
return
|
||||
}
|
||||
const onabort = () => resume(Effect.fail(abortError(signal)))
|
||||
signal.addEventListener("abort", onabort, { once: true })
|
||||
return Effect.sync(() => signal.removeEventListener("abort", onabort))
|
||||
})
|
||||
|
||||
const normalizeStdin = (
|
||||
input: string | Uint8Array | Stream.Stream<Uint8Array, PlatformError>,
|
||||
): Stream.Stream<Uint8Array, PlatformError> =>
|
||||
typeof input === "string"
|
||||
? Stream.make(new TextEncoder().encode(input))
|
||||
: input instanceof Uint8Array
|
||||
? Stream.make(input)
|
||||
: input
|
||||
|
||||
const collectStream = (stream: Stream.Stream<Uint8Array, PlatformError>, maxOutputBytes: number | undefined) =>
|
||||
Stream.runFold(
|
||||
stream,
|
||||
() => ({ chunks: [] as Uint8Array[], bytes: 0, truncated: false }),
|
||||
(acc, chunk) => {
|
||||
if (maxOutputBytes === undefined) {
|
||||
acc.chunks.push(chunk)
|
||||
acc.bytes += chunk.length
|
||||
return acc
|
||||
}
|
||||
const remaining = maxOutputBytes - acc.bytes
|
||||
if (remaining > 0) acc.chunks.push(remaining >= chunk.length ? chunk : chunk.slice(0, remaining))
|
||||
acc.bytes += chunk.length
|
||||
acc.truncated = acc.truncated || acc.bytes > maxOutputBytes
|
||||
return acc
|
||||
},
|
||||
).pipe(Effect.map((x) => ({ buffer: Buffer.concat(x.chunks), truncated: x.truncated })))
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
|
||||
const runCommand = (command: ChildProcess.Command, options?: RunOptions) => {
|
||||
const description = describeCommand(command)
|
||||
const collect = Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* spawner.spawn(command)
|
||||
const [stdout, stderr, exitCode] = yield* Effect.all(
|
||||
[
|
||||
collectStream(handle.stdout, options?.maxOutputBytes),
|
||||
collectStream(handle.stderr, options?.maxErrorBytes),
|
||||
handle.exitCode,
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return {
|
||||
command: description,
|
||||
exitCode,
|
||||
stdout: stdout.buffer,
|
||||
stderr: stderr.buffer,
|
||||
truncated: stdout.truncated,
|
||||
} satisfies RunResult
|
||||
}),
|
||||
)
|
||||
const timed = options?.timeout
|
||||
? Effect.timeoutOrElse(collect, {
|
||||
duration: options.timeout,
|
||||
orElse: () => Effect.fail(new AppProcessError({ command: description, cause: new Error("Timed out") })),
|
||||
})
|
||||
: collect
|
||||
const aborted = options?.signal
|
||||
? timed.pipe(
|
||||
Effect.raceFirst(
|
||||
waitForAbort(options.signal).pipe(Effect.mapError((cause) => wrapError(description, cause))),
|
||||
),
|
||||
)
|
||||
: timed
|
||||
return aborted.pipe(Effect.catch((cause) => Effect.fail(wrapError(description, cause))))
|
||||
}
|
||||
|
||||
const run = Effect.fn("AppProcess.run")(function* (command: ChildProcess.Command, options?: RunOptions) {
|
||||
if (options?.stdin === undefined) return yield* runCommand(command, options)
|
||||
if (command._tag !== "StandardCommand") {
|
||||
return yield* new AppProcessError({
|
||||
command: describeCommand(command),
|
||||
cause: new Error("stdin option only supports StandardCommand; received PipedCommand"),
|
||||
})
|
||||
}
|
||||
const next = ChildProcess.make(command.command, command.args, {
|
||||
...command.options,
|
||||
stdin: normalizeStdin(options.stdin),
|
||||
})
|
||||
return yield* runCommand(next, options)
|
||||
})
|
||||
|
||||
const runStream = (
|
||||
command: ChildProcess.Command,
|
||||
options?: RunStreamOptions,
|
||||
): Stream.Stream<string, AppProcessError> => {
|
||||
const description = describeCommand(command)
|
||||
const okExitCodes = options?.okExitCodes
|
||||
const built: Stream.Stream<string, AppProcessError | PlatformError> = Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* spawner.spawn(command)
|
||||
const stderrFiber = yield* Effect.forkScoped(
|
||||
collectStream(handle.stderr, options?.maxErrorBytes).pipe(Effect.map((x) => x.buffer.toString("utf8"))),
|
||||
)
|
||||
const source = options?.includeStderr === true ? handle.all : handle.stdout
|
||||
const lines = source.pipe(
|
||||
Stream.decodeText,
|
||||
Stream.splitLines,
|
||||
Stream.filter((line) => line.length > 0),
|
||||
)
|
||||
const tail = Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const code = yield* handle.exitCode
|
||||
if (okExitCodes && okExitCodes.length > 0 && !okExitCodes.includes(code)) {
|
||||
const stderr = yield* Fiber.join(stderrFiber)
|
||||
return Stream.fail(new AppProcessError({ command: description, exitCode: code, stderr }))
|
||||
}
|
||||
return Stream.empty
|
||||
}),
|
||||
)
|
||||
return Stream.concat(lines, tail) as Stream.Stream<string, AppProcessError | PlatformError>
|
||||
}),
|
||||
)
|
||||
const mapped = built.pipe(
|
||||
Stream.catch((cause): Stream.Stream<string, AppProcessError> => Stream.fail(wrapError(description, cause))),
|
||||
)
|
||||
if (!options?.signal) return mapped
|
||||
const signal = options.signal
|
||||
return mapped.pipe(
|
||||
Stream.interruptWhen(waitForAbort(signal).pipe(Effect.mapError((cause) => wrapError(description, cause)))),
|
||||
)
|
||||
}
|
||||
|
||||
return Service.of({ ...spawner, run, runStream })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
export * as AppProcess from "./process"
|
||||
@@ -1,120 +0,0 @@
|
||||
export * as ProviderV2 from "./provider"
|
||||
|
||||
import { withStatics } from "./schema"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const ID = Schema.String.pipe(
|
||||
Schema.brand("ProviderV2.ID"),
|
||||
withStatics((schema) => ({
|
||||
// Well-known providers
|
||||
opencode: schema.make("opencode"),
|
||||
anthropic: schema.make("anthropic"),
|
||||
openai: schema.make("openai"),
|
||||
google: schema.make("google"),
|
||||
googleVertex: schema.make("google-vertex"),
|
||||
githubCopilot: schema.make("github-copilot"),
|
||||
amazonBedrock: schema.make("amazon-bedrock"),
|
||||
azure: schema.make("azure"),
|
||||
openrouter: schema.make("openrouter"),
|
||||
mistral: schema.make("mistral"),
|
||||
gitlab: schema.make("gitlab"),
|
||||
})),
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
const OpenAIResponses = Schema.Struct({
|
||||
type: Schema.Literal("openai/responses"),
|
||||
url: Schema.String,
|
||||
websocket: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
|
||||
const OpenAICompletions = Schema.Struct({
|
||||
type: Schema.Literal("openai/completions"),
|
||||
url: Schema.String,
|
||||
reasoning: Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("reasoning_content"),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("reasoning_details"),
|
||||
}),
|
||||
]).pipe(Schema.optional),
|
||||
})
|
||||
export type OpenAICompletions = typeof OpenAICompletions.Type
|
||||
|
||||
const AISDK = Schema.Struct({
|
||||
type: Schema.Literal("aisdk"),
|
||||
package: Schema.String,
|
||||
url: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const AnthropicMessages = Schema.Struct({
|
||||
type: Schema.Literal("anthropic/messages"),
|
||||
url: Schema.String,
|
||||
})
|
||||
|
||||
const UnknownEndpoint = Schema.Struct({
|
||||
type: Schema.Literal("unknown"),
|
||||
})
|
||||
|
||||
export const Endpoint = Schema.Union([
|
||||
UnknownEndpoint,
|
||||
OpenAIResponses,
|
||||
OpenAICompletions,
|
||||
AnthropicMessages,
|
||||
AISDK,
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Endpoint = typeof Endpoint.Type
|
||||
|
||||
export const Options = Schema.Struct({
|
||||
headers: Schema.Record(Schema.String, Schema.String),
|
||||
body: Schema.Record(Schema.String, Schema.Any),
|
||||
aisdk: Schema.Struct({
|
||||
provider: Schema.Record(Schema.String, Schema.Any),
|
||||
request: Schema.Record(Schema.String, Schema.Any),
|
||||
}),
|
||||
})
|
||||
export type Options = typeof Options.Type
|
||||
|
||||
export class Info extends Schema.Class<Info>("ProviderV2.Info")({
|
||||
id: ID,
|
||||
name: Schema.String,
|
||||
enabled: Schema.Union([
|
||||
Schema.Literal(false),
|
||||
Schema.Struct({
|
||||
via: Schema.Literal("env"),
|
||||
name: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
via: Schema.Literal("auth"),
|
||||
service: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
via: Schema.Literal("custom"),
|
||||
data: Schema.Record(Schema.String, Schema.Any),
|
||||
}),
|
||||
]),
|
||||
env: Schema.String.pipe(Schema.Array),
|
||||
endpoint: Endpoint,
|
||||
options: Options,
|
||||
}) {
|
||||
static empty(providerID: ID) {
|
||||
return new Info({
|
||||
id: providerID,
|
||||
name: providerID,
|
||||
enabled: false,
|
||||
env: [],
|
||||
endpoint: {
|
||||
type: "unknown",
|
||||
},
|
||||
options: {
|
||||
headers: {},
|
||||
body: {},
|
||||
aisdk: {
|
||||
provider: {},
|
||||
request: {},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ const levelPriority: Record<Level, number> = {
|
||||
ERROR: 3,
|
||||
}
|
||||
const keep = 10
|
||||
const initializedRunID = "OPENCODE_LOG_INITIALIZED_RUN_ID"
|
||||
|
||||
let level: Level = "INFO"
|
||||
|
||||
@@ -71,10 +70,7 @@ export async function init(options: Options) {
|
||||
Global.Path.log,
|
||||
options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
|
||||
)
|
||||
const runID = process.env.OPENCODE_RUN_ID
|
||||
const shouldTruncate = !options.dev || !runID || process.env[initializedRunID] !== runID
|
||||
if (shouldTruncate) await fs.truncate(logpath).catch(() => {})
|
||||
if (options.dev && runID) process.env[initializedRunID] = runID
|
||||
await fs.truncate(logpath).catch(() => {})
|
||||
const stream = createWriteStream(logpath, { flags: "a" })
|
||||
write = async (msg: any) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
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()
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,277 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { realpathSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { Effect, Exit, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(AppProcess.defaultLayer)
|
||||
|
||||
const NODE = process.execPath
|
||||
const cmd = (...args: string[]) => ChildProcess.make(NODE, args)
|
||||
|
||||
describe("AppProcess", () => {
|
||||
describe("run", () => {
|
||||
it.effect(
|
||||
"captures stdout and exit code zero",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc.run(cmd("-e", "process.stdout.write('hi\\n')"))
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.toString("utf8")).toBe("hi\n")
|
||||
expect(result.truncated).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"non-zero exit returns RunResult; caller can require success",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc.run(cmd("-e", "process.exit(1)"))
|
||||
expect(result.exitCode).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"requireSuccess fails on non-zero exit",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const exit = yield* Effect.exit(
|
||||
svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(AppProcess.requireSuccess)),
|
||||
)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const reason = exit.cause.reasons[0]
|
||||
if (reason && reason._tag === "Fail") {
|
||||
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
|
||||
expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(1)
|
||||
} else {
|
||||
throw new Error("expected fail reason")
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"requireSuccess succeeds on exit 0",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc.run(cmd("-e", "process.exit(0)")).pipe(Effect.flatMap(AppProcess.requireSuccess))
|
||||
expect(result.exitCode).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"requireExitIn allowlists multiple exit codes",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const requireZeroOrOne = AppProcess.requireExitIn([0, 1])
|
||||
const okZero = yield* svc.run(cmd("-e", "process.exit(0)")).pipe(Effect.flatMap(requireZeroOrOne))
|
||||
expect(okZero.exitCode).toBe(0)
|
||||
const okOne = yield* svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(requireZeroOrOne))
|
||||
expect(okOne.exitCode).toBe(1)
|
||||
const exit = yield* Effect.exit(svc.run(cmd("-e", "process.exit(2)")).pipe(Effect.flatMap(requireZeroOrOne)))
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const reason = exit.cause.reasons[0]
|
||||
if (reason && reason._tag === "Fail") {
|
||||
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
|
||||
expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(2)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"truncates output when maxOutputBytes is set",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc.run(cmd("-e", "process.stdout.write('0123456789')"), { maxOutputBytes: 5 })
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.stdout.length).toBe(5)
|
||||
expect(result.stdout.toString("utf8")).toBe("01234")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"result includes command description",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc.run(cmd("-e", "process.stdout.write('hi')"))
|
||||
expect(result.command).toBe(`${NODE} -e process.stdout.write('hi')`)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("inherited platform methods", () => {
|
||||
it.effect(
|
||||
"string returns stdout as string",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const out = yield* svc.string(cmd("-e", "process.stdout.write('hi\\n')"))
|
||||
expect(out).toBe("hi\n")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"lines returns the platform's array of lines",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const out = yield* svc.lines(cmd("-e", "process.stdout.write('a\\nb\\n')"))
|
||||
expect(Array.from(out)).toEqual(["a", "b"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("run with stdin option", () => {
|
||||
const echoStdin = "process.stdin.on('data', c => process.stdout.write(c))"
|
||||
|
||||
it.effect(
|
||||
"feeds a string to stdin and returns it on stdout",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc.run(cmd("-e", echoStdin), { stdin: "hello" })
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.toString("utf8")).toBe("hello")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"feeds a Uint8Array to stdin",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const bytes = new TextEncoder().encode("bytes")
|
||||
const result = yield* svc.run(cmd("-e", echoStdin), { stdin: bytes })
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.toString("utf8")).toBe("bytes")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"feeds a Stream of Uint8Array chunks to stdin",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const enc = new TextEncoder()
|
||||
const stream = Stream.fromIterable([enc.encode("one"), enc.encode("-two"), enc.encode("-three")])
|
||||
const result = yield* svc.run(cmd("-e", echoStdin), { stdin: stream })
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.toString("utf8")).toBe("one-two-three")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"completes correctly with empty input",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc.run(cmd("-e", echoStdin), { stdin: "" })
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.toString("utf8")).toBe("")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"carries existing Command options like env",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const script =
|
||||
"process.stdout.write(process.env.FEED + ':'); process.stdin.on('data', c => process.stdout.write(c))"
|
||||
const command = ChildProcess.make(NODE, ["-e", script], { env: { FEED: "envset" }, extendEnv: true })
|
||||
const result = yield* svc.run(command, { stdin: "payload" })
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.toString("utf8")).toBe("envset:payload")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"carries existing Command options like cwd",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const dir = realpathSync(tmpdir())
|
||||
const script =
|
||||
"process.stdout.write(process.cwd() + '|'); process.stdin.on('data', c => process.stdout.write(c))"
|
||||
const command = ChildProcess.make(NODE, ["-e", script], { cwd: dir })
|
||||
const result = yield* svc.run(command, { stdin: "ok" })
|
||||
expect(result.exitCode).toBe(0)
|
||||
const [cwd, stdin] = result.stdout.toString("utf8").split("|")
|
||||
expect(realpathSync(cwd)).toBe(dir)
|
||||
expect(stdin).toBe("ok")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("runStream", () => {
|
||||
it.live(
|
||||
"emits lines incrementally and ends cleanly on exit 0",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc
|
||||
.runStream(cmd("-e", "console.log('one'); console.log('two'); console.log('three')"))
|
||||
.pipe(Stream.runCollect)
|
||||
expect(Array.from(result)).toEqual(["one", "two", "three"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"okExitCodes determines whether a non-zero exit fails the stream",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const allowed = yield* svc
|
||||
.runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] })
|
||||
.pipe(Stream.runCollect)
|
||||
expect(Array.from(allowed)).toEqual(["only"])
|
||||
const exit = yield* Effect.exit(
|
||||
svc
|
||||
.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0, 1] })
|
||||
.pipe(Stream.runCollect),
|
||||
)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const reason = exit.cause.reasons[0]
|
||||
if (reason && reason._tag === "Fail") {
|
||||
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"without okExitCodes, never fails on exit code",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc.runStream(cmd("-e", "console.log('only'); process.exit(7)")).pipe(Stream.runCollect)
|
||||
expect(Array.from(result)).toEqual(["only"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"AbortSignal interrupts the stream",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const exit = yield* Effect.exit(
|
||||
svc
|
||||
.runStream(cmd("-e", "setInterval(() => {}, 60_000)"), { signal: controller.signal })
|
||||
.pipe(Stream.runCollect),
|
||||
)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("spawn (inherited)", () => {
|
||||
it.live(
|
||||
"returns the platform ChildProcessHandle for advanced use",
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const handle = yield* svc.spawn(cmd("-e", "setInterval(() => {}, 1_000)"))
|
||||
expect(yield* handle.isRunning).toBe(true)
|
||||
yield* handle.kill()
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,199 +0,0 @@
|
||||
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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,9 +0,0 @@
|
||||
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 }
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,465 +0,0 @@
|
||||
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()
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,91 +0,0 @@
|
||||
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"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,127 +0,0 @@
|
||||
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",
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,245 +0,0 @@
|
||||
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"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,102 +0,0 @@
|
||||
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()
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,384 +0,0 @@
|
||||
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)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -1,267 +0,0 @@
|
||||
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()
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
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"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,129 +0,0 @@
|
||||
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"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,172 +0,0 @@
|
||||
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" } })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,87 +0,0 @@
|
||||
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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,346 +0,0 @@
|
||||
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 } }],
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,147 +0,0 @@
|
||||
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()
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,300 +0,0 @@
|
||||
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"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user